hakk
Version:
Interactive programming for Node.js. Speed up your JavaScript development!
213 lines (189 loc) • 6.76 kB
JavaScript
const repl = require('node:repl');
const homedir = require('os').homedir();
const fs = require('fs');
const path = require('node:path');
const { prepareAstNodes, generate } = require('./transform.js');
const { sha256 } = require('./utils.js');
// TODO: Get source mapping with something like:
// generate(ast, {sourceMaps: true, sourceFileName: "test"})
// (The `generate` API requires `sourceFileName` to be included for source maps
// to be generated.)
// Q: Can we use https://www.npmjs.com/package/babel-plugin-source-map-support
// and https://www.npmjs.com/package/source-map-support ?
// How do these work? See also https://v8.dev/docs/stack-trace-api
// Returns true if the inputted code is incomplete.
const unexpectedNewLine = (code, e) =>
e &&
e.code === 'BABEL_PARSER_SYNTAX_ERROR' &&
e.reasonCode === 'UnexpectedToken' &&
e.loc &&
e.loc.index === code.length &&
code[code.length - 1] === '\n';
const unterminatedTemplate = (code, e) =>
e &&
e.code === 'BABEL_PARSER_SYNTAX_ERROR' &&
e.reasonCode === 'UnterminatedTemplate';
const incompleteCode = (code, e) =>
unexpectedNewLine(code, e) || unterminatedTemplate(code, e);
class ModulePathManager {
constructor (paths) {
this.modulePaths_ = paths;
}
add (path) {
if (!this.modulePaths_.includes(path)) {
this.modulePaths_.push(path);
}
}
forward () {
this.modulePaths_.push(this.modulePaths_.shift());
}
back () {
this.modulePaths_.unshift(this.modulePaths_.pop());
}
jump (path) {
if (!this.modulePaths_.includes(path)) {
throw new Error(`module '${path}' not found`);
}
// Step forward one step, so user can hit "back" to return
this.forward();
this.modulePaths_ = this.modulePaths_.filter(p => p !== path);
this.modulePaths_.unshift(path);
}
current () {
return this.modulePaths_[0];
}
has (path) {
return this.modulePaths_.includes(path);
}
}
const caselessStartsWith = (a, b) =>
a.toLowerCase().startsWith((b ?? '').toLowerCase());
const fileBasedPrompt = (filenameFullPath) => {
const filename = path.relative('.', filenameFullPath);
return `${filename}> `;
};
const updatePrompt = (replServer, modulePathManager) => {
replServer.setPrompt(fileBasedPrompt(modulePathManager.current()));
replServer.prompt();
};
const historyDir = () => {
const histDir = path.join(homedir, '.hakk', 'history');
fs.mkdirSync(histDir, { recursive: true });
return histDir;
};
const monitorSpecialKeys = (replServer, modulePathManager) => {
const originalTtyWrite = replServer._ttyWrite;
replServer._ttyWrite = async (d, key) => {
const shiftOnly = key.meta === false && key.shift === true && key.ctrl === false;
if (shiftOnly && key.name === 'right') {
modulePathManager.forward();
updatePrompt(replServer, modulePathManager);
} else if (shiftOnly && key.name === 'left') {
modulePathManager.back();
updatePrompt(replServer, modulePathManager);
} else {
originalTtyWrite(d, key);
}
};
};
class Repl {
static async start (moduleManager) {
const repl = new Repl(moduleManager);
await repl.initializeHistory();
return repl;
}
constructor (moduleManager) {
this.moduleManager_ = moduleManager;
this.modulePathManager_ = new ModulePathManager(moduleManager.getModulePaths());
this.moduleManager_.addModuleCreationListener((path) => {
this.modulePathManager_.add(path);
});
const options = {
useColors: true,
prompt: fileBasedPrompt(this.modulePathManager_.current()),
eval: (code, context, filename, callback) =>
this.eval(code, context, filename, callback),
preview: false
};
console.log('Use shift+left and shift+right to switch between modules.');
this.replServer_ = new repl.REPLServer(options);
const originalCompleter = this.replServer_.completer;
this.replServer_.completer = (text, cb) => {
originalCompleter(text, (error, [completions, stub]) => {
const vars = moduleManager.getVars(this.modulePathManager_
.current()).filter(v => !v.endsWith('_hakk_'));
completions.push('', ...vars.filter(v => caselessStartsWith(v, stub)));
cb(error, [completions, stub ?? '']);
});
};
this.moduleManager_.addModuleUpdateListener(filename => this.update(filename));
monitorSpecialKeys(this.replServer_, this.modulePathManager_);
}
initializeHistory () {
return new Promise(resolve => this.replServer_.setupHistory(
path.join(historyDir(), sha256(this.modulePathManager_.current())), resolve));
}
update (filenameFullPath) {
// Trigger preview update in case the file has updated a function
// that will produce a new result for the pending REPL input.
this.replServer_._ttyWrite(null, {});
// Switch the repl to the current file.
this.modulePathManager_.jump(filenameFullPath);
updatePrompt(this.replServer_, this.modulePathManager_);
}
updateUnderscores (lastResult) {
const vars = this.moduleManager_.getVars(this.modulePathManager_.current());
global.________ = global._______;
global._______ = global.______;
global.______ = global._____;
global._____ = global.____;
global.____ = global.___;
global.___ = global.__;
if (vars.includes('_')) {
global.__ = lastResult;
} else {
global.__ = global._;
global._ = lastResult;
}
}
async eval (code, context, filename, callback) {
let nodes;
try {
nodes = prepareAstNodes(code);
} catch (e) {
if (incompleteCode(code, e)) {
return callback(new repl.Recoverable(e));
} else {
return callback(e);
}
}
if (nodes.length === 0) {
return callback(null);
}
const evalInCurrentModule = (code, definedVars) =>
this.moduleManager_.evalInModule(
this.modulePathManager_.current(), code, definedVars);
let result;
for (const node of nodes) {
const modifiedCode = generate(node).code;
try {
if (this.moduleManager_.isWeb) {
result = await evalInCurrentModule(modifiedCode, node._definedVars);
} else if (node._topLevelAwait) {
result = await evalInCurrentModule(
`(async () => { return ${modifiedCode}\n })()`, node._definedVars);
} else if (node._topLevelForOfAwait) {
await evalInCurrentModule(
`(async () => { ${modifiedCode} \n })()`, node._definedVars);
} else {
result = evalInCurrentModule(modifiedCode, node._definedVars);
}
this.updateUnderscores(result);
} catch (e) {
return callback(e);
}
}
return callback(null, result);
}
}
module.exports = { Repl };