/* * Requires sudo dnf install wl-clipboard TODOs: - Replace - limit occurrences - FIX - escape chars when building Regs - Case coverters - TODO - ability to undo `over $x ...` commands */ import readline from 'node:readline' import { promisify } from 'node:util' import { homedir } from 'node:os' import fs from 'node:fs' import crypto from 'node:crypto' import { dirname } from 'node:path' import { fileURLToPath } from 'node:url' const PRESERVE_SUBJECT = Symbol('preserve-subject') const PRESERVE_SUBJECT_NO_PRINT = Symbol('preserve-subject-no-print') const EXIT = Symbol('should-exit') const TERM = Symbol('terminator') const tempFile = () => `/tmp/str-${crypto.randomBytes(4).readUInt32LE(0)}.txt` const lipsumFile = () => `${dirname(fileURLToPath(import.meta.url))}/lipsum.txt` const capFirst = s => `${s[0].toUpperCase()}${s.slice(1)}` const randomInt = (min=0, max=100) => { min = Math.ceil(min) max = Math.floor(max) return Math.floor(Math.random() * (max - min + 1)) + min } const coinFlip = (chance=0.5) => Math.random() < chance let lipsumDict = [] const getLipsumDict = () => { if ( !lipsumDict.length ) { lipsumDict = fs.readFileSync(lipsumFile()) .toString('utf-8') .split('\n') .map(x => x.trim()) } return lipsumDict } const getRandomLipsum = (i=undefined) => { if ( i === 0 ) return 'lorem' if ( i === 1 ) return 'ipsum' const dict = getLipsumDict() return dict[Math.floor(Math.random() * dict.length)] } const makeState = () => ({ quote: "'", escape: "\\", terminator: '\n', session: `${homedir()}/.str.json`, rc: `${homedir()}/.str.rc`, commonQuotes: ["'", '"', '`'], debug: 0, encloses: { '(': ')', ')': '(', '[': ']', ']': '[', '{': '}', '}': '{', '<': '>', '>': '<', }, }) let state = makeState() const logIfDebug = (...out) => { if ( parseInt(state.debug) ) console.log(...out) } class Lexer { input = '' token = '' tokens = [] isEscape = false isQuote = false isLiteralEmpty = false isAsLiteral = false constructor(input) { this.input = input } advanceToken() { if ( this.token || this.isLiteralEmpty ) { this.tokens.push({ token: this.token, asLiteral: this.isAsLiteral }) this.token = '' this.isAsLiteral = false this.isLiteralEmpty = false } } run() { while ( this.input ) this.step() this.advanceToken() return this.tokens } step() { const c = this.input[0] this.input = this.input.substring(1) if ( this.isEscape ) { if ( !this.token && c === '$' ) this.isAsLiteral = true // escaping var names this.token += c this.isEscape = false return } if ( c === state.escape ) { this.isEscape = true return } if ( c === state.terminator ) { this.advanceToken() this.token = TERM this.advanceToken() return } if ( (c === ' ' || c === '\n' || c === '\t') && !this.isQuote ) { this.advanceToken() return } if ( c === state.quote ) { if ( this.isQuote ) { this.isQuote = false if ( !this.token ) this.isLiteralEmpty = true this.advanceToken() return } else { this.isAsLiteral = true this.isQuote = true return } } this.token += c } } class Parser { tokens = [] ast = [] constructor(tokens) { this.tokens = tokens } parse() { while ( this.tokens.length ) this.parseOnce() return this.ast } parseOnce() { const cmd = this.parseCommand() this.popTerm() this.ast.push(cmd) } parseCommand() { const token = this.popToken() const commandParsers = { copy: () => ({ command: 'copy' }), paste: () => ({ command: 'paste' }), infile: () => ({ command: 'infile', file: this.popToken() }), outfile: () => ({ command: 'outfile', file: this.popToken() }), save: () => ({ command: 'save', file: this.popOptionalToken() }), load: () => ({ command: 'load', file: this.popOptionalToken() }), edit: () => ({ command: 'edit' }), history: () => ({ command: 'history' }), exit: () => ({ command: 'exit' }), runfile: () => ({ command: 'runfile', file: this.popToken() }), to: () => ({ command: 'to', lval: this.popLValToken() }), from: () => ({ command: 'from', lval: this.popLValToken() }), lipsum: () => ({ command: 'lipsum', len: this.popToken(), type: this.popTokenInSet(['word', 'line', 'para']) }), indent: () => ({ command: 'indent', with: this.popTokenInSet(['spaces', 'tabs']), level: this.popOptionalToken(), }), trim: () => ({ command: 'trim', type: this.popOptionalTokenInSet(['start', 'end', 'both', 'left', 'right', 'lines'], 'both'), char: this.popOptionalToken(), }), quote: () => ({ command: 'quote', with: this.popOptionalToken(state.quote) }), unquote: () => ({ command: 'unquote', mark: this.popOptionalToken() }), enclose: () => ({ command: 'enclose', with: this.popOptionalToken('('), rwith: this.popOptionalToken() }), prefix: () => ({ command: 'prefix', with: this.popToken() }), suffix: () => ({ command: 'suffix', with: this.popToken() }), split: () => ({ command: 'split', on: this.popToken(), with: this.popOptionalToken('\n') }), lines: () => ({ command: 'lines', on: this.popOptionalToken(), with: this.popOptionalToken('\n') }), join: () => ({ command: 'join', with: this.popOptionalToken(',') }), replace: () => ({ command: 'replace', find: this.popToken(), with: this.popToken(), range: this.popOptionalRange(), }), lsub: () => ({ command: 'lsub', offset: this.popToken(), len: this.popOptionalToken() }), rsub: () => ({ command: 'rsub', offset: this.popToken(), len: this.popOptionalToken() }), reparse: () => ({ command: 'reparse', fromLang: this.popTokenInSet(['json', 'php']), toLang: this.popTokenInSet(['json', 'php']), }), contains: () => ({ command: 'contains', find: this.popToken() }), missing: () => ({ command: 'missing', find: this.popToken() }), upper: () => ({ command: 'upper' }), lower: () => ({ command: 'lower' }), unique: () => ({ command: 'unique' }), help: () => ({ command: 'help' }), show: () => ({ command: 'show' }), clear: () => ({ command: 'clear' }), undo: () => ({ command: 'undo', steps: this.popOptionalToken('1') }), redo: () => ({ command: 'redo', steps: this.popOptionalToken('1') }), set: () => ({ command: 'set', setting: this.popTokenInSet(['quote', 'escape', 'terminator', 'session', 'debug']), to: this.popOptionalToken(), }), over: () => ({ command: 'over', subject: this.popToken(), sub: this.parseCommand() }), line: () => ({ command: 'line', sub: this.parseCommand() }), word: () => ({ command: 'word', sub: this.parseCommand() }), on: () => ({ command: 'on', type: this.popTokenInSet(['line', 'word']), specific: this.popToken(), sub: this.parseCommand(), }), map: () => { const cmd = { command: 'map', type: this.popTokenInSet(['line', 'word']), start: this.popToken(), } let next = this.peekToken() if ( next.token === 'to' ) { this.popToken() cmd.to = this.popToken() next = this.peekToken() } if ( next.token === 'by' ) { this.popToken() cmd.by = this.popToken() } else { cmd.by = { token: '1', asLiteral: true } } cmd.sub = this.parseCommand() return cmd }, varSet: () => { this.popTokenInSet('=') const rval = this.popToken() return { command: 'varSet', lval: token, rval } }, varUnset: () => ({ command: 'varUnset', lval: this.popLValToken() }), vars: () => ({ command: 'vars' }), function: () => { const cmd = { command: 'function', name: this.popTokenAsLiteral(), subs: [], } this.popTerm() // Parse commands one at a time until we hit an `end function` while ( true ) { const sub = this.parseCommand() this.popTerm() if ( state.debug ) console.log({ function: cmd.name, sub }) if ( sub.command === 'function' ) throw new Error('Nested functions are not supported.') else if ( sub.command === 'end' && sub.type.token === 'function' ) break else cmd.subs.push(sub) } return cmd }, end: () => ({ command: 'end', type: this.popTokenInSetAsLiteral(['function']) }), call: () => ({ command: 'call', name: this.popTokenAsLiteral() }), // todo: args } const parser = (!token.asLiteral && token.token.startsWith('$')) ? commandParsers.varSet : commandParsers[token.token] if ( !parser ) { throw new Error('Cannot find parser for command: ' + token.token) } return parser() } popOptionalRange() { const token = this.peekToken() const tokenValue = token?.token.trim() if ( token && tokenValue.startsWith('$') && !token.asLiteral ) { // If the token is a variable, assume it may be a range and return it return this.popToken() } if ( !token || !tokenValue.startsWith('[') ) { return undefined } // Consume tokens until we find either a `]` or an invalid token: const rangeParts = [] while ( true ) { const next = this.popToken() let nextValue = next.token // If we are the first token, strip off the opening [ if ( !rangeParts.length && nextValue.startsWith('[') ) nextValue = nextValue.trim().substring(1) // Strip off the closing ] if present const hasClose = nextValue.trim().endsWith(']') if ( hasClose ) nextValue = nextValue.trim().substring(0, nextValue.length - 1) // Within a range, we may only have numbers, whitespace, and commas if ( !nextValue.match(/^[0-9\s,]*$/s) ) { throw new Error(`Found invalid characters in range context: ${nextValue}`) } rangeParts.push(nextValue) if ( hasClose ) break } const range = rangeParts.join('') .split(',') .map(x => parseInt(x.trim(), 10)) return { token: range, asLiteral: false, asRange: true } } popTerm() { if ( this.tokens.length && this.tokens[0].token !== TERM ) throw new Error('Expected TERM; instead found token: ' + this.tokens[0].token) if ( this.tokens.length ) this.tokens.shift() } popToken() { if ( !this.tokens.length || this.tokens[0].token === TERM ) throw new Error('Unexpected end of token stream!') return this.tokens.shift() } peekToken() { if ( !this.tokens.length || this.tokens[0].token === TERM ) return undefined return this.tokens[0] } popTokenAsLiteral() { return {...this.popToken(), asLiteral: true} } popLValToken() { const tok = this.popToken() if ( !tok.token.startsWith('$') ) throw new Error('Expected literal variable name. Found: ' + tok.token) return {...tok, asLiteral: false } } popTokenInSet(set) { const tok = this.popToken() if ( !set.includes(tok.token) ) throw new Error(`Invalid token "${tok}" (expected one of: ${set.join(',')})`) return tok } popTokenInSetAsLiteral(set) { return {...this.popTokenInSet(set), asLiteral: true} } popOptionalToken(fallback = undefined) { if ( !this.tokens.length || this.tokens[0].token === TERM ) return { token: fallback, asLiteral: true } return this.popToken() } popOptionalTokenInSet(set, fallback=undefined) { if ( !this.tokens.length || this.tokens[0].token === TERM ) return { token: fallback, asLiteral: true } const tok = this.popToken() if ( !set.includes(tok.token) ) throw new Error(`Invalid token "${tok.token}" (expected one of: ${set.join(',')})`) return tok } } const lexParseAndRunOnVM = async (vm, input) => { try { const tokens = (new Lexer(input)).run() logIfDebug('lexed tokens:', tokens) try { const cmds = (new Parser(tokens)).parse() logIfDebug('parsed commands:', cmds) await vm.runCommands(cmds) } catch (parseErr) { if ( state.debug ) console.error(parseErr) else console.log('ERROR: ' + parseErr.message) } } catch (lexErr) { if ( state.debug ) console.error(lexErr) else console.log('ERROR: ' + lexErr.message) } } class VM { subject = '' subjectBackHistory = [] subjectForwardHistory = [] vars = {} functions = {} rl = undefined question = undefined shouldExit = false getState() { return { subject: this.subject, subjectBackHistory: this.subjectBackHistory, subjectForwardHistory: this.subjectForwardHistory, globalState: state, vars: this.vars, functions: this.functions, } } loadState(saved) { this.subject = saved.subject this.subjectBackHistory = saved.subjectBackHistory this.subjectForwardHistory = saved.subjectForwardHistory this.vars = {...this.vars, ...saved.vars} this.functions = {...this.functions, ...saved.functions} state = saved.globalState } welcome() { console.log('str : An interactive string manipulation environment') console.log(' (Type `help` for more info, or `exit` to close.)') console.log('') } help() { console.log(fs.readFileSync(`${dirname(fileURLToPath(import.meta.url))}/HELP.txt`).toString('utf-8')) } replacePrompt() { this.closePrompt() this.rl = readline.createInterface({ input: process.stdin, output: process.stdout, }) this.question = promisify(this.rl.question).bind(this.rl) } getQuestionPrompt() { this.replacePrompt() return this.question } closePrompt() { this.rl?.close?.() this.rl = undefined this.question = undefined } replaceSubject(subject) { this.subjectBackHistory.push(this.subject) this.subjectForwardHistory = [] this.subject = subject } undoSubject() { if ( this.subjectBackHistory.length ) { this.subjectForwardHistory.push(this.subject) this.subject = this.subjectBackHistory.pop() } } redoSubject() { if ( this.subjectForwardHistory.length ) { this.subjectBackHistory.push(this.subject) this.subject = this.subjectForwardHistory.pop() || '' } } resolveImmediate(token) { if ( !token.token || token.asLiteral || token.asRange || !token.token.startsWith('$') ) { return token.token } const varName = token.token.substring(1) if ( !Object.prototype.hasOwnProperty.call(this.vars, varName) ) { throw new Error(`Undefined variable: $${varName}`) } return this.vars[varName] } async runCommands(cmds) { for ( const cmd of cmds ) { try { await this.runCommand(cmd) if ( this.shouldExit ) break } catch (e) { console.log('ERROR: ' + e.message) break } } } async runCommand(cmd) { const result = await this.runCommandOnSubject(cmd, this.subject) if ( result === EXIT ) { this.shouldExit = true return this } if ( result !== PRESERVE_SUBJECT && result !== PRESERVE_SUBJECT_NO_PRINT ) this.replaceSubject(result) if ( result !== PRESERVE_SUBJECT_NO_PRINT ) this.printValue(this.subject) return this } printValue(val, prefix = '') { console.log(`${prefix}\n---------------\n${val}\n---------------\n`) } async runCommandOnSubject(cmd, subject) { const runners = { copy: async () => { const childProcess = await import('node:child_process') const tmp = tempFile() await fs.writeFileSync(tmp, subject) const proc = childProcess.spawn('sh', ['-c', `wl-copy < "${tmp}"`], { stdio: 'inherit' }) await new Promise(res => { proc.on('close', () => res()) }) return PRESERVE_SUBJECT }, paste: async () => { const childProcess = await import('node:child_process') const tmp = tempFile() await fs.writeFileSync(tmp, subject) const proc = childProcess.spawn('sh', ['-c', `wl-paste > "${tmp}"`]) await new Promise(res => { proc.on('close', () => res()) }) return fs.readFileSync(tmp).toString('utf-8') }, edit: async () => { this.closePrompt() const childProcess = await import('node:child_process') const tmp = tempFile() await fs.writeFileSync(tmp, this.subject) const proc = childProcess.spawn(process.env.EDITOR || 'vim', [tmp], { stdio: 'inherit' }) await new Promise(res => { proc.on('close', () => res()) }) return fs.readFileSync(tmp).toString('utf-8') }, infile: () => fs.readFileSync(this.resolveImmediate(cmd.file)).toString('utf-8'), outfile: () => { fs.writeFileSync(this.resolveImmediate(cmd.file), subject) return subject }, save: () => { fs.writeFileSync(this.resolveImmediate(cmd.file) || state.session, JSON.stringify(this.getState())) return PRESERVE_SUBJECT }, load: () => { this.loadState(JSON.parse(fs.readFileSync(this.resolveImmediate(cmd.file) || state.session))) return PRESERVE_SUBJECT }, history: () => { for ( let i = 0; i < this.subjectBackHistory.length; i += 1 ) { console.log(`--------------- UNDO ${(this.subjectBackHistory.length - i).toString().padStart(2)} ---------------`) console.log(this.subjectBackHistory[i]) console.log('---------------------------------------') } console.log('') console.log('--------------- CURRENT ---------------') console.log(this.subject) console.log('---------------------------------------') console.log('') for ( let i = 0; i < this.subjectForwardHistory.length; i += 1 ) { console.log(`--------------- REDO ${i.toString().padStart(2)} ---------------`) console.log(this.subjectForwardHistory[i]) console.log('---------------------------------------') } return PRESERVE_SUBJECT_NO_PRINT }, exit: () => EXIT, runfile: async () => { const input = fs.readFileSync(this.resolveImmediate(cmd.file)).toString('utf-8') await lexParseAndRunOnVM(this, input) return PRESERVE_SUBJECT_NO_PRINT }, to: () => { this.vars[cmd.lval.token.substring(1)] = subject return PRESERVE_SUBJECT }, from: () => this.resolveImmediate(cmd.lval), lipsum: () => { const len = parseInt(this.resolveImmediate(cmd.len), 10) const type = this.resolveImmediate(cmd.type) const base = Array(len).fill(undefined) const genLipsumSentence = (i=0) => { const words = Array(randomInt(7, 18)) .fill(undefined) .map((_, j) => getRandomLipsum(i + j) + (coinFlip(0.2) ? ',' : '')) let line = words.join(' ') if ( line.endsWith(',') ) line = line.slice(0, -1) return capFirst(line) + '.' } if ( type === 'word' ) { return base.map((_, i) => getRandomLipsum(i)) .join(' ') } else if ( type === 'line' ) { return base.map((_, i) => genLipsumSentence(i)) .join('\n') } else if ( type === 'para' ) { return base.map((_, i) => Array(randomInt(2, 6)) .fill(undefined) .map((_, j) => genLipsumSentence(i + j))) .join('\n\n') return base.trim().split('\n\n').slice(0, len).join('\n\n') } return PRESERVE_SUBJECT }, indent: () => { const dent = this.resolveImmediate(cmd.with) === 'spaces' ? ''.padStart(parseInt(String(this.resolveImmediate(cmd.level) || '4')), ' ') : ''.padStart(parseInt(String(this.resolveImmediate(cmd.level) || '1')), '\t') return `${dent}${subject.replace(/^\s*/, '')}` }, trim: () => { const type = this.resolveImmediate(cmd.type) const char = this.resolveImmediate(cmd.char) if ( type === 'start' || type === 'left' || type === 'both' ) { const leftRex = new RegExp(`^${char || '\\s'}*`, 's') subject = subject.replace(leftRex, '') } if ( type === 'end' || type === 'right' || type === 'both' ) { const rightRex = new RegExp(`${char || '\\s'}*$`, 's') subject = subject.replace(rightRex, '') } if ( type === 'lines' ) { subject = subject.split('\n') .filter(l => l.trim()) .join('\n') } return subject }, quote: () => { for ( const mark of state.commonQuotes ) { if ( !subject.startsWith(mark) || !subject.endsWith(mark) ) continue subject = subject.substring(1, subject.length - 1) break } const withChar = this.resolveImmediate(cmd.with) return `${withChar || state.quote}${subject}${withChar || state.quote}` }, unquote: () => { const marks = state.commonQuotes if ( cmd.mark ) marks.unshift(this.resolveImmediate(cmd.mark)) for ( const mark of marks ) { if ( !subject.startsWith(mark) || !subject.endsWith(mark) ) continue subject = subject.substring(1, subject.length - 1) break } return subject }, enclose: () => { const withChar = this.resolveImmediate(cmd.with) const rwithChar = this.resolveImmediate(cmd.rwith) return `${withChar}${subject}${rwithChar || state.encloses[withChar] || withChar}` }, prefix: () => `${this.resolveImmediate(cmd.with)}${subject}`, suffix: () => `${subject}${this.resolveImmediate(cmd.with)}`, split: () => subject.split(this.resolveImmediate(cmd.on)).join(this.resolveImmediate(cmd.with)), lines: () => subject.split(new RegExp(`${this.resolveImmediate(cmd.on) || '\\s'}+`, 's')) .join(this.resolveImmediate(cmd.with)), join: () => subject.split('\n').join(this.resolveImmediate(cmd.with)), replace: () => { const find = this.resolveImmediate(cmd.find) const replace = this.resolveImmediate(cmd.with) const rangeToken = cmd.range ? this.resolveImmediate(cmd.range) : undefined const rangeArr = rangeToken ? [...rangeToken] : undefined // Split the string apart based on the `find` string: const literalParts = subject.split(find) // Now, stitch the string back together with the `replace` string, respecting // the range if it has been given: let replacedParts = [] let partsSinceLastReplace = 0 for ( let partIdx = 0; partIdx < literalParts.length; partIdx += 1 ) { const part = literalParts[partIdx] replacedParts.push(part) partsSinceLastReplace += 1 // If this is the last part of the string, we don't need to "replace" at the end if ( partIdx === literalParts.length - 1 ) break if ( !rangeArr?.length || partsSinceLastReplace === rangeArr[0] ) { // This is an occurrence we need to replace. // Do so, then reset the counter. replacedParts.push(replace) partsSinceLastReplace = 0 if ( rangeArr?.length > 1 ) rangeArr.shift() continue } // This isn't an occurrence we need to replace, so stitch it back w/ the original string replacedParts.push(find) } return replacedParts.join('') }, lsub: () => subject.slice( this.resolveImmediate(cmd.offset), this.resolveImmediate(cmd.offset) + (this.resolveImmediate(cmd.len) || subject.length)), rsub: () => subject.split('') .reverse() .slice( this.resolveImmediate(cmd.offset), this.resolveImmediate(cmd.offset) + (this.resolveImmediate(cmd.len) || subject.length)) .reverse() .join(''), upper: () => subject.toUpperCase(), lower: () => subject.toLowerCase(), unique: () => [...(new Set(subject.split('\n')))].join('\n'), // reparse, help: () => { this.help() return PRESERVE_SUBJECT_NO_PRINT }, show: () => PRESERVE_SUBJECT, clear: () => '', undo: () => { for ( let i = 0; i < parseInt(this.resolveImmediate(cmd.steps)); i += 1 ) { this.undoSubject() } return PRESERVE_SUBJECT }, redo: () => { for ( let i = 0; i < parseInt(this.resolveImmediate(cmd.steps)); i += 1 ) { this.redoSubject() } return PRESERVE_SUBJECT }, set: () => { const setting = this.resolveImmediate(cmd.setting) state[setting] = this.resolveImmediate(cmd.to) || (makeState()[setting]) return subject }, over: async () => { const explicitSubject = this.resolveImmediate(cmd.subject) const result = await this.runCommandOnSubject(cmd.sub, explicitSubject) if ( cmd.subject.token.startsWith('$') && !cmd.subject.asLiteral ) { this.vars[cmd.subject.token.substring(1)] = result } this.printValue(result) return PRESERVE_SUBJECT_NO_PRINT }, line: async () => this.mapSubjectLines( subject, line => this.runCommandOnSubject(cmd.sub, line)), word: async () => this.mapSubjectWords( subject, word => this.runCommandOnSubject(cmd.sub, word)), contains: () => subject.includes(this.resolveImmediate(cmd.find)) ? subject : '', missing: () => subject.includes(this.resolveImmediate(cmd.find)) ? '' : subject, on: async () => { const specific = this.resolveImmediate(cmd.specific) const type = this.resolveImmediate(cmd.type) if ( type === 'line' ) { return this.mapSubjectLines( subject, (line, idx) => (idx + 1) == specific ? this.runCommandOnSubject(cmd.sub, line) : line) } else if ( type === 'word' ) { return this.mapSubjectWords( subject, (word, idx) => (idx + 1) == specific ? this.runCommandOnSubject(cmd.sub, word) : word) } return subject }, map: async () => { const type = this.resolveImmediate(cmd.type) const loopForSubject = async (sub, idx) => { if ( (!to || idx < to) && (idx >= start) && !((idx - start) % by) ) { return this.runCommandOnSubject(cmd.sub, sub) } return sub } const start = this.resolveImmediate(cmd.start) const to = cmd.to ? this.resolveImmediate(cmd.to) : undefined const by = this.resolveImmediate(cmd.by) if ( type === 'line' ) { return this.mapSubjectLines(subject, loopForSubject) } else if ( type === 'word' ) { return this.mapSubjectWords(subject, loopForSubject) } }, varSet: () => { const varName = cmd.lval.token.substring(1) this.vars[varName] = this.resolveImmediate(cmd.rval) this.printValue(this.vars[varName], `$${varName} =`) return PRESERVE_SUBJECT_NO_PRINT }, varUnset: () => { const varName = cmd.lval.token.substring(1) this.resolveImmediate(cmd.lval.token) // to weed out undefined vars delete this.vars[varName] return PRESERVE_SUBJECT_NO_PRINT }, vars: () => { for ( const varName in this.vars ) this.printValue(this.vars[varName], `$${varName} =`) return PRESERVE_SUBJECT_NO_PRINT }, function: () => { this.functions[this.resolveImmediate(cmd.name)] = cmd return PRESERVE_SUBJECT_NO_PRINT }, call: async () => { const fnName = this.resolveImmediate(cmd.name) const fn = this.functions[fnName] if ( !fn ) throw new Error('Could not find function: ' + fnName) await this.runCommands(fn.subs) return PRESERVE_SUBJECT }, } const runner = runners[cmd.command] if ( !runner ) throw new Error('Invalid command: ' + cmd.command) return runner() } async mapSubjectLines(subject, closure) { return (await Promise.all(subject.split('\n') .map(closure))) .join('\n') } async mapSubjectWords(subject, closure) { const separators = [...subject.matchAll(/\s+/sg)] const words = await Promise.all(subject.split(/\s+/sg) .map(closure)) const parts = [] for ( let i = 0; i < words.length; i += 1 ) { parts.push(words[i]) if ( separators[i] ) parts.push(separators[i][0]) } return parts.join('') } } (async () => { const vm = new VM() vm.welcome() // Execute the user's ~/.strrc file if it exists if ( fs.existsSync(state.rc) ) { await lexParseAndRunOnVM(vm, `runfile ${state.quote}${state.rc}${state.quote}`) } while ( true ) { const question = vm.getQuestionPrompt() let ans = (await question('str %> ')) while ( state.terminator !== '\n' && !ans.trim().endsWith(state.terminator) ) { ans += '\n' + (await question('str |> ')) } logIfDebug('raw input:', ans) await lexParseAndRunOnVM(vm, ans) if ( vm.shouldExit ) break } vm.closePrompt() })();