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