]> git.immae.eu Git - github/fretlink/purs-loader.git/blob - src/index.js
cfba1e2c923b3266bdc0379f5bc61d9be2ea9deb
[github/fretlink/purs-loader.git] / src / index.js
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 srcModuleRegex = /(?:^|\n)module\s+([\w\.]+)/i
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: {},
29 pscIde: false,
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 }, 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 = {
57 rebuild: options.pscIde,
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) => {
65 if (options.warnings && cache.warnings) {
66 compilation.warnings.unshift(`PureScript compilation:\n${cache.warnings}`)
67 }
68
69 if (cache.errors) {
70 compilation.errors.unshift(`PureScript compilation:\n${cache.errors}`)
71 }
72
73 callback()
74 })
75 }
76
77 const psModuleName = match(srcModuleRegex, source)
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
89 debug('loader called', psModule.name)
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.compilationFinished) {
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.compilationStarted) {
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.name)
132
133 return Promise.props({
134 js: fs.readFileAsync(jsPath, 'utf8'),
135 psModuleMap: psModuleMap(options, 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 + jsStringEscape(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 js = result.js
148 .replace(requireRegex, (m, p1) => {
149 return 'require("' + jsStringEscape(result.psModuleMap[p1].src) + '")'
150 })
151 .replace(/require\(['"]\.\/foreign['"]\)/g, (m, p1) => {
152 return 'require("' + jsStringEscape(result.psModuleMap[psModule.name].ffi) + '")'
153 })
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
165 if (cache.compilationStarted) return Promise.resolve(psModule)
166
167 cache.compilationStarted = true
168
169 const args = dargs(Object.assign({
170 _: options.src,
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
181 compilation.stdout.on('data', data => stderr.push(data.toString()))
182 compilation.stderr.on('data', data => stderr.push(data.toString()))
183
184 compilation.on('close', code => {
185 console.log('Finished compiling PureScript.')
186 cache.compilationFinished = true
187 if (code !== 0) {
188 cache.errors = stderr.join('')
189 reject(true)
190 } else {
191 cache.warnings = stderr.join('')
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
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
231 let res = null
232
233 try {
234 res = JSON.parse(stdout.toString())
235 debug(res)
236 } catch (err) {
237 return reject(err)
238 }
239
240 if (res && !Array.isArray(res.result)) {
241 return res.resultType === 'success'
242 ? resolve(psModule)
243 : reject('psc-ide rebuild failed')
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') {
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 }
259 cache.errors = compileMessages.join('\n')
260 reject('psc-ide rebuild failed')
261 } else {
262 cache.warnings = compileMessages.join('\n')
263 resolve(psModule)
264 }
265 })
266 })
267
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 }
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) {
358 cache.errors = (cache.errors || '') + stderr.join('')
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
368 function psModuleMap(options, cache) {
369 if (cache.psModuleMap) return Promise.resolve(cache.psModuleMap)
370
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 }
379
380 return globby(globs).then(paths => {
381 return Promise
382 .props(paths.reduce((map, file) => {
383 const fileJS = pursToJs(file)
384 map[file] = fs.readFileAsync(file, 'utf8')
385 map[fileJS] = fs.readFileAsync(fileJS, 'utf8').catch(() => undefined)
386 return map
387 }, {}))
388 .then(fileMap => {
389 cache.psModuleMap = Object.keys(fileMap).reduce((map, file) => {
390 const ext = path.extname(file)
391 const isPurs = ext.match(/purs$/i)
392 if (isPurs) {
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] || {}
398 map[moduleName].src = path.resolve(file)
399 if (ffi) {
400 map[moduleName].ffi = path.resolve(fileJs)
401 }
402 }
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
499 return args.filter(arg => (typeof arg !== 'boolean'))
500 }, [])
501 }