Last active
March 2, 2018 11:08
-
-
Save kybernetikos/26b30952e5313513cb023cf3064791de to your computer and use it in GitHub Desktop.
REPL with types and autocomplete
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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