diff options
author | Alex Mingoia <talk@alexmingoia.com> | 2016-05-10 00:09:28 -0700 |
---|---|---|
committer | Alex Mingoia <talk@alexmingoia.com> | 2016-05-10 00:09:28 -0700 |
commit | 7de41f10b4ff0f0d6b45d59bee0f166c3cfe3f9f (patch) | |
tree | 9ee160ba5b7dab900ccd1cfa657760e4103a175e /src/index.js | |
parent | 777472b3830cb3d2ff3390003ea422c6d4522715 (diff) | |
download | purs-loader-7de41f10b4ff0f0d6b45d59bee0f166c3cfe3f9f.tar.gz purs-loader-7de41f10b4ff0f0d6b45d59bee0f166c3cfe3f9f.tar.zst purs-loader-7de41f10b4ff0f0d6b45d59bee0f166c3cfe3f9f.zip |
Refactor to compile independently of purescript-webpack-plugin.
- Remove dependence on purescript-webpack-plugin
- Fixes double-compilation issue by loading compiled JS instead of adding
dependency.
- Uses `psc-ide-server` for fast rebuilds.
Diffstat (limited to 'src/index.js')
-rw-r--r-- | src/index.js | 465 |
1 files changed, 465 insertions, 0 deletions
diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..0a25ccd --- /dev/null +++ b/src/index.js | |||
@@ -0,0 +1,465 @@ | |||
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 | pscIdeColors: webpackOptions.psc === 'psa' || query.psc === 'psa', | ||
29 | pscIdeArgs: {}, | ||
30 | bundleOutput: 'output/bundle.js', | ||
31 | bundleNamespace: 'PS', | ||
32 | bundle: false, | ||
33 | warnings: true, | ||
34 | output: 'output', | ||
35 | src: [ | ||
36 | path.join('src', '**', '*.purs'), | ||
37 | path.join('bower_components', 'purescript-*', 'src', '**', '*.purs') | ||
38 | ], | ||
39 | ffi: [ | ||
40 | path.join('src', '**', '*.js'), | ||
41 | path.join('bower_components', 'purescript-*', 'src', '**', '*.js') | ||
42 | ], | ||
43 | }, webpackOptions, query) | ||
44 | |||
45 | this.cacheable && this.cacheable() | ||
46 | |||
47 | let cache = config.purescriptLoaderCache = config.purescriptLoaderCache || { | ||
48 | rebuild: false, | ||
49 | deferred: [], | ||
50 | bundleModules: [], | ||
51 | } | ||
52 | |||
53 | if (!config.purescriptLoaderInstalled) { | ||
54 | config.purescriptLoaderInstalled = true | ||
55 | |||
56 | // invalidate loader cache when bundle is marked as invalid (in watch mode) | ||
57 | this._compiler.plugin('invalid', () => { | ||
58 | cache = config.purescriptLoaderCache = { | ||
59 | rebuild: true, | ||
60 | deferred: [], | ||
61 | ideServer: cache.ideServer | ||
62 | } | ||
63 | }) | ||
64 | |||
65 | // add psc warnings to webpack compilation warnings | ||
66 | this._compiler.plugin('after-compile', (compilation, callback) => { | ||
67 | if (options.warnings && cache.warnings && cache.warnings.length) { | ||
68 | compilation.warnings.unshift(`PureScript compilation:\n${cache.warnings.join('')}`) | ||
69 | } | ||
70 | |||
71 | if (cache.errors && cache.errors.length) { | ||
72 | compilation.errors.unshift(`PureScript compilation:\n${cache.errors.join('\n')}`) | ||
73 | } | ||
74 | |||
75 | callback() | ||
76 | }) | ||
77 | } | ||
78 | |||
79 | const psModuleName = match(psModuleRegex, source) | ||
80 | const psModule = { | ||
81 | name: psModuleName, | ||
82 | load: js => callback(null, js), | ||
83 | reject: error => callback(error), | ||
84 | srcPath: this.resourcePath, | ||
85 | srcDir: path.dirname(this.resourcePath), | ||
86 | jsPath: path.resolve(path.join(options.output, psModuleName, 'index.js')), | ||
87 | options: options, | ||
88 | cache: cache, | ||
89 | } | ||
90 | |||
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 | |||
103 | if (cache.compilation && cache.compilation.length) { | ||
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 | |||
111 | if (!cache.compilation) { | ||
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 | |||
131 | debug('loading JavaScript for', psModule.srcPath) | ||
132 | |||
133 | return Promise.props({ | ||
134 | js: fs.readFileAsync(jsPath, 'utf8'), | ||
135 | psModuleMap: psModuleMap(options.src, cache) | ||
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("' | ||
142 | + path.relative(psModule.srcDir, options.bundleOutput) | ||
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. | ||
147 | const foreignRequire = 'require("' + path.resolve( | ||
148 | path.join(psModule.options.output, psModule.name, 'foreign.js') | ||
149 | ) + '")' | ||
150 | |||
151 | js = result.js | ||
152 | .replace(requireRegex, (m, p1) => { | ||
153 | return 'require("' + result.psModuleMap[p1] + '")' | ||
154 | }) | ||
155 | .replace(/require\(['"]\.\/foreign['"]\)/g, foreignRequire) | ||
156 | } | ||
157 | |||
158 | return js | ||
159 | }) | ||
160 | } | ||
161 | |||
162 | function compile(psModule) { | ||
163 | const options = psModule.options | ||
164 | const cache = psModule.cache | ||
165 | const stderr = [] | ||
166 | |||
167 | if (cache.compilation) return Promise.resolve(cache.compilation) | ||
168 | |||
169 | cache.compilation = [] | ||
170 | cache.warnings = [] | ||
171 | cache.errors = [] | ||
172 | |||
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.stderr.on('data', data => stderr.push(data.toString())) | ||
188 | |||
189 | compilation.on('close', code => { | ||
190 | console.log('Finished compiling PureScript.') | ||
191 | if (code !== 0) { | ||
192 | cache.compilation = cache.errors = stderr | ||
193 | reject(true) | ||
194 | } else { | ||
195 | cache.compilation = cache.warnings = stderr | ||
196 | resolve(psModule) | ||
197 | } | ||
198 | }) | ||
199 | })) | ||
200 | .then(compilerOutput => { | ||
201 | if (options.bundle) { | ||
202 | return bundle(options, cache).then(() => psModule) | ||
203 | } | ||
204 | return psModule | ||
205 | }) | ||
206 | } | ||
207 | |||
208 | function rebuild(psModule) { | ||
209 | const options = psModule.options | ||
210 | const cache = psModule.cache | ||
211 | |||
212 | debug('attempting rebuild with psc-ide-client %s', psModule.srcPath) | ||
213 | |||
214 | const request = (body) => new Promise((resolve, reject) => { | ||
215 | const args = dargs(options.pscIdeArgs) | ||
216 | const ideClient = spawn('psc-ide-client', args) | ||
217 | |||
218 | ideClient.stdout.once('data', data => { | ||
219 | const res = JSON.parse(data.toString()) | ||
220 | debug(res) | ||
221 | |||
222 | if (!Array.isArray(res.result)) { | ||
223 | return res.resultType === 'success' | ||
224 | ? resolve(psModule) | ||
225 | : reject(res.result) | ||
226 | } | ||
227 | |||
228 | Promise.map(res.result, (item, i) => { | ||
229 | debug(item) | ||
230 | return formatIdeResult(item, options, i, res.result.length) | ||
231 | }) | ||
232 | .then(compileMessages => { | ||
233 | if (res.resultType === 'error') { | ||
234 | cache.errors = compileMessages | ||
235 | reject(res.result) | ||
236 | } else { | ||
237 | cache.warnings = compileMessages | ||
238 | resolve(psModule) | ||
239 | } | ||
240 | }) | ||
241 | }) | ||
242 | |||
243 | ideClient.stderr.once('data', data => reject(data.toString())) | ||
244 | |||
245 | ideClient.stdin.write(JSON.stringify(body)) | ||
246 | ideClient.stdin.write('\n') | ||
247 | }) | ||
248 | |||
249 | return request({ | ||
250 | command: 'rebuild', | ||
251 | params: { | ||
252 | file: psModule.srcPath, | ||
253 | } | ||
254 | }).catch(res => { | ||
255 | if (res.resultType === 'error') { | ||
256 | if (res.result.some(item => item.errorCode === 'UnknownModule')) { | ||
257 | console.log('Unknown module, attempting full recompile') | ||
258 | return compile(psModule).then(() => request({ command: 'load' })) | ||
259 | } | ||
260 | } | ||
261 | return Promise.resolve(psModule) | ||
262 | }) | ||
263 | } | ||
264 | |||
265 | function formatIdeResult(result, options, index, length) { | ||
266 | const srcPath = path.relative(options.context, result.filename) | ||
267 | const pos = result.position | ||
268 | const fileAndPos = `${srcPath}:${pos.startLine}:${pos.startColumn}` | ||
269 | let numAndErr = `[${index+1}/${length} ${result.errorCode}]` | ||
270 | numAndErr = options.pscIdeColors ? colors.yellow(numAndErr) : numAndErr | ||
271 | |||
272 | return fs.readFileAsync(result.filename, 'utf8').then(source => { | ||
273 | const lines = source.split('\n').slice(pos.startLine - 1, pos.endLine) | ||
274 | const endsOnNewline = pos.endColumn === 1 && pos.startLine !== pos.endLine | ||
275 | const up = options.pscIdeColors ? colors.red('^') : '^' | ||
276 | const down = options.pscIdeColors ? colors.red('v') : 'v' | ||
277 | let trimmed = lines.slice(0) | ||
278 | |||
279 | if (endsOnNewline) { | ||
280 | lines.splice(lines.length - 1, 1) | ||
281 | pos.endLine = pos.endLine - 1 | ||
282 | pos.endColumn = lines[lines.length - 1].length || 1 | ||
283 | } | ||
284 | |||
285 | // strip newlines at the end | ||
286 | if (endsOnNewline) { | ||
287 | trimmed = lines.reverse().reduce((trimmed, line, i) => { | ||
288 | if (i === 0 && line === '') trimmed.trimming = true | ||
289 | if (!trimmed.trimming) trimmed.push(line) | ||
290 | if (trimmed.trimming && line !== '') { | ||
291 | trimmed.trimming = false | ||
292 | trimmed.push(line) | ||
293 | } | ||
294 | return trimmed | ||
295 | }, []).reverse() | ||
296 | pos.endLine = pos.endLine - (lines.length - trimmed.length) | ||
297 | pos.endColumn = trimmed[trimmed.length - 1].length || 1 | ||
298 | } | ||
299 | |||
300 | const spaces = ' '.repeat(String(pos.endLine).length) | ||
301 | let snippet = trimmed.map((line, i) => { | ||
302 | return ` ${pos.startLine + i} ${line}` | ||
303 | }).join('\n') | ||
304 | |||
305 | if (trimmed.length === 1) { | ||
306 | snippet += `\n ${spaces} ${' '.repeat(pos.startColumn - 1)}${up.repeat(pos.endColumn - pos.startColumn + 1)}` | ||
307 | } else { | ||
308 | snippet = ` ${spaces} ${' '.repeat(pos.startColumn - 1)}${down}\n${snippet}` | ||
309 | snippet += `\n ${spaces} ${' '.repeat(pos.endColumn - 1)}${up}` | ||
310 | } | ||
311 | |||
312 | return Promise.resolve( | ||
313 | `\n${numAndErr} ${fileAndPos}\n\n${snippet}\n\n${result.message}` | ||
314 | ) | ||
315 | }) | ||
316 | } | ||
317 | |||
318 | function bundle(options, cache) { | ||
319 | if (cache.bundle) return Promise.resolve(cache.bundle) | ||
320 | |||
321 | const stdout = [] | ||
322 | const stderr = cache.bundle = [] | ||
323 | |||
324 | const args = dargs(Object.assign({ | ||
325 | _: [path.join(options.output, '*', '*.js')], | ||
326 | output: options.bundleOutput, | ||
327 | namespace: options.bundleNamespace, | ||
328 | }, options.pscBundleArgs)) | ||
329 | |||
330 | cache.bundleModules.forEach(name => args.push('--module', name)) | ||
331 | |||
332 | debug('spawning bundler %s %o', options.pscBundle, args.join(' ')) | ||
333 | |||
334 | return (new Promise((resolve, reject) => { | ||
335 | console.log('Bundling PureScript...') | ||
336 | |||
337 | const compilation = spawn(options.pscBundle, args) | ||
338 | |||
339 | compilation.stdout.on('data', data => stdout.push(data.toString())) | ||
340 | compilation.stderr.on('data', data => stderr.push(data.toString())) | ||
341 | compilation.on('close', code => { | ||
342 | if (code !== 0) { | ||
343 | cache.errors.concat(stderr) | ||
344 | return reject(true) | ||
345 | } | ||
346 | cache.bundle = stderr | ||
347 | resolve(fs.appendFileAsync('output/bundle.js', `module.exports = ${options.bundleNamespace}`)) | ||
348 | }) | ||
349 | })) | ||
350 | } | ||
351 | |||
352 | // map of PS module names to their source path | ||
353 | function psModuleMap(globs, cache) { | ||
354 | if (cache.psModuleMap) return Promise.resolve(cache.psModuleMap) | ||
355 | |||
356 | return globby(globs).then(paths => { | ||
357 | return Promise | ||
358 | .props(paths.reduce((map, file) => { | ||
359 | map[file] = fs.readFileAsync(file, 'utf8') | ||
360 | return map | ||
361 | }, {})) | ||
362 | .then(srcMap => { | ||
363 | cache.psModuleMap = Object.keys(srcMap).reduce((map, file) => { | ||
364 | const source = srcMap[file] | ||
365 | const psModuleName = match(psModuleRegex, source) | ||
366 | map[psModuleName] = path.resolve(file) | ||
367 | return map | ||
368 | }, {}) | ||
369 | return cache.psModuleMap | ||
370 | }) | ||
371 | }) | ||
372 | } | ||
373 | |||
374 | function connectIdeServer(psModule) { | ||
375 | const options = psModule.options | ||
376 | const cache = psModule.cache | ||
377 | |||
378 | if (cache.ideServer) return Promise.resolve(psModule) | ||
379 | |||
380 | cache.ideServer = true | ||
381 | |||
382 | const connect = () => new Promise((resolve, reject) => { | ||
383 | const args = dargs(options.pscIdeArgs) | ||
384 | |||
385 | debug('attempting to connect to psc-ide-server', args) | ||
386 | |||
387 | const ideClient = spawn('psc-ide-client', args) | ||
388 | |||
389 | ideClient.stderr.on('data', data => { | ||
390 | debug(data.toString()) | ||
391 | cache.ideServer = false | ||
392 | reject(true) | ||
393 | }) | ||
394 | ideClient.stdout.once('data', data => { | ||
395 | debug(data.toString()) | ||
396 | if (data.toString()[0] === '{') { | ||
397 | const res = JSON.parse(data.toString()) | ||
398 | if (res.resultType === 'success') { | ||
399 | cache.ideServer = ideServer | ||
400 | resolve(psModule) | ||
401 | } else { | ||
402 | cache.ideServer = ideServer | ||
403 | reject(true) | ||
404 | } | ||
405 | } else { | ||
406 | cache.ideServer = false | ||
407 | reject(true) | ||
408 | } | ||
409 | }) | ||
410 | ideClient.stdin.resume() | ||
411 | ideClient.stdin.write(JSON.stringify({ command: 'load' })) | ||
412 | ideClient.stdin.write('\n') | ||
413 | }) | ||
414 | |||
415 | const args = dargs(Object.assign({ | ||
416 | outputDirectory: options.output, | ||
417 | }, options.pscIdeArgs)) | ||
418 | |||
419 | debug('attempting to start psc-ide-server', args) | ||
420 | |||
421 | const ideServer = cache.ideServer = spawn('psc-ide-server', []) | ||
422 | ideServer.stderr.on('data', data => { | ||
423 | debug(data.toString()) | ||
424 | }) | ||
425 | |||
426 | return retryPromise((retry, number) => { | ||
427 | return connect().catch(error => { | ||
428 | if (!cache.ideServer && number === 9) { | ||
429 | debug(error) | ||
430 | |||
431 | console.log( | ||
432 | 'failed to connect to or start psc-ide-server, ' + | ||
433 | 'full compilation will occur on rebuild' | ||
434 | ) | ||
435 | |||
436 | return Promise.resolve(psModule) | ||
437 | } | ||
438 | |||
439 | return retry(error) | ||
440 | }) | ||
441 | }, { | ||
442 | retries: 9, | ||
443 | factor: 1, | ||
444 | minTimeout: 333, | ||
445 | maxTimeout: 333, | ||
446 | }) | ||
447 | } | ||
448 | |||
449 | function match(regex, str) { | ||
450 | const matches = str.match(regex) | ||
451 | return matches && matches[1] | ||
452 | } | ||
453 | |||
454 | function dargs(obj) { | ||
455 | return Object.keys(obj).reduce((args, key) => { | ||
456 | const arg = '--' + key.replace(/[A-Z]/g, '-$&').toLowerCase(); | ||
457 | const val = obj[key] | ||
458 | |||
459 | if (key === '_') val.forEach(v => args.push(v)) | ||
460 | else if (Array.isArray(val)) val.forEach(v => args.push(arg, v)) | ||
461 | else args.push(arg, obj[key]) | ||
462 | |||
463 | return args | ||
464 | }, []) | ||
465 | } | ||