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