Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ESM support #113

Merged
merged 1 commit into from
Apr 3, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,21 @@
"root": true,
"extends": ["./node_modules/sanctuary-style/eslint-es3.json"],
"env": {"node": true},
"globals": {
"Promise": "readonly"
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm using native Promises in the cjs version to align its output type with the mjs version. If you don't align these types, tooling tends to get confused, not to mention people.

},
"overrides": [
{
"files": ["lib/doctest.js"],
"rules": {
"no-multiple-empty-lines": ["error", {"max": 2, "maxEOF": 0}],
"spaced-comment": ["error", "always", {"markers": ["/"]}]
}
},
{
"files": ["*.mjs"],
"env": {"es6": true},
"parser": "babel-eslint"
Copy link
Collaborator Author

@Avaq Avaq Apr 1, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need a special parser for our files, because import() isn't an officially supported part of the language yet. The work around here is that we "pretend" to have written our source for Babel, while in reality we've written it for node --experimental-modules.

}
]
}
1 change: 1 addition & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ language: node_js
node_js:
- "6"
- "8"
- "9"
- "10"
before_install:
- git fetch origin refs/heads/master:refs/heads/master
Expand Down
15 changes: 6 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,13 @@ $ doctest lib/temperature.js

The exit code is 0 if all tests pass, 1 otherwise.

### AMD and CommonJS modules
### Supported module systems

doctest supports CommonJS modules and partially supports AMD modules:

| Module system | Node.js |
| --------------------------- |:-------:|
| AMD | ✔︎ |
| AMD w/ dependencies | ✘ |
| CommonJS | ✔︎ |
| CommonJS w/ dependencies | ✔︎ |
| Module system | Option | Node.js | Dependencies |
| --------------------- | ------------- |:-------------:|:-------------:|
| AMD | `amd` | ✔︎ | ✘ |
| CommonJS | `commonjs` | ✔︎ | ✔︎ |
| ECMAScript modules | `esm` | ✔︎ | ✔︎ |

Specify module system via JavaScript API:

Expand Down
31 changes: 17 additions & 14 deletions bin/doctest
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,21 @@

'use strict';

// Based on:
// https://github.com/jashkenas/coffeescript/blob/1.9.1/src/command.coffee#L431-L441
var args = process.argv.slice (1);
var path = require ('path');

var esmSupported = Number ((process.versions.node.split ('.'))[0]) >= 9;
var args = process.argv.slice (2);
var idx = args.indexOf ('--nodejs');
if (idx < 0 || idx === args.length - 1) {
require ('../lib/command');
} else {
require ('child_process')
.spawn (process.execPath,
args[idx + 1]
.split (/\s+/)
.concat (args.slice (0, idx), args.slice (idx + 2)),
{cwd: process.cwd (), env: process.env, stdio: [0, 1, 2]})
.on ('exit', process.exit);
}
var flags = idx >= 0 && idx < args.length - 1;

require ('child_process')
.spawn (
process.execPath,
[].concat (esmSupported ? ['--experimental-modules'] : [])
.concat (flags ? args[idx + 1].split (/\s+/) : [])
.concat (['--', path.resolve (__dirname, '..', 'lib', 'command')])
.concat (flags ? (args.slice (0, idx)).concat (args.slice (idx + 2))
: args),
{cwd: process.cwd (), env: process.env, stdio: [0, 1, 2]}
)
.on ('exit', process.exit);
65 changes: 11 additions & 54 deletions lib/command.js
Original file line number Diff line number Diff line change
@@ -1,62 +1,19 @@
'use strict';

var program = require ('commander');

var common = require ('./common');
var program = require ('./program');
var doctest = require ('..');
var pkg = require ('../package.json');


program
.version (pkg.version)
.usage ('[options] path/to/js/or/coffee/module')
.option ('-m, --module <type>',
'specify module system ("amd" or "commonjs")')
.option (' --nodejs <options>',
'pass options directly to the "node" binary')
.option (' --prefix <prefix>',
'specify Transcribe-style prefix (e.g. ".")')
.option (' --opening-delimiter <delimiter>',
'specify line preceding doctest block (e.g. "```javascript")')
.option (' --closing-delimiter <delimiter>',
'specify line following doctest block (e.g. "```")')
.option ('-p, --print',
'output the rewritten source without running tests')
.option ('-s, --silent',
'suppress output')
.option ('-t, --type <type>',
'specify file type ("coffee" or "js")')
.parse (process.argv);

