]>
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 | ||
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: {}, | |
5163b5a2 | 28 | pscIde: false, |
7de41f10 AM |
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 = { | |
5163b5a2 | 60 | rebuild: options.pscIde, |
7de41f10 AM |
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) => { | |
9dc10210 AM |
68 | if (options.warnings && cache.warnings) { |
69 | compilation.warnings.unshift(`PureScript compilation:\n${cache.warnings}`) | |
7de41f10 AM |
70 | } |
71 | ||
9dc10210 AM |
72 | if (cache.errors) { |
73 | compilation.errors.unshift(`PureScript compilation:\n${cache.errors}`) | |
7de41f10 AM |
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 | ||
67888496 AM |
92 | debug('loader called', psModule.name) |
93 | ||
7de41f10 AM |
94 | if (options.bundle) { |
95 | cache.bundleModules.push(psModule.name) | |
96 | } | |
97 | ||
98 | if (cache.rebuild) { | |
99 | return connectIdeServer(psModule) | |
100 | .then(rebuild) | |
101 | .then(toJavaScript) | |
102 | .then(psModule.load) | |
103 | .catch(psModule.reject) | |
104 | } | |
105 | ||
67888496 | 106 | if (cache.compilationFinished) { |
7de41f10 AM |
107 | return toJavaScript(psModule).then(psModule.load).catch(psModule.reject) |
108 | } | |
109 | ||
110 | // We need to wait for compilation to finish before the loaders run so that | |
111 | // references to compiled output are valid. | |
112 | cache.deferred.push(psModule) | |
113 | ||
67888496 | 114 | if (!cache.compilationStarted) { |
7de41f10 AM |
115 | return compile(psModule) |
116 | .then(() => Promise.map(cache.deferred, psModule => { | |
117 | if (typeof cache.ideServer === 'object') cache.ideServer.kill() | |
118 | return toJavaScript(psModule).then(psModule.load) | |
119 | })) | |
120 | .catch(error => { | |
121 | cache.deferred[0].reject(error) | |
122 | cache.deferred.slice(1).forEach(psModule => psModule.reject(true)) | |
123 | }) | |
124 | } | |
125 | } | |
126 | ||
127 | // The actual loader is executed *after* purescript compilation. | |
128 | function toJavaScript(psModule) { | |
129 | const options = psModule.options | |
130 | const cache = psModule.cache | |
131 | const bundlePath = path.resolve(options.bundleOutput) | |
132 | const jsPath = cache.bundle ? bundlePath : psModule.jsPath | |
133 | ||
67888496 | 134 | debug('loading JavaScript for', psModule.name) |
7de41f10 AM |
135 | |
136 | return Promise.props({ | |
137 | js: fs.readFileAsync(jsPath, 'utf8'), | |
138 | psModuleMap: psModuleMap(options.src, cache) | |
139 | }).then(result => { | |
140 | let js = '' | |
141 | ||
142 | if (options.bundle) { | |
143 | // if bundling, return a reference to the bundle | |
144 | js = 'module.exports = require("' | |
145 | + path.relative(psModule.srcDir, options.bundleOutput) | |
146 | + '")["' + psModule.name + '"]' | |
147 | } else { | |
148 | // replace require paths to output files generated by psc with paths | |
149 | // to purescript sources, which are then also run through this loader. | |
150 | const foreignRequire = 'require("' + path.resolve( | |
151 | path.join(psModule.options.output, psModule.name, 'foreign.js') | |
152 | ) + '")' | |
153 | ||
154 | js = result.js | |
155 | .replace(requireRegex, (m, p1) => { | |
156 | return 'require("' + result.psModuleMap[p1] + '")' | |
157 | }) | |
158 | .replace(/require\(['"]\.\/foreign['"]\)/g, foreignRequire) | |
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 | ||
67888496 | 170 | if (cache.compilationStarted) return Promise.resolve(psModule) |
7de41f10 | 171 | |
67888496 | 172 | cache.compilationStarted = true |
7de41f10 AM |
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 | ||
9dc10210 | 187 | compilation.stdout.on('data', data => stderr.push(data.toString())) |
7de41f10 AM |
188 | compilation.stderr.on('data', data => stderr.push(data.toString())) |
189 | ||
190 | compilation.on('close', code => { | |
191 | console.log('Finished compiling PureScript.') | |
67888496 | 192 | cache.compilationFinished = true |
7de41f10 | 193 | if (code !== 0) { |
9dc10210 | 194 | cache.errors = stderr.join('') |
7de41f10 AM |
195 | reject(true) |
196 | } else { | |
9dc10210 | 197 | cache.warnings = stderr.join('') |
7de41f10 AM |
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 | ideClient.stdout.once('data', data => { | |
5163b5a2 | 221 | let res = null |
7de41f10 | 222 | |
5163b5a2 AM |
223 | try { |
224 | res = JSON.parse(data.toString()) | |
225 | debug(res) | |
226 | } catch (err) { | |
227 | return reject(err) | |
228 | } | |
229 | ||
230 | if (res && !Array.isArray(res.result)) { | |
7de41f10 AM |
231 | return res.resultType === 'success' |
232 | ? resolve(psModule) | |
5163b5a2 | 233 | : reject('psc-ide rebuild failed') |
7de41f10 AM |
234 | } |
235 | ||
236 | Promise.map(res.result, (item, i) => { | |
237 | debug(item) | |
238 | return formatIdeResult(item, options, i, res.result.length) | |
239 | }) | |
240 | .then(compileMessages => { | |
241 | if (res.resultType === 'error') { | |
5163b5a2 AM |
242 | if (res.result.some(item => item.errorCode === 'UnknownModule')) { |
243 | console.log('Unknown module, attempting full recompile') | |
244 | return compile(psModule) | |
245 | .then(() => request({ command: 'load' })) | |
246 | .then(resolve) | |
247 | .catch(() => reject('psc-ide rebuild failed')) | |
248 | } | |
9dc10210 | 249 | cache.errors = compileMessages.join('\n') |
5163b5a2 | 250 | reject('psc-ide rebuild failed') |
7de41f10 | 251 | } else { |
9dc10210 | 252 | cache.warnings = compileMessages.join('\n') |
7de41f10 AM |
253 | resolve(psModule) |
254 | } | |
255 | }) | |
256 | }) | |
257 | ||
258 | ideClient.stderr.once('data', data => reject(data.toString())) | |
259 | ||
260 | ideClient.stdin.write(JSON.stringify(body)) | |
261 | ideClient.stdin.write('\n') | |
262 | }) | |
263 | ||
264 | return request({ | |
265 | command: 'rebuild', | |
266 | params: { | |
267 | file: psModule.srcPath, | |
268 | } | |
7de41f10 AM |
269 | }) |
270 | } | |
271 | ||
272 | function formatIdeResult(result, options, index, length) { | |
273 | const srcPath = path.relative(options.context, result.filename) | |
274 | const pos = result.position | |
275 | const fileAndPos = `${srcPath}:${pos.startLine}:${pos.startColumn}` | |
276 | let numAndErr = `[${index+1}/${length} ${result.errorCode}]` | |
277 | numAndErr = options.pscIdeColors ? colors.yellow(numAndErr) : numAndErr | |
278 | ||
279 | return fs.readFileAsync(result.filename, 'utf8').then(source => { | |
280 | const lines = source.split('\n').slice(pos.startLine - 1, pos.endLine) | |
281 | const endsOnNewline = pos.endColumn === 1 && pos.startLine !== pos.endLine | |
282 | const up = options.pscIdeColors ? colors.red('^') : '^' | |
283 | const down = options.pscIdeColors ? colors.red('v') : 'v' | |
284 | let trimmed = lines.slice(0) | |
285 | ||
286 | if (endsOnNewline) { | |
287 | lines.splice(lines.length - 1, 1) | |
288 | pos.endLine = pos.endLine - 1 | |
289 | pos.endColumn = lines[lines.length - 1].length || 1 | |
290 | } | |
291 | ||
292 | // strip newlines at the end | |
293 | if (endsOnNewline) { | |
294 | trimmed = lines.reverse().reduce((trimmed, line, i) => { | |
295 | if (i === 0 && line === '') trimmed.trimming = true | |
296 | if (!trimmed.trimming) trimmed.push(line) | |
297 | if (trimmed.trimming && line !== '') { | |
298 | trimmed.trimming = false | |
299 | trimmed.push(line) | |
300 | } | |
301 | return trimmed | |
302 | }, []).reverse() | |
303 | pos.endLine = pos.endLine - (lines.length - trimmed.length) | |
304 | pos.endColumn = trimmed[trimmed.length - 1].length || 1 | |
305 | } | |
306 | ||
307 | const spaces = ' '.repeat(String(pos.endLine).length) | |
308 | let snippet = trimmed.map((line, i) => { | |
309 | return ` ${pos.startLine + i} ${line}` | |
310 | }).join('\n') | |
311 | ||
312 | if (trimmed.length === 1) { | |
313 | snippet += `\n ${spaces} ${' '.repeat(pos.startColumn - 1)}${up.repeat(pos.endColumn - pos.startColumn + 1)}` | |
314 | } else { | |
315 | snippet = ` ${spaces} ${' '.repeat(pos.startColumn - 1)}${down}\n${snippet}` | |
316 | snippet += `\n ${spaces} ${' '.repeat(pos.endColumn - 1)}${up}` | |
317 | } | |
318 | ||
319 | return Promise.resolve( | |
320 | `\n${numAndErr} ${fileAndPos}\n\n${snippet}\n\n${result.message}` | |
321 | ) | |
322 | }) | |
323 | } | |
324 | ||
325 | function bundle(options, cache) { | |
326 | if (cache.bundle) return Promise.resolve(cache.bundle) | |
327 | ||
328 | const stdout = [] | |
329 | const stderr = cache.bundle = [] | |
330 | ||
331 | const args = dargs(Object.assign({ | |
332 | _: [path.join(options.output, '*', '*.js')], | |
333 | output: options.bundleOutput, | |
334 | namespace: options.bundleNamespace, | |
335 | }, options.pscBundleArgs)) | |
336 | ||
337 | cache.bundleModules.forEach(name => args.push('--module', name)) | |
338 | ||
339 | debug('spawning bundler %s %o', options.pscBundle, args.join(' ')) | |
340 | ||
341 | return (new Promise((resolve, reject) => { | |
342 | console.log('Bundling PureScript...') | |
343 | ||
344 | const compilation = spawn(options.pscBundle, args) | |
345 | ||
346 | compilation.stdout.on('data', data => stdout.push(data.toString())) | |
347 | compilation.stderr.on('data', data => stderr.push(data.toString())) | |
348 | compilation.on('close', code => { | |
349 | if (code !== 0) { | |
9dc10210 | 350 | cache.errors = (cache.errors || '') + stderr.join('') |
7de41f10 AM |
351 | return reject(true) |
352 | } | |
353 | cache.bundle = stderr | |
354 | resolve(fs.appendFileAsync('output/bundle.js', `module.exports = ${options.bundleNamespace}`)) | |
355 | }) | |
356 | })) | |
357 | } | |
358 | ||
359 | // map of PS module names to their source path | |
360 | function psModuleMap(globs, cache) { | |
361 | if (cache.psModuleMap) return Promise.resolve(cache.psModuleMap) | |
362 | ||
363 | return globby(globs).then(paths => { | |
364 | return Promise | |
365 | .props(paths.reduce((map, file) => { | |
366 | map[file] = fs.readFileAsync(file, 'utf8') | |
367 | return map | |
368 | }, {})) | |
369 | .then(srcMap => { | |
370 | cache.psModuleMap = Object.keys(srcMap).reduce((map, file) => { | |
371 | const source = srcMap[file] | |
372 | const psModuleName = match(psModuleRegex, source) | |
373 | map[psModuleName] = path.resolve(file) | |
374 | return map | |
375 | }, {}) | |
376 | return cache.psModuleMap | |
377 | }) | |
378 | }) | |
379 | } | |
380 | ||
381 | function connectIdeServer(psModule) { | |
382 | const options = psModule.options | |
383 | const cache = psModule.cache | |
384 | ||
385 | if (cache.ideServer) return Promise.resolve(psModule) | |
386 | ||
387 | cache.ideServer = true | |
388 | ||
389 | const connect = () => new Promise((resolve, reject) => { | |
390 | const args = dargs(options.pscIdeArgs) | |
391 | ||
392 | debug('attempting to connect to psc-ide-server', args) | |
393 | ||
394 | const ideClient = spawn('psc-ide-client', args) | |
395 | ||
396 | ideClient.stderr.on('data', data => { | |
397 | debug(data.toString()) | |
398 | cache.ideServer = false | |
399 | reject(true) | |
400 | }) | |
401 | ideClient.stdout.once('data', data => { | |
402 | debug(data.toString()) | |
403 | if (data.toString()[0] === '{') { | |
404 | const res = JSON.parse(data.toString()) | |
405 | if (res.resultType === 'success') { | |
406 | cache.ideServer = ideServer | |
407 | resolve(psModule) | |
408 | } else { | |
409 | cache.ideServer = ideServer | |
410 | reject(true) | |
411 | } | |
412 | } else { | |
413 | cache.ideServer = false | |
414 | reject(true) | |
415 | } | |
416 | }) | |
417 | ideClient.stdin.resume() | |
418 | ideClient.stdin.write(JSON.stringify({ command: 'load' })) | |
419 | ideClient.stdin.write('\n') | |
420 | }) | |
421 | ||
422 | const args = dargs(Object.assign({ | |
423 | outputDirectory: options.output, | |
424 | }, options.pscIdeArgs)) | |
425 | ||
426 | debug('attempting to start psc-ide-server', args) | |
427 | ||
428 | const ideServer = cache.ideServer = spawn('psc-ide-server', []) | |
429 | ideServer.stderr.on('data', data => { | |
430 | debug(data.toString()) | |
431 | }) | |
432 | ||
433 | return retryPromise((retry, number) => { | |
434 | return connect().catch(error => { | |
435 | if (!cache.ideServer && number === 9) { | |
436 | debug(error) | |
437 | ||
438 | console.log( | |
439 | 'failed to connect to or start psc-ide-server, ' + | |
440 | 'full compilation will occur on rebuild' | |
441 | ) | |
442 | ||
443 | return Promise.resolve(psModule) | |
444 | } | |
445 | ||
446 | return retry(error) | |
447 | }) | |
448 | }, { | |
449 | retries: 9, | |
450 | factor: 1, | |
451 | minTimeout: 333, | |
452 | maxTimeout: 333, | |
453 | }) | |
454 | } | |
455 | ||
456 | function match(regex, str) { | |
457 | const matches = str.match(regex) | |
458 | return matches && matches[1] | |
459 | } | |
460 | ||
461 | function dargs(obj) { | |
462 | return Object.keys(obj).reduce((args, key) => { | |
463 | const arg = '--' + key.replace(/[A-Z]/g, '-$&').toLowerCase(); | |
464 | const val = obj[key] | |
465 | ||
466 | if (key === '_') val.forEach(v => args.push(v)) | |
467 | else if (Array.isArray(val)) val.forEach(v => args.push(arg, v)) | |
468 | else args.push(arg, obj[key]) | |
469 | ||
470 | return args | |
471 | }, []) | |
472 | } |