Skip to content

Instantly share code, notes, and snippets.

@kybernetikos
Last active March 2, 2018 11:08
Show Gist options
  • Save kybernetikos/26b30952e5313513cb023cf3064791de to your computer and use it in GitHub Desktop.
Save kybernetikos/26b30952e5313513cb023cf3064791de to your computer and use it in GitHub Desktop.
REPL with types and autocomplete
// You should probably use Vorpal.js instead of this.
// usage demo down at the bottom of the file...
const Repl = require('repl');
const util = require('util');
const fs = require('fs');
const path = require('path');
// Don't be a repli-can't, be a...
class Replican {
constructor() {
this.completions = {
command: () => Object.keys(this.actions)
}
this.parsers = {
command: (str) => ({value: str})
}
this.actions = {}
this.repl = null;
this.prompt = "> ";
}
start(options = {}) {
this.repl = Repl.start({
...options,
prompt: this.prompt,
eval: (line, env, ns, finish) => {
this.exec(line).then((result) => finish(null, result)).catch((err) => finish(err));
},
completer: (line) => this.suggest(line)
});
}
setPrompt(prompt) {
this.prompt = prompt;
if (this.repl) {
this.repl.setPrompt(prompt);
}
}
defineType(typeName, parser, completer) {
const lowerTypeName = typeName.toLowerCase();
this.parsers[lowerTypeName] = parser;
this.completions[lowerTypeName] = completer;
}
defineTypes(...types) {
for (let {name, parser, suggester} of types) {
this.defineType(name, parser, suggester);
}
}
defineAction(name, args, fn, help) {
this.actions[name] = this.actions[name] || [];
this.actions[name].push({
arguments:args.map((type) => type.toLowerCase()), fn, help
});
}
defineActions(...actions) {
for (let {name, types, fn, help} of actions) {
this.defineAction(name, types, fn, help);
}
}
parse(line) {
if (line.trim() === '') {
return {errors: []};
}
const parts = this._split(line);
const actionName = parts.shift();
const potentialActions = this.actions[actionName];
if (potentialActions === undefined) {
return {errors: ["Action " + actionName + ' not known\n\tKnown actions are ' + Object.keys(this.actions).join(', ')]}
}
const parsed = potentialActions
.map((action) => ({action, ...this._parseArgs(parts, action.arguments)}))
const failedParses = parsed.filter((parse) => parse.errors.length > 0 || parse.incomplete);
const successfulParses = parsed.filter((parse) => parse.errors.length === 0 && parse.incomplete === false);
let errors = []
if (successfulParses.length === 1) {
return successfulParses[0];
} else if (successfulParses.length > 1) {
errors = ["Too many possible intentions"];
} else {
errors = failedParses.map((parse) => parse.errors).reduce((result, item) => [...result, ...item], []);
}
errors.push(potentialActions.map((action) => "Syntax: " + actionName + " " + this._typesToString(action.arguments) + (action.help ? "\n\t" + action.help : "")).join("\n"))
return {errors}
}
help(actionName) {
const potentialActions = this.actions[actionName];
return potentialActions.map((action) => "Syntax: " + actionName + " " + this._typesToString(action.arguments) + (action.help ? "\n\t" + action.help : "")).join("\n");
}
suggest(line) {
const parts = this._split(line);
let last = parts.pop() || "";
let possibleTerms = [];
if (parts.length < 1 && !this.actions[last]) {
possibleTerms = Object.keys(this.actions);
} else {
let actionName = parts[0];
if (actionName == null && this.actions[last]) {
parts.push(last);
actionName = last;
last = "";
}
const argParts = parts.slice(1);
const potentialActions = this.actions[actionName];
if (potentialActions === undefined) {
return [[], last]
}
const parsed = potentialActions
.map((action) => ({action, ...this._parseArgs(argParts, action.arguments)}))
.filter(({errors}) => errors.length === 0);
if (parsed.length > 0) {
const parse = parsed[0];
const types = parse.action.arguments;
let suggester = this.completions[types[parts.length - 1]];
if (suggester) {
possibleTerms = suggester(last);
if (possibleTerms.indexOf(last) >= 0) {
parts.push(last);
last = '';
possibleTerms = [];
suggester = this.completions[types[parts.length - 1]];
if (suggester) {
possibleTerms = suggester(last)
}
}
}
}
}
// not sure if this is exactly correct, but it allows me to replace something with a quoted string starting with
// the thing typed
this.repl.line = this.repl.line.substring(0, this.repl.line.length - last.length);
let hits = possibleTerms
.filter((c) => c.startsWith(last))
.map((l) => l.indexOf(' ') >= 0 ? '"' + l + '"' : l)
return [hits.length ? hits : possibleTerms, ""];
}
exec(line) {
if (line.trim().length === 0) {
return Promise.resolve();
}
const parsed = this.parse(line);
if (parsed.errors.length > 0) {
for (let error of parsed.errors) {
console.error(error);
}
return Promise.reject();
} else if (parsed.action) {
try {
return Promise.resolve(parsed.action.fn.apply(this, parsed.arguments));
} catch (err) {
return Promise.reject(err);
}
}
}
_parseArgs(params, types) {
const result = [];
const errors = [];
const args = [...params];
let incomplete = false;
for (let type of types) {
if (args.length === 0) {
incomplete = true;
break;
}
if (type === 'rest') {
result.push(args.join(' '))
args.length = 0;
} else {
const parserOutput = this.parsers[type](args.shift())
result.push(parserOutput.value);
if (parserOutput.error != null) {
errors.push(parserOutput.error);
}
}
}
if (args.length > 0) {
errors.push("Unused arguments: " + args.join(" "));
}
return {arguments:result, errors, incomplete}
}
_split(lineWithEnding) {
const line = lineWithEnding.trim();
const startingWithQuote = line.startsWith('"');
const quoteBlocks = line.trim().split('"');
if (startingWithQuote) {
quoteBlocks.shift()
}
if (line.endsWith('"')) {
quoteBlocks.pop();
}
return quoteBlocks
.reduce((result, part, idx) => (idx % 2 === 0) === startingWithQuote ? [...result, part] : [...result, ...part.trim().split(" ")], [])
}
_typesToString(types) {
if (types.length === 0) {
return "";
}
return "<" + types.join("> <") + ">";
}
}
function isFile(pathname) {
try {
return fs.lstatSync(pathname).isFile();
} catch (err) {
return false;
}
}
function isDirectory(pathname) {
try {
return fs.lstatSync(pathname).isDirectory();
} catch (err) {
return false;
}
}
Replican.types = {
string: {name: 'string', parser: (str) => ({value: str, errors: []}), suggester: () => []},
file: {name: 'file', parser: (str) => ({value: str, errors: []}), suggester: (pathname) => {
if (isFile(pathname)) {
return [pathname]
}
let parent = pathname;
if (!isDirectory(pathname)) {
parent = path.dirname(pathname);
}
if (isDirectory(parent)) {
const possibleFiles = fs.readdirSync(parent)
return possibleFiles.map((c) => path.join(parent, c));
}
return [pathname];
}},
boolean: {name: 'boolean', parser: (str) => {
const input = str.toLowerCase();
if (input === 'true' || input === 'on' || input === '1') {
return {value: true}
} else if (input === 'false' || input === 'off' || input === '0') {
return {value: false}
} else {
return {error: 'Value ' + str + ' is not a boolean.'}
}
}, suggester: () => (['true', 'on', '1', 'false', 'off', '0'])
}
}
Replican.actions = {
prompt: {
name: 'prompt',
types: ['string'],
fn(newPrompt) { this.setPrompt(newPrompt) },
help: 'Sets the prompt string that appears at the start of an input line.'
},
exit: {
name: 'exit',
types: [],
fn() {
process.exit()
},
help: 'Quits.'
},
help: {
name: 'help',
types: ['command'],
fn(action) {
console.log(this.help(action));
},
help: 'Finds out the help information for an action.'
},
run: {
name: 'run',
types: ['file'],
async fn(file) {
const contents = fs.readFileSync(file, 'utf8').split('\n')
const starting = {};
let lastResult = starting;
for (let line of contents) {
if (lastResult !== starting) {
console.log(this.repl.writer(lastResult));
}
console.log(this.prompt + line);
lastResult = await this.exec(line);
}
return lastResult
},
help: 'Runs a file of commands.'
}
}
Replican.writers = {
redIfPresent: (output) => output === undefined ? '' : "\x1B[38;2;255;255;0m" + String(output) + "\x1B[0m"
}
const {string, boolean, file} = Replican.types;
const {prompt, exit, help, run} = Replican.actions;
const x = new Replican();
x.defineTypes(string, boolean, file);
x.defineActions(prompt, exit, help, run);
const addressBook = {
"adam": "Adam Iley",
"bob": "Robert Jones"
}
x.defineType("person", (str) => ({value: addressBook[str] || str}), () => Object.keys(addressBook))
x.defineAction('boom', ['boolean', 'boolean', 'string'], (a, b, c) => [a, b, c]);
x.defineAction('add', ['string', 'string'], (name, fullname) => addressBook[name] = fullname);
x.defineAction('who', ['person'], (name) => name, "Looks up the full name of a person.");
x.start({
writer: Replican.writers.redIfPresent
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment