str/str.mjs

793 lines
28 KiB
JavaScript

/*
* 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 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() }),
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() }),
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' }),
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()
}
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.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),
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: () => 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(''),
upper: () => subject.toUpperCase(),
lower: () => subject.toLowerCase(),
// 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()
})();