]>
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')) | |
9 | const spawn = require('child_process').spawn | |
10 | const path = require('path') | |
11 | const retryPromise = require('promise-retry') | |
12 | ||
17acb575 AM |
13 | const ffiModuleRegex = /\/\/\s+module\s+([\w\.]+)/i |
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') | |
40 | ], | |
41 | ffi: [ | |
42 | path.join('src', '**', '*.js'), | |
43 | path.join('bower_components', 'purescript-*', 'src', '**', '*.js') | |
44 | ], | |
45 | }, webpackOptions, query) | |
46 | ||
47 | this.cacheable && this.cacheable() | |
48 | ||
49 | let cache = config.purescriptLoaderCache = config.purescriptLoaderCache || { | |
50 | rebuild: false, | |
51 | deferred: [], | |
52 | bundleModules: [], | |
53 | } | |
54 | ||
55 | if (!config.purescriptLoaderInstalled) { | |
56 | config.purescriptLoaderInstalled = true | |
57 | ||
58 | // invalidate loader cache when bundle is marked as invalid (in watch mode) | |
59 | this._compiler.plugin('invalid', () => { | |
60 | cache = config.purescriptLoaderCache = { | |
5163b5a2 | 61 | rebuild: options.pscIde, |
7de41f10 AM |
62 | deferred: [], |
63 | ideServer: cache.ideServer | |
64 | } | |
65 | }) | |
66 | ||
67 | // add psc warnings to webpack compilation warnings | |
68 | this._compiler.plugin('after-compile', (compilation, callback) => { | |
9dc10210 AM |
69 | if (options.warnings && cache.warnings) { |
70 | compilation.warnings.unshift(`PureScript compilation:\n${cache.warnings}`) | |
7de41f10 AM |
71 | } |
72 | ||
9dc10210 AM |
73 | if (cache.errors) { |
74 | compilation.errors.unshift(`PureScript compilation:\n${cache.errors}`) | |
7de41f10 AM |
75 | } |
76 | ||
77 | callback() | |
78 | }) | |
79 | } | |
80 | ||
17acb575 | 81 | const psModuleName = match(srcModuleRegex, source) |
7de41f10 AM |
82 | const psModule = { |
83 | name: psModuleName, | |
84 | load: js => callback(null, js), | |
85 | reject: error => callback(error), | |
86 | srcPath: this.resourcePath, | |
87 | srcDir: path.dirname(this.resourcePath), | |
88 | jsPath: path.resolve(path.join(options.output, psModuleName, 'index.js')), | |
89 | options: options, | |
90 | cache: cache, | |
91 | } | |
92 | ||
67888496 AM |
93 | debug('loader called', psModule.name) |
94 | ||
7de41f10 AM |
95 | if (options.bundle) { |
96 | cache.bundleModules.push(psModule.name) | |
97 | } | |
98 | ||
99 | if (cache.rebuild) { | |
100 | return connectIdeServer(psModule) | |
101 | .then(rebuild) | |
102 | .then(toJavaScript) | |
103 | .then(psModule.load) | |
104 | .catch(psModule.reject) | |
105 | } | |
106 | ||
67888496 | 107 | if (cache.compilationFinished) { |
7de41f10 AM |
108 | return toJavaScript(psModule).then(psModule.load).catch(psModule.reject) |
109 | } | |
110 | ||
111 | // We need to wait for compilation to finish before the loaders run so that | |
112 | // references to compiled output are valid. | |
113 | cache.deferred.push(psModule) | |
114 | ||
67888496 | 115 | if (!cache.compilationStarted) { |
7de41f10 AM |
116 | return compile(psModule) |
117 | .then(() => Promise.map(cache.deferred, psModule => { | |
118 | if (typeof cache.ideServer === 'object') cache.ideServer.kill() | |
119 | return toJavaScript(psModule).then(psModule.load) | |
120 | })) | |
121 | .catch(error => { | |
122 | cache.deferred[0].reject(error) | |
123 | cache.deferred.slice(1).forEach(psModule => psModule.reject(true)) | |
124 | }) | |
125 | } | |
126 | } | |
127 | ||
128 | // The actual loader is executed *after* purescript compilation. | |
129 | function toJavaScript(psModule) { | |
130 | const options = psModule.options | |
131 | const cache = psModule.cache | |
132 | const bundlePath = path.resolve(options.bundleOutput) | |
133 | const jsPath = cache.bundle ? bundlePath : psModule.jsPath | |
134 | ||
67888496 | 135 | debug('loading JavaScript for', psModule.name) |
7de41f10 AM |
136 | |
137 | return Promise.props({ | |
138 | js: fs.readFileAsync(jsPath, 'utf8'), | |
17acb575 | 139 | psModuleMap: psModuleMap(options, cache) |
7de41f10 AM |
140 | }).then(result => { |
141 | let js = '' | |
142 | ||
143 | if (options.bundle) { | |
144 | // if bundling, return a reference to the bundle | |
145 | js = 'module.exports = require("' | |
146 | + path.relative(psModule.srcDir, options.bundleOutput) | |
147 | + '")["' + psModule.name + '"]' | |
148 | } else { | |
149 | // replace require paths to output files generated by psc with paths | |
150 | // to purescript sources, which are then also run through this loader. | |
7de41f10 AM |
151 | js = result.js |
152 | .replace(requireRegex, (m, p1) => { | |
17acb575 AM |
153 | return 'require("' + result.psModuleMap[p1].src + '")' |
154 | }) | |
155 | .replace(/require\(['"]\.\/foreign['"]\)/g, (m, p1) => { | |
156 | return 'require("' + result.psModuleMap[psModule.name].ffi + '")' | |
7de41f10 | 157 | }) |
7de41f10 AM |
158 | } |
159 | ||
160 | return js | |
161 | }) | |
162 | } | |
163 | ||
164 | function compile(psModule) { | |
165 | const options = psModule.options | |
166 | const cache = psModule.cache | |
167 | const stderr = [] | |
168 | ||
67888496 | 169 | if (cache.compilationStarted) return Promise.resolve(psModule) |
7de41f10 | 170 | |
67888496 | 171 | cache.compilationStarted = true |
7de41f10 AM |
172 | |
173 | const args = dargs(Object.assign({ | |
174 | _: options.src, | |
175 | ffi: options.ffi, | |
176 | output: options.output, | |
177 | }, options.pscArgs)) | |
178 | ||
179 | debug('spawning compiler %s %o', options.psc, args) | |
180 | ||
181 | return (new Promise((resolve, reject) => { | |
182 | console.log('\nCompiling PureScript...') | |
183 | ||
184 | const compilation = spawn(options.psc, args) | |
185 | ||
9dc10210 | 186 | compilation.stdout.on('data', data => stderr.push(data.toString())) |
7de41f10 AM |
187 | compilation.stderr.on('data', data => stderr.push(data.toString())) |
188 | ||
189 | compilation.on('close', code => { | |
190 | console.log('Finished compiling PureScript.') | |
67888496 | 191 | cache.compilationFinished = true |
7de41f10 | 192 | if (code !== 0) { |
9dc10210 | 193 | cache.errors = stderr.join('') |
7de41f10 AM |
194 | reject(true) |
195 | } else { | |
9dc10210 | 196 | cache.warnings = stderr.join('') |
7de41f10 AM |
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 => { | |
5163b5a2 | 220 | let res = null |
7de41f10 | 221 | |
5163b5a2 AM |
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)) { | |
7de41f10 AM |
230 | return res.resultType === 'success' |
231 | ? resolve(psModule) | |
5163b5a2 | 232 | : reject('psc-ide rebuild failed') |
7de41f10 AM |
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') { | |
5163b5a2 AM |
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 | } | |
9dc10210 | 248 | cache.errors = compileMessages.join('\n') |
5163b5a2 | 249 | reject('psc-ide rebuild failed') |
7de41f10 | 250 | } else { |
9dc10210 | 251 | cache.warnings = compileMessages.join('\n') |
7de41f10 AM |
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 | } | |
7de41f10 AM |
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) { | |
9dc10210 | 349 | cache.errors = (cache.errors || '') + stderr.join('') |
7de41f10 AM |
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 | |
17acb575 | 359 | function psModuleMap(options, cache) { |
7de41f10 AM |
360 | if (cache.psModuleMap) return Promise.resolve(cache.psModuleMap) |
361 | ||
17acb575 AM |
362 | const globs = [].concat(options.src).concat(options.ffi) |
363 | ||
7de41f10 AM |
364 | return globby(globs).then(paths => { |
365 | return Promise | |
366 | .props(paths.reduce((map, file) => { | |
367 | map[file] = fs.readFileAsync(file, 'utf8') | |
368 | return map | |
369 | }, {})) | |
17acb575 AM |
370 | .then(fileMap => { |
371 | cache.psModuleMap = Object.keys(fileMap).reduce((map, file) => { | |
372 | const source = fileMap[file] | |
373 | const ext = path.extname(file) | |
374 | const isPurs = ext.match(/purs$/i) | |
375 | const moduleRegex = isPurs ? srcModuleRegex : ffiModuleRegex | |
376 | const moduleName = match(moduleRegex, source) | |
377 | map[moduleName] = map[moduleName] || {} | |
378 | if (isPurs) { | |
379 | map[moduleName].src = path.resolve(file) | |
380 | } else { | |
381 | map[moduleName].ffi = path.resolve(file) | |
382 | } | |
7de41f10 AM |
383 | return map |
384 | }, {}) | |
385 | return cache.psModuleMap | |
386 | }) | |
387 | }) | |
388 | } | |
389 | ||
390 | function connectIdeServer(psModule) { | |
391 | const options = psModule.options | |
392 | const cache = psModule.cache | |
393 | ||
394 | if (cache.ideServer) return Promise.resolve(psModule) | |
395 | ||
396 | cache.ideServer = true | |
397 | ||
398 | const connect = () => new Promise((resolve, reject) => { | |
399 | const args = dargs(options.pscIdeArgs) | |
400 | ||
401 | debug('attempting to connect to psc-ide-server', args) | |
402 | ||
403 | const ideClient = spawn('psc-ide-client', args) | |
404 | ||
405 | ideClient.stderr.on('data', data => { | |
406 | debug(data.toString()) | |
407 | cache.ideServer = false | |
408 | reject(true) | |
409 | }) | |
410 | ideClient.stdout.once('data', data => { | |
411 | debug(data.toString()) | |
412 | if (data.toString()[0] === '{') { | |
413 | const res = JSON.parse(data.toString()) | |
414 | if (res.resultType === 'success') { | |
415 | cache.ideServer = ideServer | |
416 | resolve(psModule) | |
417 | } else { | |
418 | cache.ideServer = ideServer | |
419 | reject(true) | |
420 | } | |
421 | } else { | |
422 | cache.ideServer = false | |
423 | reject(true) | |
424 | } | |
425 | }) | |
426 | ideClient.stdin.resume() | |
427 | ideClient.stdin.write(JSON.stringify({ command: 'load' })) | |
428 | ideClient.stdin.write('\n') | |
429 | }) | |
430 | ||
431 | const args = dargs(Object.assign({ | |
432 | outputDirectory: options.output, | |
433 | }, options.pscIdeArgs)) | |
434 | ||
435 | debug('attempting to start psc-ide-server', args) | |
436 | ||
437 | const ideServer = cache.ideServer = spawn('psc-ide-server', []) | |
438 | ideServer.stderr.on('data', data => { | |
439 | debug(data.toString()) | |
440 | }) | |
441 | ||
442 | return retryPromise((retry, number) => { | |
443 | return connect().catch(error => { | |
444 | if (!cache.ideServer && number === 9) { | |
445 | debug(error) | |
446 | ||
447 | console.log( | |
448 | 'failed to connect to or start psc-ide-server, ' + | |
449 | 'full compilation will occur on rebuild' | |
450 | ) | |
451 | ||
452 | return Promise.resolve(psModule) | |
453 | } | |
454 | ||
455 | return retry(error) | |
456 | }) | |
457 | }, { | |
458 | retries: 9, | |
459 | factor: 1, | |
460 | minTimeout: 333, | |
461 | maxTimeout: 333, | |
462 | }) | |
463 | } | |
464 | ||
465 | function match(regex, str) { | |
466 | const matches = str.match(regex) | |
467 | return matches && matches[1] | |
468 | } | |
469 | ||
470 | function dargs(obj) { | |
471 | return Object.keys(obj).reduce((args, key) => { | |
472 | const arg = '--' + key.replace(/[A-Z]/g, '-$&').toLowerCase(); | |
473 | const val = obj[key] | |
474 | ||
475 | if (key === '_') val.forEach(v => args.push(v)) | |
476 | else if (Array.isArray(val)) val.forEach(v => args.push(arg, v)) | |
477 | else args.push(arg, obj[key]) | |
478 | ||
2d052df7 | 479 | return args.filter(arg => (typeof arg !== 'boolean')) |
7de41f10 AM |
480 | }, []) |
481 | } |