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')
14 const srcModuleRegex
= /(?:^|\n)module\s+([\w\.]+)/i
15 const requireRegex
= /require\(['"]\.\.\/([\w\.]+)['"]\)/g
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
|| {}
23 const options
= Object
.assign({
24 context: config
.context
,
27 pscBundle: 'psc-bundle',
30 pscIdeColors: webpackOptions
.psc
=== 'psa' || query
.psc
=== 'psa',
32 bundleOutput: 'output/bundle.js',
33 bundleNamespace: 'PS',
38 path
.join('src', '**', '*.purs'),
39 path
.join('bower_components', 'purescript-*', 'src', '**', '*.purs')
41 }, webpackOptions
, query
)
43 this.cacheable
&& this.cacheable()
45 let cache
= config
.purescriptLoaderCache
= config
.purescriptLoaderCache
|| {
51 if (!config
.purescriptLoaderInstalled
) {
52 config
.purescriptLoaderInstalled
= true
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
,
59 ideServer: cache
.ideServer
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}`)
70 compilation
.errors
.unshift(`PureScript compilation:\n${cache.errors}`)
77 const psModuleName
= match(srcModuleRegex
, source
)
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')),
89 debug('loader called', psModule
.name
)
92 cache
.bundleModules
.push(psModule
.name
)
96 return connectIdeServer(psModule
)
100 .catch(psModule
.reject
)
103 if (cache
.compilationFinished
) {
104 return toJavaScript(psModule
).then(psModule
.load
).catch(psModule
.reject
)
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
)
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
)
118 cache
.deferred
[0].reject(error
)
119 cache
.deferred
.slice(1).forEach(psModule
=> psModule
.reject(true))
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
131 debug('loading JavaScript for', psModule
.name
)
133 return Promise
.props({
134 js: fs
.readFileAsync(jsPath
, 'utf8'),
135 psModuleMap: psModuleMap(options
, cache
)
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
+ '"]'
145 // replace require paths to output files generated by psc with paths
146 // to purescript sources, which are then also run through this loader.
148 .replace(requireRegex
, (m
, p1
) => {
149 return 'require("' + jsStringEscape(result
.psModuleMap
[p1
].src
) + '")'
151 .replace(/require\(['"]\.\/foreign['"]\)/g, (m
, p1
) => {
152 return 'require("' + jsStringEscape(result
.psModuleMap
[psModule
.name
].ffi
) + '")'
160 function compile(psModule
) {
161 const options
= psModule
.options
162 const cache
= psModule
.cache
165 if (cache
.compilationStarted
) return Promise
.resolve(psModule
)
167 cache
.compilationStarted
= true
169 const args
= dargs(Object
.assign({
171 output: options
.output
,
174 debug('spawning compiler %s %o', options
.psc
, args
)
176 return (new Promise((resolve
, reject
) => {
177 console
.log('\nCompiling PureScript...')
179 const compilation
= spawn(options
.psc
, args
)
181 compilation
.stdout
.on('data', data
=> stderr
.push(data
.toString()))
182 compilation
.stderr
.on('data', data
=> stderr
.push(data
.toString()))
184 compilation
.on('close', code
=> {
185 console
.log('Finished compiling PureScript.')
186 cache
.compilationFinished
= true
188 cache
.errors
= stderr
.join('')
191 cache
.warnings
= stderr
.join('')
196 .then(compilerOutput
=> {
197 if (options
.bundle
) {
198 return bundle(options
, cache
).then(() => psModule
)
204 function rebuild(psModule
) {
205 const options
= psModule
.options
206 const cache
= psModule
.cache
208 debug('attempting rebuild with psc-ide-client %s', psModule
.srcPath
)
210 const request
= (body
) => new Promise((resolve
, reject
) => {
211 const args
= dargs(options
.pscIdeArgs
)
212 const ideClient
= spawn('psc-ide-client', args
)
217 ideClient
.stdout
.on('data', data
=> {
218 stdout
= stdout
+ data
.toString()
221 ideClient
.stderr
.on('data', data
=> {
222 stderr
= stderr
+ data
.toString()
225 ideClient
.on('close', code
=> {
227 const error
= stderr
=== '' ? 'Failed to spawn psc-ide-client' : stderr
228 return reject(new Error(error
))
234 res
= JSON
.parse(stdout
.toString())
240 if (res
&& !Array
.isArray(res
.result
)) {
241 return res
.resultType
=== 'success'
243 : reject('psc-ide rebuild failed')
246 Promise
.map(res
.result
, (item
, i
) => {
248 return formatIdeResult(item
, options
, i
, res
.result
.length
)
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' }))
257 .catch(() => reject('psc-ide rebuild failed'))
259 cache
.errors
= compileMessages
.join('\n')
260 reject('psc-ide rebuild failed')
262 cache
.warnings
= compileMessages
.join('\n')
268 ideClient
.stdin
.write(JSON
.stringify(body
))
269 ideClient
.stdin
.write('\n')
275 file: psModule
.srcPath
,
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
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)
295 lines
.splice(lines
.length
- 1, 1)
296 pos
.endLine
= pos
.endLine
- 1
297 pos
.endColumn
= lines
[lines
.length
- 1].length
|| 1
300 // strip newlines at the end
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
311 pos
.endLine
= pos
.endLine
- (lines
.length
- trimmed
.length
)
312 pos
.endColumn
= trimmed
[trimmed
.length
- 1].length
|| 1
315 const spaces
= ' '.repeat(String(pos
.endLine
).length
)
316 let snippet
= trimmed
.map((line
, i
) => {
317 return ` ${pos.startLine + i} ${line}`
320 if (trimmed
.length
=== 1) {
321 snippet
+= `\n ${spaces} ${' '.repeat(pos.startColumn - 1)}${up.repeat(pos.endColumn - pos.startColumn + 1)}`
323 snippet
= ` ${spaces} ${' '.repeat(pos.startColumn - 1)}${down}\n${snippet}`
324 snippet
+= `\n ${spaces} ${' '.repeat(pos.endColumn - 1)}${up}`
327 return Promise
.resolve(
328 `\n${numAndErr} ${fileAndPos}\n\n${snippet}\n\n${result.message}`
333 function bundle(options
, cache
) {
334 if (cache
.bundle
) return Promise
.resolve(cache
.bundle
)
337 const stderr
= cache
.bundle
= []
339 const args
= dargs(Object
.assign({
340 _: [path
.join(options
.output
, '*', '*.js')],
341 output: options
.bundleOutput
,
342 namespace: options
.bundleNamespace
,
343 }, options
.pscBundleArgs
))
345 cache
.bundleModules
.forEach(name
=> args
.push('--module', name
))
347 debug('spawning bundler %s %o', options
.pscBundle
, args
.join(' '))
349 return (new Promise((resolve
, reject
) => {
350 console
.log('Bundling PureScript...')
352 const compilation
= spawn(options
.pscBundle
, args
)
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
=> {
358 cache
.errors
= (cache
.errors
|| '') + stderr
.join('')
361 cache
.bundle
= stderr
362 resolve(fs
.appendFileAsync('output/bundle.js', `module.exports = ${options.bundleNamespace}`))
367 // map of PS module names to their source path
368 function psModuleMap(options
, cache
) {
369 if (cache
.psModuleMap
) return Promise
.resolve(cache
.psModuleMap
)
371 const globs
= [].concat(options
.src
);
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`)
380 return globby(globs
).then(paths
=> {
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)
389 cache
.psModuleMap
= Object
.keys(fileMap
).reduce((map
, file
) => {
390 const ext
= path
.extname(file
)
391 const isPurs
= ext
.match(/purs$/i)
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
)
400 map
[moduleName
].ffi
= path
.resolve(fileJs
)
405 return cache
.psModuleMap
410 function connectIdeServer(psModule
) {
411 const options
= psModule
.options
412 const cache
= psModule
.cache
414 if (cache
.ideServer
) return Promise
.resolve(psModule
)
416 cache
.ideServer
= true
418 const connect
= () => new Promise((resolve
, reject
) => {
419 const args
= dargs(options
.pscIdeArgs
)
421 debug('attempting to connect to psc-ide-server', args
)
423 const ideClient
= spawn('psc-ide-client', args
)
425 ideClient
.stderr
.on('data', data
=> {
426 debug(data
.toString())
427 cache
.ideServer
= false
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
438 cache
.ideServer
= ideServer
442 cache
.ideServer
= false
446 ideClient
.stdin
.resume()
447 ideClient
.stdin
.write(JSON
.stringify({ command: 'load' }))
448 ideClient
.stdin
.write('\n')
451 const args
= dargs(Object
.assign({
452 outputDirectory: options
.output
,
453 }, options
.pscIdeArgs
))
455 debug('attempting to start psc-ide-server', args
)
457 const ideServer
= cache
.ideServer
= spawn('psc-ide-server', [])
458 ideServer
.stderr
.on('data', data
=> {
459 debug(data
.toString())
462 return retryPromise((retry
, number
) => {
463 return connect().catch(error
=> {
464 if (!cache
.ideServer
&& number
=== 9) {
468 'failed to connect to or start psc-ide-server, ' +
469 'full compilation will occur on rebuild'
472 return Promise
.resolve(psModule
)
485 function match(regex
, str
) {
486 const matches
= str
.match(regex
)
487 return matches
&& matches
[1]
490 function dargs(obj
) {
491 return Object
.keys(obj
).reduce((args
, key
) => {
492 const arg
= '--' + key
.replace(/[A-Z]/g, '-$&').toLowerCase();
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
])
499 return args
.filter(arg
=> (typeof arg
!== 'boolean'))