diff --git a/HELP.txt b/HELP.txt new file mode 100644 index 0000000..98e1b8e --- /dev/null +++ b/HELP.txt @@ -0,0 +1,199 @@ +str : An interactive string manipulation environment + Copyright (C) 2025 Garrett Mills + +---------------------------------------- +Input / Output +---------------------------------------- +copy + Copy the current string to the clipboard. (Requires wl-clipboard.) + +paste + Paste the contents of the clipboard to replace the current string. (Requires wl-clipboard.) + +infile + Replace the current string with the contents of . + +outfile + Write the current string as the contents of . + +edit + Open the current string in EDITOR. + +history + Print the undo/redo history + +runfile + Parse a file as a set of str commands and execute them. + +from + Replace the current string with the contents of the variable. + +to + Store the current string contents into the variable. + +---------------------------------------- +String Manipulation +---------------------------------------- +indent [] + Indent the string with the specified number of spaces or tabs. + Default is a single standard indentation level. + +trim [] [] + Remove instances of the given character from either the start/end or both sides of the string. + Default is to trim whitespace from both ends. + +quote [] + Surround the current string in the given quote character. + Default is to use the current "quote" setting, which defaults to a single-quote. + +unquote [] + Try to strip surrounding quotes of the given character from the string. + Will only proceed if the string has the quote mark on both ends. + Default is to try common quote schemes (single/double quotes, backtick). + +enclose [] + Wrap the string in the given character. Tries to match pairs when possible. + Example: Using `(` will wrap with a closing `)` + Default is to wrap with parentheses. + +prefix + Prepend to the current string. + +suffix + Append to the current string. + +split [] + Split the current string using the given separator, and rejoin it using the given separator. + Default is to rejoin on newlines. + +lines [] [] + Like `split`, but defaults to splitting on chunks of whitespace. + +join [] + Join separate lines in the string using the given separator. + +replace + Replace all instances of with . + +lsub [] + Replace the current string with a substring from the left, starting at . + Optionally, limit to characters. + +rsub [] + Like `lsub`, but works on the string from right-to-left. + +reparse + Assuming the string is a valid expression, parse it and convert it to encoding. + Example: reparse json php + +---------------------------------------- +Advanced Manipulation +---------------------------------------- +line + Runs the given command for every line in the string (separated by \n). + +word + Runs the given command for every word in the string (separated by whitespace). + +contains + Check if the current string contains . If not, replace it with an empty string. + +on + Run the given command on the nth line or word. + Example: on line 2 prefix + + +map [to ] [by ] + Map the subject line-wise or word-wise for the given range. + Default is to map until the end of the string if `to` is not provided, + and to apply to every single line/word. + Example: map line 1 to 5 by 2 prefix + + +over + Apply a command to the given and save it back in . + Example: over $a line prefix + + +---------------------------------------- +State Management +---------------------------------------- +show + Print out the current string. + +clear + Replace the current string with an empty string. + +undo [] + Undo the past operations on the string. Defaults to a single operation. + +redo + Redo the past operations that were undone. Defaults to a single operation. + +save [] + Store the current state of the interpreter to the given file path. + Defaults to ~/.str.json + +load [] + Restore a saved state from the given file path to the interpreter. + Defaults to ~/.str.json +exit + Exit the interpreter. + +set [] + Change the given interpreter to the given . + If no value is given, will reset it back to the default. + Supported settings: + quote (default: ') - What character is used to parse quoted strings. + Also sets the default for the `quote` command. + escape (default: \) - What character is used to parse escape sequences. + session (default: ~/.str.json) - Default file used by `save`/`load` commands. + debug (default: 0) - Set to 1 to enable debug mode + terminator (default: \n) - What character is used to parse sequential commands. + Example: `set terminator ;` will begin parsing commands + separated by a ; instead of when enter is pressed. + +---------------------------------------- +Variables +---------------------------------------- +Define/set a variable + $varname = + Example: $foo = example + $baz = 'another example' + +Use variables as arguments to commands + Example: line prefix $foo + +Use quotes to escape variable names + Example: line prefix '$foo' + +vars + Print the value of all defined variables + +varUnset + Unset a variable. + Example: varUnset $foo + +---------------------------------------- +Functions +---------------------------------------- +function + Start a function definition. Subsequent commands will be parsed + as part of the function body until `end function` is encountered. + + Functions may not be nested. + +end + End a block of the given type. Only supports `function` currently. + +call + Execute the given function. + +Example: +``` +function format_sql_in_clause + line lines + line trim + trim + line quote " + join , + enclose ( +end function +``` diff --git a/str.mjs b/str.mjs index a2db05c..4e61fb4 100644 --- a/str.mjs +++ b/str.mjs @@ -4,6 +4,7 @@ TODOs: - Replace - limit occurrences - FIX - escape chars when building Regs - Case coverters +- TODO - ability to undo `over $x ...` commands */ @@ -12,6 +13,8 @@ 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') @@ -24,12 +27,18 @@ const makeState = () => ({ escape: "\\", terminator: '\n', session: `${homedir()}/.str.json`, + rc: `${homedir()}/.str.rc`, commonQuotes: ["'", '"', '`'], debug: 0, encloses: { '(': ')', + ')': '(', '[': ']', + ']': '[', '{': '}', + '}': '{', + '<': '>', + '>': '<', }, }) @@ -46,6 +55,7 @@ class Lexer { isEscape = false isQuote = false isLiteralEmpty = false + isAsLiteral = false constructor(input) { this.input = input @@ -53,8 +63,9 @@ class Lexer { advanceToken() { if ( this.token || this.isLiteralEmpty ) { - this.tokens.push(this.token) + this.tokens.push({ token: this.token, asLiteral: this.isAsLiteral }) this.token = '' + this.isAsLiteral = false this.isLiteralEmpty = false } } @@ -70,6 +81,7 @@ class Lexer { 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 @@ -99,6 +111,7 @@ class Lexer { this.advanceToken() return } else { + this.isAsLiteral = true this.isQuote = true return } @@ -141,6 +154,9 @@ class Parser { 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() }), indent: () => ({ command: 'indent', @@ -149,12 +165,12 @@ class Parser { }), trim: () => ({ command: 'trim', - type: this.popOptionalTokenInSet(['start', 'end', 'both', 'left', 'right'], 'both'), + 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('(') }), + 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') }), @@ -169,6 +185,7 @@ class Parser { toLang: this.popTokenInSet(['json', 'php']), }), contains: () => ({ command: 'contains', find: this.popToken() }), + missing: () => ({ command: 'missing', find: this.popToken() }), help: () => ({ command: 'help' }), show: () => ({ command: 'show' }), @@ -181,51 +198,159 @@ class Parser { 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 = commandParsers[token] + const parser = (!token.asLiteral && token.token.startsWith('$')) + ? commandParsers.varSet + : commandParsers[token.token] + if ( !parser ) { - throw new Error('Cannot find parser for command: ' + token) + throw new Error('Cannot find parser for command: ' + token.token) } return parser() } popTerm() { - if ( this.tokens.length && this.tokens[0] !== TERM ) throw new Error('Expected TERM; instead found token: ' + this.tokens[0]) + 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] === TERM ) throw new Error('Unexpected end of token stream!') + 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) ) throw new Error(`Invalid token "${tok}" (expected one of: ${set.join(',')})`) + if ( !set.includes(tok.token) ) throw new Error(`Invalid token "${tok}" (expected one of: ${set.join(',')})`) return tok } - popOptionalToken(fallback=undefined) { - if ( !this.tokens.length || this.tokens[0] === TERM ) return fallback + 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] === TERM ) return fallback + if ( !this.tokens.length || this.tokens[0].token === TERM ) return { token: fallback, asLiteral: true } const tok = this.popToken() - if ( !set.includes(tok) ) throw new Error(`Invalid token "${tok}" (expected one of: ${set.join(',')})`) + 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 @@ -237,6 +362,8 @@ class VM { subjectBackHistory: this.subjectBackHistory, subjectForwardHistory: this.subjectForwardHistory, globalState: state, + vars: this.vars, + functions: this.functions, } } @@ -244,6 +371,8 @@ class VM { 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 } @@ -254,134 +383,7 @@ class VM { } help() { - console.log('str : An interactive string manipulation environment') - console.log(' Copyright (C) 2025 Garrett Mills ') - console.log('') - console.log('----------------------------------------') - console.log('Input / Output') - console.log('----------------------------------------') - console.log('copy') - console.log(' Copy the current string to the clipboard. (Requires wl-clipboard.)') - console.log('') - console.log('paste') - console.log(' Paste the contents of the clipboard to replace the current string. (Requires wl-clipboard.)') - console.log('') - console.log('infile ') - console.log(' Replace the current string with the contents of .') - console.log('') - console.log('outfile ') - console.log(' Write the current string as the contents of .') - console.log('') - console.log('edit') - console.log(' Open the current string in EDITOR.') - console.log('') - console.log('history') - console.log(' Print the undo/redo history') - console.log('') - console.log('----------------------------------------') - console.log('String Manipulation') - console.log('----------------------------------------') - console.log('indent []') - console.log(' Indent the string with the specified number of spaces or tabs.') - console.log(' Default is a single standard indentation level.') - console.log('') - console.log('trim [] []') - console.log(' Remove instances of the given character from either the start/end or both sides of the string.') - console.log(' Default is to trim whitespace from both ends.') - console.log('') - console.log('quote []') - console.log(' Surround the current string in the given quote character.') - console.log(' Default is to use the current "quote" setting, which defaults to a single-quote.') - console.log('') - console.log('unquote []') - console.log(' Try to strip surrounding quotes of the given character from the string.') - console.log(' Will only proceed if the string has the quote mark on both ends.') - console.log(' Default is to try common quote schemes (single/double quotes, backtick).') - console.log('') - console.log('enclose []') - console.log(' Wrap the string in the given character. Tries to match pairs when possible.') - console.log(' Example: Using `(` will wrap with a closing `)`') - console.log(' Default is to wrap with parentheses.') - console.log('') - console.log('prefix ') - console.log(' Prepend to the current string.') - console.log('') - console.log('suffix ') - console.log(' Append to the current string.') - console.log('') - console.log('split []') - console.log(' Split the current string using the given separator, and rejoin it using the given separator.') - console.log(' Default is to rejoin on newlines.') - console.log('') - console.log('lines [] []') - console.log(' Like `split`, but defaults to splitting on chunks of whitespace.') - console.log('') - console.log('join []') - console.log(' Join separate lines in the string using the given separator.') - console.log('') - console.log('replace ') - console.log(' Replace all instances of with .') - console.log('') - console.log('lsub []') - console.log(' Replace the current string with a substring from the left, starting at .') - console.log(' Optionally, limit to characters.') - console.log('') - console.log('rsub []') - console.log(' Like `lsub`, but works on the string from right-to-left.') - console.log('') - console.log('reparse ') - console.log(' Assuming the string is a valid expression, parse it and convert it to encoding.') - console.log(' Example: reparse json php') - console.log('') - console.log('----------------------------------------') - console.log('Advanced Manipulation') - console.log('----------------------------------------') - console.log('line ') - console.log(' Runs the given command for every line in the string (separated by \\n).') - console.log('') - console.log('word ') - console.log(' Runs the given command for every word in the string (separated by whitespace).') - console.log('') - console.log('contains ') - console.log(' Check if the current string contains . If not, replace it with an empty string.') - console.log('') - console.log('----------------------------------------') - console.log('State Management') - console.log('----------------------------------------') - console.log('show') - console.log(' Print out the current string.') - console.log('') - console.log('clear') - console.log(' Replace the current string with an empty string.') - console.log('') - console.log('undo []') - console.log(' Undo the past operations on the string. Defaults to a single operation.') - console.log('') - console.log('redo') - console.log(' Redo the past operations that were undone. Defaults to a single operation.') - console.log('') - console.log('save []') - console.log(' Store the current state of the interpreter to the given file path.') - console.log(' Defaults to ~/.str.json') - console.log('') - console.log('load []') - console.log(' Restore a saved state from the given file path to the interpreter.') - console.log(' Defaults to ~/.str.json') - console.log('exit') - console.log(' Exit the interpreter.') - console.log('') - console.log('set []') - console.log(' Change the given interpreter to the given .') - console.log(' If no value is given, will reset it back to the default.') - console.log(' Supported settings:') - console.log(' quote (default: \') - What character is used to parse quoted strings.') - console.log(' Also sets the default for the `quote` command.') - console.log(' escape (default: \\) - What character is used to parse escape sequences.') - console.log(' session (default: ~/.str.json) - Default file used by `save`/`load` commands.') - console.log(' debug (default: 0) - Set to 1 to enable debug mode') - console.log(' terminator (default: \\n) - What character is used to parse sequential commands.') - console.log(' Example: `set terminator ;` will begin parsing commands') - console.log(' separated by a ; instead of when enter is pressed.') + console.log(fs.readFileSync(`${dirname(fileURLToPath(import.meta.url))}/HELP.txt`).toString('utf-8')) } replacePrompt() { @@ -426,6 +428,19 @@ class VM { } } + resolveImmediate(token) { + if ( !token.token || token.asLiteral || !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 { @@ -446,17 +461,21 @@ class VM { } if ( result !== PRESERVE_SUBJECT && result !== PRESERVE_SUBJECT_NO_PRINT ) this.replaceSubject(result) - if ( result !== PRESERVE_SUBJECT_NO_PRINT ) console.log(`\n---------------\n${this.subject}\n---------------\n`) + 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}"`]) + const proc = childProcess.spawn('sh', ['-c', `wl-copy < "${tmp}"`], { stdio: 'inherit' }) await new Promise(res => { proc.on('close', () => res()) }) @@ -483,17 +502,17 @@ class VM { }) return fs.readFileSync(tmp).toString('utf-8') }, - infile: () => fs.readFileSync(cmd.file).toString('utf-8'), + infile: () => fs.readFileSync(this.resolveImmediate(cmd.file)).toString('utf-8'), outfile: () => { - fs.writeFileSync(cmd.file, subject) + fs.writeFileSync(this.resolveImmediate(cmd.file), subject) return subject }, save: () => { - fs.writeFileSync(cmd.file || state.session, JSON.stringify(this.getState())) + fs.writeFileSync(this.resolveImmediate(cmd.file) || state.session, JSON.stringify(this.getState())) return PRESERVE_SUBJECT }, load: () => { - this.loadState(JSON.parse(fs.readFileSync(cmd.file || state.session))) + this.loadState(JSON.parse(fs.readFileSync(this.resolveImmediate(cmd.file) || state.session))) return PRESERVE_SUBJECT }, history: () => { @@ -515,25 +534,43 @@ class VM { 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), indent: () => { - const dent = cmd.with === 'spaces' - ? ''.padStart(parseInt(String(cmd.level || '4')), ' ') - : ''.padStart(parseInt(String(cmd.level || '1')), '\t') + 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: () => { - if ( cmd.type === 'start' || cmd.type === 'left' || cmd.type === 'both' ) { - const leftRex = new RegExp(`^${cmd.char || '\\s'}*`, 's') + 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 ( cmd.type === 'end' || cmd.type === 'right' || cmd.type === 'both' ) { - const rightRex = new RegExp(`${cmd.char || '\\s'}*$`, 's') + 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: () => { @@ -543,11 +580,12 @@ class VM { break } - return `${cmd.with || state.quote}${subject}${cmd.with || state.quote}` + const withChar = this.resolveImmediate(cmd.with) + return `${withChar || state.quote}${subject}${withChar || state.quote}` }, unquote: () => { const marks = state.commonQuotes - if ( cmd.mark ) marks.unshift(cmd.mark) + 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) @@ -556,15 +594,28 @@ class VM { return subject }, - enclose: () => `${cmd.with}${subject}${state.encloses[cmd.with] || cmd.with}`, - prefix: () => `${cmd.with}${subject}`, - suffix: () => `${subject}${cmd.with}`, - split: () => subject.split(cmd.on).join(cmd.with), - lines: () => subject.split(new RegExp(`${cmd.on || '\\s'}+`, 's')).join(cmd.with), - join: () => subject.split('\n').join(cmd.with), - replace: () => subject.replaceAll(cmd.find, cmd.with), - lsub: () => subject.slice(cmd.offset, cmd.offset + (cmd.len || subject.length)), - rsub: () => subject.split('').reverse().slice(cmd.offset, cmd.offset + (cmd.len || subject.length)).reverse().join(''), + 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: () => subject.replaceAll(this.resolveImmediate(cmd.find), this.resolveImmediate(cmd.with)), + 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(''), // reparse, help: () => { @@ -574,54 +625,149 @@ class VM { show: () => PRESERVE_SUBJECT, clear: () => '', undo: () => { - for ( let i = 0; i < parseInt(cmd.steps); i += 1 ) { + for ( let i = 0; i < parseInt(this.resolveImmediate(cmd.steps)); i += 1 ) { this.undoSubject() } return PRESERVE_SUBJECT }, redo: () => { - for ( let i = 0; i < parseInt(cmd.steps); i += 1 ) { + for ( let i = 0; i < parseInt(this.resolveImmediate(cmd.steps)); i += 1 ) { this.redoSubject() } return PRESERVE_SUBJECT }, set: () => { - state[cmd.setting] = cmd.to || (makeState()[cmd.setting]) + const setting = this.resolveImmediate(cmd.setting) + state[setting] = this.resolveImmediate(cmd.to) || (makeState()[setting]) return subject }, - line: async () => (await Promise.all(subject - .split('\n') - .map(line => this.runCommandOnSubject(cmd.sub, line)))) - .join('\n'), - word: async () => { - const separators = [...subject.matchAll(/\s+/sg)] - const words = await Promise.all(subject.split(/\s+/sg) - .map(word => this.runCommandOnSubject(cmd.sub, word))) - - const parts = [] - for ( let i = 0; i < words.length; i += 1 ) { - parts.push(words[i]) - if ( separators[i] ) parts.push(separators[i][0]) + 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 } - return parts.join('') + 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 }, - contains: () => subject.includes(cmd.find) ? 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 %> ')) @@ -630,20 +776,8 @@ while ( true ) { } logIfDebug('raw input:', ans) - try { - const tokens = (new Lexer(ans)).run() - logIfDebug('lexed tokens:', tokens) - try { - const cmds = (new Parser(tokens)).parse() - logIfDebug('parsed commands:', cmds) - await vm.runCommands(cmds) - if ( vm.shouldExit ) break - } catch (parseErr) { - console.log('ERROR: ' + parseErr.message) - } - } catch (lexErr) { - console.log('ERROR: ' + lexErr.message) - } + await lexParseAndRunOnVM(vm, ans) + if ( vm.shouldExit ) break } vm.closePrompt()