// formatErrors :: Array String -> String
function formatErrors(errors) {
return (errors.map (function(s) { return 'error: ' + s + '\n'; })).join ('');
}

var errors = [];
if (program.module != null &&
program.module !== 'amd' &&
program.module !== 'commonjs') {
errors.push ('Invalid module `' + program.module + "'");
}
if (program.type != null &&
program.type !== 'coffee' &&
program.type !== 'js') {
errors.push ('Invalid type `' + program.type + "'");
}
if (errors.length > 0) {
process.stderr.write (formatErrors (errors));
if (program.module === 'esm') {
process.stderr.write (
common.formatErrors ([
'Node.js v' +
process.versions.node +
' does not support ECMAScript modules (supported since v9.0.0)'
])
);
process.exit (1);
}

process.exit (program.args.reduce (function(status, path) {
var results;
try {
results = doctest (path, program);
} catch (err) {
process.stderr.write (formatErrors ([err.message]));
process.exit (1);
}
return results.reduce (function(status, tuple) {
return tuple[0] ? status : 1;
}, status);
}, 0));
common.runDoctests (doctest, program);
5 changes: 5 additions & 0 deletions lib/command.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import common from './common';
import program from './program';
import doctest from '..';

common.runDoctests (doctest, program);
38 changes: 38 additions & 0 deletions lib/common.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
'use strict';


// formatErrors :: Array String -> String
exports.formatErrors = function(errors) {
return (errors.map (function(s) { return 'error: ' + s + '\n'; })).join ('');
};

// sanitizeFileContents :: String -> String
exports.sanitizeFileContents = function(contents) {
return contents.replace (/\r\n?/g, '\n').replace (/^#!.*/, '');
};

// unlines :: Array String -> String
exports.unlines = function(lines) {
return lines.reduce (function(s, line) { return s + line + '\n'; }, '');
};
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realize that common.js might be a badly chosen name, seeing as it already means something within the very domain we're working with. I intended it to have "common functionality", which is where it gets the name.


exports.runDoctests = function(doctest, program) {
if (program.args.length === 0) {
process.stderr.write (exports.formatErrors ([
'No files for doctesting provided'
]));
process.exit (1);
}
Promise.all (program.args.map (function(path) {
return (doctest (path, program)).then (function(results) {
return results.reduce (function(status, tuple) {
return tuple[0] ? status : 1;
}, 0);
});
})).then (function(statuses) {
process.exit (statuses.every (function(s) { return s === 0; }) ? 0 : 1);
}, function(err) {
process.stderr.write (exports.formatErrors ([err.message]));
process.exit (1);
});
};
73 changes: 49 additions & 24 deletions lib/doctest.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ var esprima = require ('esprima');
var _show = require ('sanctuary-show');
var Z = require ('sanctuary-type-classes');

var common = require ('./common');


function inferType(path) {
switch (pathlib.extname (path)) {
case '.coffee': return 'coffee';
case '.js': return 'js';
default: throw new Error ('Cannot infer type from extension');
default: return null;
}
}

Expand All @@ -39,36 +41,46 @@ module.exports = function(path, options) {
if (options.module != null &&
options.module !== 'amd' &&
options.module !== 'commonjs') {
throw new Error ('Invalid module `' + options.module + "'");
return Promise.reject (new Error (
'Invalid module `' + options.module + "'"
));
}
if (options.type != null &&
options.type !== 'coffee' &&
options.type !== 'js') {
throw new Error ('Invalid type `' + options.type + "'");
return Promise.reject (new Error (
'Invalid type `' + options.type + "'"
));
}

var type = options.type == null ? inferType (path) : options.type;
if (type == null) {
return Promise.reject (new Error (
'Cannot infer type from extension'
));
}

var source = toModule (
rewriters[options.type == null ? inferType (path) : options.type] (
rewriters[type] (
{prefix: options.prefix == null ? '' : options.prefix,
openingDelimiter: options.openingDelimiter,
closingDelimiter: options.closingDelimiter},
fs.readFileSync (path, 'utf8')
.replace (/\r\n?/g, '\n')
.replace (/^#!.*/, '')
closingDelimiter: options.closingDelimiter,
sourceType: 'script'},
common.sanitizeFileContents (fs.readFileSync (path, 'utf8'))
),
options.module
);

if (options.print) {
console.log (source.replace (/\n$/, ''));
return [];
return Promise.resolve ([]);
} else if (options.silent) {
return evaluate (options.module, source, path);
return Promise.resolve (evaluate (options.module, source, path));
} else {
console.log ('running doctests in ' + path + '...');
var results = evaluate (options.module, source, path);
log (results);
return results;
return Promise.resolve (results);
}
};

Expand Down Expand Up @@ -111,11 +123,6 @@ function stripLeading(n, c, s) {
return s.slice (idx);
}

// unlines :: Array String -> String
function unlines(lines) {
return lines.reduce (function(s, line) { return s + line + '\n'; }, '');
}

// iifeWrap :: String -> String
function iifeWrap(s) {
return 'void function() {\n' + indentN (2, s) + '}.call(this);';
Expand All @@ -125,7 +132,7 @@ function iifeWrap(s) {
function toModule(source, moduleType) {
switch (moduleType) {
case 'amd':
return unlines ([
return common.unlines ([
source,
'function define() {',
' for (var idx = 0; idx < arguments.length; idx += 1) {',
Expand All @@ -137,7 +144,7 @@ function toModule(source, moduleType) {
'}'
]);
case 'commonjs':
return iifeWrap (unlines ([
return iifeWrap (common.unlines ([
'var __doctest = {',
' require: require,',
' queue: [],',
Expand Down Expand Up @@ -308,9 +315,12 @@ function substring(input, start, end) {
);
}

function wrap$js(test) {
var type = (esprima.parse (test[INPUT].value)).body[0].type;
return type === 'FunctionDeclaration' || type === 'VariableDeclaration' ?
function wrap$js(test, sourceType) {
var ast = esprima.parse (test[INPUT].value, {sourceType: sourceType});
var type = ast.body[0].type;
return type === 'FunctionDeclaration' ||
type === 'ImportDeclaration' ||
type === 'VariableDeclaration' ?
test[INPUT].value :
[
'__doctest.enqueue({',
Expand Down Expand Up @@ -378,7 +388,16 @@ function rewrite$js(options, input) {
// produced by step 6 (substituting "step 6" for "step 2").

function getComments(input) {
return (esprima.parse (input, {comment: true, loc: true})).comments;
var ast = esprima.parse (input, {
comment: true,
loc: true,
sourceType: options.sourceType
});
return ast.comments;
}

function wrapCode(js) {
return wrap$js (js, options.sourceType);
}

// comments :: { Block :: Array Comment, Line :: Array Comment }
Expand All @@ -401,7 +420,7 @@ function rewrite$js(options, input) {

// source :: String
var source = lineTests
.map (wrap$js)
.map (wrapCode)
.concat ([''])
.reduce (function(accum, s, idx) { return accum + chunks[idx] + s; }, '');

Expand All @@ -413,7 +432,7 @@ function rewrite$js(options, input) {
substring (source, accum.loc, comment.loc.start),
blockTests
.filter (function(test) { return test.commentIndex === idx; })
.map (wrap$js)
.map (wrapCode)
.join ('\n')
);
accum.loc = comment.loc.end;
Expand All @@ -423,6 +442,8 @@ function rewrite$js(options, input) {
.join ('');
}

module.exports.rewrite$js = rewrite$js;

function rewrite$coffee(options, input) {
var lines = input.match (/^.*(?=\n)/gm);
var chunks = lines.reduce (function(accum, line, idx) {
Expand Down Expand Up @@ -548,6 +569,8 @@ function run(queue) {
}, {results: [], thunk: null}).results;
}

module.exports.run = run;

function log(results) {
console.log (results.reduce (function(s, tuple) {
return s + (tuple[0] ? '.' : 'x');
Expand All @@ -559,3 +582,5 @@ function log(results) {
}
});
}

module.exports.log = log;
Loading