Implement on, map, over, from, to commands and update HELP

This commit is contained in:
Garrett Mills 2025-11-07 01:41:11 -06:00
parent dd0fdbff3b
commit fb00c2fc41
2 changed files with 531 additions and 198 deletions

199
HELP.txt Normal file
View File

@ -0,0 +1,199 @@
str : An interactive string manipulation environment
Copyright (C) 2025 Garrett Mills <shout@garrettmills.dev>
----------------------------------------
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 <file path>
Replace the current string with the contents of <file path>.
outfile <file path>
Write the current string as the contents of <file path>.
edit
Open the current string in EDITOR.
history
Print the undo/redo history
runfile <file path>
Parse a file as a set of str commands and execute them.
from <var>
Replace the current string with the contents of the <var> variable.
to <var>
Store the current string contents into the <var> variable.
----------------------------------------
String Manipulation
----------------------------------------
indent <spaces|tabs> [<level>]
Indent the string with the specified number of spaces or tabs.
Default is a single standard indentation level.
trim [<start|end|both|lines>] [<char>]
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 [<char>]
Surround the current string in the given quote character.
Default is to use the current "quote" setting, which defaults to a single-quote.
unquote [<char>]
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 [<char>]
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 <string>
Prepend <string> to the current string.
suffix <string>
Append <string> to the current string.
split <on> [<join>]
Split the current string using the given <on> separator, and rejoin it using the given <join> separator.
Default is to rejoin on newlines.
lines [<on>] [<join>]
Like `split`, but defaults to splitting on chunks of whitespace.
join [<with>]
Join separate lines in the string using the given <with> separator.
replace <find> <replace>
Replace all instances of <find> with <replace>.
lsub <offset> [<length>]
Replace the current string with a substring from the left, starting at <offset>.
Optionally, limit to <length> characters.
rsub <offset> [<length>]
Like `lsub`, but works on the string from right-to-left.
reparse <fromlang> <tolang>
Assuming the string is a valid <fromlang> expression, parse it and convert it to <tolang> encoding.
Example: reparse json php
----------------------------------------
Advanced Manipulation
----------------------------------------
line <command...>
Runs the given command for every line in the string (separated by \n).
word <command...>
Runs the given command for every word in the string (separated by whitespace).
contains <search>
Check if the current string contains <search>. If not, replace it with an empty string.
on <line|word> <index> <command...>
Run the given command on the nth line or word.
Example: on line 2 prefix +
map <line|word> <start index> [to <end index>] [by <nth lines>] <command...>
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 <var> <command...>
Apply a command to the given <var> and save it back in <var>.
Example: over $a line prefix +
----------------------------------------
State Management
----------------------------------------
show
Print out the current string.
clear
Replace the current string with an empty string.
undo [<steps>]
Undo the past <steps> operations on the string. Defaults to a single operation.
redo
Redo the past <steps> operations that were undone. Defaults to a single operation.
save [<file>]
Store the current state of the interpreter to the given file path.
Defaults to ~/.str.json
load [<file>]
Restore a saved state from the given file path to the interpreter.
Defaults to ~/.str.json
exit
Exit the interpreter.
set <setting> [<value>]
Change the given interpreter <setting> to the given <value>.
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 = <string>
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 <var>
Unset a variable.
Example: varUnset $foo
----------------------------------------
Functions
----------------------------------------
function <name>
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 <block>
End a block of the given type. Only supports `function` currently.
call <name>
Execute the given function.
Example:
```
function format_sql_in_clause
line lines
line trim
trim
line quote "
join ,
enclose (
end function
```

530
str.mjs
View File

@ -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 <shout@garrettmills.dev>')
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 <file path>')
console.log(' Replace the current string with the contents of <file path>.')
console.log('')
console.log('outfile <file path>')
console.log(' Write the current string as the contents of <file path>.')
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 <spaces|tabs> [<level>]')
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 [<start|end|both>] [<char>]')
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 [<char>]')
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 [<char>]')
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 [<char>]')
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 <string>')
console.log(' Prepend <string> to the current string.')
console.log('')
console.log('suffix <string>')
console.log(' Append <string> to the current string.')
console.log('')
console.log('split <on> [<join>]')
console.log(' Split the current string using the given <on> separator, and rejoin it using the given <join> separator.')
console.log(' Default is to rejoin on newlines.')
console.log('')
console.log('lines [<on>] [<join>]')
console.log(' Like `split`, but defaults to splitting on chunks of whitespace.')
console.log('')
console.log('join [<with>]')
console.log(' Join separate lines in the string using the given <with> separator.')
console.log('')
console.log('replace <find> <replace>')
console.log(' Replace all instances of <find> with <replace>.')
console.log('')
console.log('lsub <offset> [<length>]')
console.log(' Replace the current string with a substring from the left, starting at <offset>.')
console.log(' Optionally, limit to <length> characters.')
console.log('')
console.log('rsub <offset> [<length>]')
console.log(' Like `lsub`, but works on the string from right-to-left.')
console.log('')
console.log('reparse <fromlang> <tolang>')
console.log(' Assuming the string is a valid <fromlang> expression, parse it and convert it to <tolang> encoding.')
console.log(' Example: reparse json php')
console.log('')
console.log('----------------------------------------')
console.log('Advanced Manipulation')
console.log('----------------------------------------')
console.log('line <command...>')
console.log(' Runs the given command for every line in the string (separated by \\n).')
console.log('')
console.log('word <command...>')
console.log(' Runs the given command for every word in the string (separated by whitespace).')
console.log('')
console.log('contains <search>')
console.log(' Check if the current string contains <search>. 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 [<steps>]')
console.log(' Undo the past <steps> operations on the string. Defaults to a single operation.')
console.log('')
console.log('redo')
console.log(' Redo the past <steps> operations that were undone. Defaults to a single operation.')
console.log('')
console.log('save [<file>]')
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 [<file>]')
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 <setting> [<value>]')
console.log(' Change the given interpreter <setting> to the given <value>.')
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()