aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/index.js
diff options
context:
space:
mode:
Diffstat (limited to 'src/index.js')
-rw-r--r--src/index.js465
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
3const colors = require('chalk')
4const debug = require('debug')('purs-loader')
5const loaderUtils = require('loader-utils')
6const globby = require('globby')
7const Promise = require('bluebird')
8const fs = Promise.promisifyAll(require('fs'))
9const spawn = require('child_process').spawn
10const path = require('path')
11const retryPromise = require('promise-retry')
12
13const psModuleRegex = /(?:^|\n)module\s+([\w\.]+)/i
14const requireRegex = /require\(['"]\.\.\/([\w\.]+)['"]\)/g
15
16module.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.
125function 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
162function 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
208function 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
265function 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
318function 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
353function 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
374function 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
449function match(regex, str) {
450 const matches = str.match(regex)
451 return matches && matches[1]
452}
453
454function 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}