]>
Commit | Line | Data |
---|---|---|
1 | 'use strict' | |
2 | ||
3 | const colors = require('chalk') | |
4 | const debug = require('debug')('purs-loader') | |
5 | const loaderUtils = require('loader-utils') | |
6 | const globby = require('globby') | |
7 | const Promise = require('bluebird') | |
8 | const fs = Promise.promisifyAll(require('fs')) | |
9 | const spawn = require('child_process').spawn | |
10 | const path = require('path') | |
11 | const retryPromise = require('promise-retry') | |
12 | ||
13 | const psModuleRegex = /(?:^|\n)module\s+([\w\.]+)/i | |
14 | const requireRegex = /require\(['"]\.\.\/([\w\.]+)['"]\)/g | |
15 | ||
16 | module.exports = function purescriptLoader(source, map) { | |
17 | const callback = this.async() | |
18 | const config = this.options | |
19 | const query = loaderUtils.parseQuery(this.query) | |
20 | const webpackOptions = this.options.purescriptLoader || {} | |
21 | ||
22 | const options = Object.assign({ | |
23 | context: config.context, | |
24 | psc: 'psc', | |
25 | pscArgs: {}, | |
26 | pscBundle: 'psc-bundle', | |
27 | pscBundleArgs: {}, | |
28 | pscIde: false, | |
29 | pscIdeColors: webpackOptions.psc === 'psa' || query.psc === 'psa', | |
30 | pscIdeArgs: {}, | |
31 | bundleOutput: 'output/bundle.js', | |
32 | bundleNamespace: 'PS', | |
33 | bundle: false, | |
34 | warnings: true, | |
35 | output: 'output', | |
36 | src: [ | |
37 | path.join('src', '**', '*.purs'), | |
38 | path.join('bower_components', 'purescript-*', 'src', '**', '*.purs') | |
39 | ], | |
40 | ffi: [ | |
41 | path.join('src', '**', '*.js'), | |
42 | path.join('bower_components', 'purescript-*', 'src', '**', '*.js') | |
43 | ], | |
44 | }, webpackOptions, query) | |
45 | ||
46 | this.cacheable && this.cacheable() | |
47 | ||
48 | let cache = config.purescriptLoaderCache = config.purescriptLoaderCache || { | |
49 | rebuild: false, | |
50 | deferred: [], | |
51 | bundleModules: [], | |
52 | } | |
53 | ||
54 | if (!config.purescriptLoaderInstalled) { | |
55 | config.purescriptLoaderInstalled = true | |
56 | ||
57 | // invalidate loader cache when bundle is marked as invalid (in watch mode) | |
58 | this._compiler.plugin('invalid', () => { | |
59 | cache = config.purescriptLoaderCache = { | |
60 | rebuild: options.pscIde, | |
61 | deferred: [], | |
62 | ideServer: cache.ideServer | |
63 | } | |
64 | }) | |
65 | ||
66 | // add psc warnings to webpack compilation warnings | |
67 | this._compiler.plugin('after-compile', (compilation, callback) => { | |
68 | if (options.warnings && cache.warnings && cache.warnings.length) { | |
69 | compilation.warnings.unshift(`PureScript compilation:\n${cache.warnings.join('')}`) | |
70 | } | |
71 | ||
72 | if (cache.errors && cache.errors.length) { | |
73 | compilation.errors.unshift(`PureScript compilation:\n${cache.errors.join('\n')}`) | |
74 | } | |
75 | ||
76 | callback() | |
77 | }) | |
78 | } | |
79 | ||
80 | const psModuleName = match(psModuleRegex, source) | |
81 | const psModule = { | |
82 | name: psModuleName, | |
83 | load: js => callback(null, js), | |
84 | reject: error => callback(error), | |
85 | srcPath: this.resourcePath, | |
86 | srcDir: path.dirname(this.resourcePath), | |
87 | jsPath: path.resolve(path.join(options.output, psModuleName, 'index.js')), | |
88 | options: options, | |
89 | cache: cache, | |
90 | } | |
91 | ||
92 | if (options.bundle) { | |
93 | cache.bundleModules.push(psModule.name) | |
94 | } | |
95 | ||
96 | if (cache.rebuild) { | |
97 | return connectIdeServer(psModule) | |
98 | .then(rebuild) | |
99 | .then(toJavaScript) | |
100 | .then(psModule.load) | |
101 | .catch(psModule.reject) | |
102 | } | |
103 | ||
104 | if (cache.compilation && cache.compilation.length) { | |
105 | return toJavaScript(psModule).then(psModule.load).catch(psModule.reject) | |
106 | } | |
107 | ||
108 | // We need to wait for compilation to finish before the loaders run so that | |
109 | // references to compiled output are valid. | |
110 | cache.deferred.push(psModule) | |
111 | ||
112 | if (!cache.compilation) { | |
113 | return compile(psModule) | |
114 | .then(() => Promise.map(cache.deferred, psModule => { | |
115 | if (typeof cache.ideServer === 'object') cache.ideServer.kill() | |
116 | return toJavaScript(psModule).then(psModule.load) | |
117 | })) | |
118 | .catch(error => { | |
119 | cache.deferred[0].reject(error) | |
120 | cache.deferred.slice(1).forEach(psModule => psModule.reject(true)) | |
121 | }) | |
122 | } | |
123 | } | |
124 | ||
125 | // The actual loader is executed *after* purescript compilation. | |
126 | function toJavaScript(psModule) { | |
127 | const options = psModule.options | |
128 | const cache = psModule.cache | |
129 | const bundlePath = path.resolve(options.bundleOutput) | |
130 | const jsPath = cache.bundle ? bundlePath : psModule.jsPath | |
131 | ||
132 | debug('loading JavaScript for', psModule.srcPath) | |
133 | ||
134 | return Promise.props({ | |
135 | js: fs.readFileAsync(jsPath, 'utf8'), | |
136 | psModuleMap: psModuleMap(options.src, cache) | |
137 | }).then(result => { | |
138 | let js = '' | |
139 | ||
140 | if (options.bundle) { | |
141 | // if bundling, return a reference to the bundle | |
142 | js = 'module.exports = require("' | |
143 | + path.relative(psModule.srcDir, options.bundleOutput) | |
144 | + '")["' + psModule.name + '"]' | |
145 | } else { | |
146 | // replace require paths to output files generated by psc with paths | |
147 | // to purescript sources, which are then also run through this loader. | |
148 | const foreignRequire = 'require("' + path.resolve( | |
149 | path.join(psModule.options.output, psModule.name, 'foreign.js') | |
150 | ) + '")' | |
151 | ||
152 | js = result.js | |
153 | .replace(requireRegex, (m, p1) => { | |
154 | return 'require("' + result.psModuleMap[p1] + '")' | |
155 | }) | |
156 | .replace(/require\(['"]\.\/foreign['"]\)/g, foreignRequire) | |
157 | } | |
158 | ||
159 | return js | |
160 | }) | |
161 | } | |
162 | ||
163 | function compile(psModule) { | |
164 | const options = psModule.options | |
165 | const cache = psModule.cache | |
166 | const stderr = [] | |
167 | ||
168 | if (cache.compilation) return Promise.resolve(cache.compilation) | |
169 | ||
170 | cache.compilation = [] | |
171 | cache.warnings = [] | |
172 | cache.errors = [] | |
173 | ||
174 | ||
175 | const args = dargs(Object.assign({ | |
176 | _: options.src, | |
177 | ffi: options.ffi, | |
178 | output: options.output, | |
179 | }, options.pscArgs)) | |
180 | ||
181 | debug('spawning compiler %s %o', options.psc, args) | |
182 | ||
183 | return (new Promise((resolve, reject) => { | |
184 | console.log('\nCompiling PureScript...') | |
185 | ||
186 | const compilation = spawn(options.psc, args) | |
187 | ||
188 | compilation.stderr.on('data', data => stderr.push(data.toString())) | |
189 | ||
190 | compilation.on('close', code => { | |
191 | console.log('Finished compiling PureScript.') | |
192 | if (code !== 0) { | |
193 | cache.compilation = cache.errors = stderr | |
194 | reject(true) | |
195 | } else { | |
196 | cache.compilation = cache.warnings = stderr | |
197 | resolve(psModule) | |
198 | } | |
199 | }) | |
200 | })) | |
201 | .then(compilerOutput => { | |
202 | if (options.bundle) { | |
203 | return bundle(options, cache).then(() => psModule) | |
204 | } | |
205 | return psModule | |
206 | }) | |
207 | } | |
208 | ||
209 | function rebuild(psModule) { | |
210 | const options = psModule.options | |
211 | const cache = psModule.cache | |
212 | ||
213 | debug('attempting rebuild with psc-ide-client %s', psModule.srcPath) | |
214 | ||
215 | const request = (body) => new Promise((resolve, reject) => { | |
216 | const args = dargs(options.pscIdeArgs) | |
217 | const ideClient = spawn('psc-ide-client', args) | |
218 | ||
219 | ideClient.stdout.once('data', data => { | |
220 | let res = null | |
221 | ||
222 | try { | |
223 | res = JSON.parse(data.toString()) | |
224 | debug(res) | |
225 | } catch (err) { | |
226 | return reject(err) | |
227 | } | |
228 | ||
229 | if (res && !Array.isArray(res.result)) { | |
230 | return res.resultType === 'success' | |
231 | ? resolve(psModule) | |
232 | : reject('psc-ide rebuild failed') | |
233 | } | |
234 | ||
235 | Promise.map(res.result, (item, i) => { | |
236 | debug(item) | |
237 | return formatIdeResult(item, options, i, res.result.length) | |
238 | }) | |
239 | .then(compileMessages => { | |
240 | if (res.resultType === 'error') { | |
241 | if (res.result.some(item => item.errorCode === 'UnknownModule')) { | |
242 | console.log('Unknown module, attempting full recompile') | |
243 | return compile(psModule) | |
244 | .then(() => request({ command: 'load' })) | |
245 | .then(resolve) | |
246 | .catch(() => reject('psc-ide rebuild failed')) | |
247 | } | |
248 | cache.errors = compileMessages | |
249 | reject('psc-ide rebuild failed') | |
250 | } else { | |
251 | cache.warnings = compileMessages | |
252 | resolve(psModule) | |
253 | } | |
254 | }) | |
255 | }) | |
256 | ||
257 | ideClient.stderr.once('data', data => reject(data.toString())) | |
258 | ||
259 | ideClient.stdin.write(JSON.stringify(body)) | |
260 | ideClient.stdin.write('\n') | |
261 | }) | |
262 | ||
263 | return request({ | |
264 | command: 'rebuild', | |
265 | params: { | |
266 | file: psModule.srcPath, | |
267 | } | |
268 | }) | |
269 | } | |
270 | ||
271 | function formatIdeResult(result, options, index, length) { | |
272 | const srcPath = path.relative(options.context, result.filename) | |
273 | const pos = result.position | |
274 | const fileAndPos = `${srcPath}:${pos.startLine}:${pos.startColumn}` | |
275 | let numAndErr = `[${index+1}/${length} ${result.errorCode}]` | |
276 | numAndErr = options.pscIdeColors ? colors.yellow(numAndErr) : numAndErr | |
277 | ||
278 | return fs.readFileAsync(result.filename, 'utf8').then(source => { | |
279 | const lines = source.split('\n').slice(pos.startLine - 1, pos.endLine) | |
280 | const endsOnNewline = pos.endColumn === 1 && pos.startLine !== pos.endLine | |
281 | const up = options.pscIdeColors ? colors.red('^') : '^' | |
282 | const down = options.pscIdeColors ? colors.red('v') : 'v' | |
283 | let trimmed = lines.slice(0) | |
284 | ||
285 | if (endsOnNewline) { | |
286 | lines.splice(lines.length - 1, 1) | |
287 | pos.endLine = pos.endLine - 1 | |
288 | pos.endColumn = lines[lines.length - 1].length || 1 | |
289 | } | |
290 | ||
291 | // strip newlines at the end | |
292 | if (endsOnNewline) { | |
293 | trimmed = lines.reverse().reduce((trimmed, line, i) => { | |
294 | if (i === 0 && line === '') trimmed.trimming = true | |
295 | if (!trimmed.trimming) trimmed.push(line) | |
296 | if (trimmed.trimming && line !== '') { | |
297 | trimmed.trimming = false | |
298 | trimmed.push(line) | |
299 | } | |
300 | return trimmed | |
301 | }, []).reverse() | |
302 | pos.endLine = pos.endLine - (lines.length - trimmed.length) | |
303 | pos.endColumn = trimmed[trimmed.length - 1].length || 1 | |
304 | } | |
305 | ||
306 | const spaces = ' '.repeat(String(pos.endLine).length) | |
307 | let snippet = trimmed.map((line, i) => { | |
308 | return ` ${pos.startLine + i} ${line}` | |
309 | }).join('\n') | |
310 | ||
311 | if (trimmed.length === 1) { | |
312 | snippet += `\n ${spaces} ${' '.repeat(pos.startColumn - 1)}${up.repeat(pos.endColumn - pos.startColumn + 1)}` | |
313 | } else { | |
314 | snippet = ` ${spaces} ${' '.repeat(pos.startColumn - 1)}${down}\n${snippet}` | |
315 | snippet += `\n ${spaces} ${' '.repeat(pos.endColumn - 1)}${up}` | |
316 | } | |
317 | ||
318 | return Promise.resolve( | |
319 | `\n${numAndErr} ${fileAndPos}\n\n${snippet}\n\n${result.message}` | |
320 | ) | |
321 | }) | |
322 | } | |
323 | ||
324 | function bundle(options, cache) { | |
325 | if (cache.bundle) return Promise.resolve(cache.bundle) | |
326 | ||
327 | const stdout = [] | |
328 | const stderr = cache.bundle = [] | |
329 | ||
330 | const args = dargs(Object.assign({ | |
331 | _: [path.join(options.output, '*', '*.js')], | |
332 | output: options.bundleOutput, | |
333 | namespace: options.bundleNamespace, | |
334 | }, options.pscBundleArgs)) | |
335 | ||
336 | cache.bundleModules.forEach(name => args.push('--module', name)) | |
337 | ||
338 | debug('spawning bundler %s %o', options.pscBundle, args.join(' ')) | |
339 | ||
340 | return (new Promise((resolve, reject) => { | |
341 | console.log('Bundling PureScript...') | |
342 | ||
343 | const compilation = spawn(options.pscBundle, args) | |
344 | ||
345 | compilation.stdout.on('data', data => stdout.push(data.toString())) | |
346 | compilation.stderr.on('data', data => stderr.push(data.toString())) | |
347 | compilation.on('close', code => { | |
348 | if (code !== 0) { | |
349 | cache.errors.concat(stderr) | |
350 | return reject(true) | |
351 | } | |
352 | cache.bundle = stderr | |
353 | resolve(fs.appendFileAsync('output/bundle.js', `module.exports = ${options.bundleNamespace}`)) | |
354 | }) | |
355 | })) | |
356 | } | |
357 | ||
358 | // map of PS module names to their source path | |
359 | function psModuleMap(globs, cache) { | |
360 | if (cache.psModuleMap) return Promise.resolve(cache.psModuleMap) | |
361 | ||
362 | return globby(globs).then(paths => { | |
363 | return Promise | |
364 | .props(paths.reduce((map, file) => { | |
365 | map[file] = fs.readFileAsync(file, 'utf8') | |
366 | return map | |
367 | }, {})) | |
368 | .then(srcMap => { | |
369 | cache.psModuleMap = Object.keys(srcMap).reduce((map, file) => { | |
370 | const source = srcMap[file] | |
371 | const psModuleName = match(psModuleRegex, source) | |
372 | map[psModuleName] = path.resolve(file) | |
373 | return map | |
374 | }, {}) | |
375 | return cache.psModuleMap | |
376 | }) | |
377 | }) | |
378 | } | |
379 | ||
380 | function connectIdeServer(psModule) { | |
381 | const options = psModule.options | |
382 | const cache = psModule.cache | |
383 | ||
384 | if (cache.ideServer) return Promise.resolve(psModule) | |
385 | ||
386 | cache.ideServer = true | |
387 | ||
388 | const connect = () => new Promise((resolve, reject) => { | |
389 | const args = dargs(options.pscIdeArgs) | |
390 | ||
391 | debug('attempting to connect to psc-ide-server', args) | |
392 | ||
393 | const ideClient = spawn('psc-ide-client', args) | |
394 | ||
395 | ideClient.stderr.on('data', data => { | |
396 | debug(data.toString()) | |
397 | cache.ideServer = false | |
398 | reject(true) | |
399 | }) | |
400 | ideClient.stdout.once('data', data => { | |
401 | debug(data.toString()) | |
402 | if (data.toString()[0] === '{') { | |
403 | const res = JSON.parse(data.toString()) | |
404 | if (res.resultType === 'success') { | |
405 | cache.ideServer = ideServer | |
406 | resolve(psModule) | |
407 | } else { | |
408 | cache.ideServer = ideServer | |
409 | reject(true) | |
410 | } | |
411 | } else { | |
412 | cache.ideServer = false | |
413 | reject(true) | |
414 | } | |
415 | }) | |
416 | ideClient.stdin.resume() | |
417 | ideClient.stdin.write(JSON.stringify({ command: 'load' })) | |
418 | ideClient.stdin.write('\n') | |
419 | }) | |
420 | ||
421 | const args = dargs(Object.assign({ | |
422 | outputDirectory: options.output, | |
423 | }, options.pscIdeArgs)) | |
424 | ||
425 | debug('attempting to start psc-ide-server', args) | |
426 | ||
427 | const ideServer = cache.ideServer = spawn('psc-ide-server', []) | |
428 | ideServer.stderr.on('data', data => { | |
429 | debug(data.toString()) | |
430 | }) | |
431 | ||
432 | return retryPromise((retry, number) => { | |
433 | return connect().catch(error => { | |
434 | if (!cache.ideServer && number === 9) { | |
435 | debug(error) | |
436 | ||
437 | console.log( | |
438 | 'failed to connect to or start psc-ide-server, ' + | |
439 | 'full compilation will occur on rebuild' | |
440 | ) | |
441 | ||
442 | return Promise.resolve(psModule) | |
443 | } | |
444 | ||
445 | return retry(error) | |
446 | }) | |
447 | }, { | |
448 | retries: 9, | |
449 | factor: 1, | |
450 | minTimeout: 333, | |
451 | maxTimeout: 333, | |
452 | }) | |
453 | } | |
454 | ||
455 | function match(regex, str) { | |
456 | const matches = str.match(regex) | |
457 | return matches && matches[1] | |
458 | } | |
459 | ||
460 | function dargs(obj) { | |
461 | return Object.keys(obj).reduce((args, key) => { | |
462 | const arg = '--' + key.replace(/[A-Z]/g, '-$&').toLowerCase(); | |
463 | const val = obj[key] | |
464 | ||
465 | if (key === '_') val.forEach(v => args.push(v)) | |
466 | else if (Array.isArray(val)) val.forEach(v => args.push(arg, v)) | |
467 | else args.push(arg, obj[key]) | |
468 | ||
469 | return args | |
470 | }, []) | |
471 | } |