This commit is contained in:
Garrett Mills 2025-11-05 09:25:10 -06:00
commit dd0fdbff3b
3 changed files with 660 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.idea
node_modules

4
README.md Normal file
View File

@ -0,0 +1,4 @@
# `str`: An interactive string manipulation environment
WIP

654
str.mjs Normal file
View File

@ -0,0 +1,654 @@
/*
* Requires sudo dnf install wl-clipboard
TODOs:
- Replace - limit occurrences
- FIX - escape chars when building Regs
- Case coverters
*/
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'
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`,
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
constructor(input) {
this.input = input
}
advanceToken() {
if ( this.token || this.isLiteralEmpty ) {
this.tokens.push(this.token)
this.token = ''
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 ) {
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.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' }),
indent: () => ({
command: 'indent',
with: this.popTokenInSet(['spaces', 'tabs']),
level: this.popOptionalToken(),
}),
trim: () => ({
command: 'trim',
type: this.popOptionalTokenInSet(['start', 'end', 'both', 'left', 'right'], 'both'),
char: this.popOptionalToken(),
}),
quote: () => ({ command: 'quote', with: this.popOptionalToken(state.quote) }),
unquote: () => ({ command: 'unquote', mark: this.popOptionalToken() }),
enclose: () => ({ command: 'enclose', with: 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() }),
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(),
}),
line: () => ({ command: 'line', sub: this.parseCommand() }),
word: () => ({ command: 'word', sub: this.parseCommand() }),
}
const parser = commandParsers[token]
if ( !parser ) {
throw new Error('Cannot find parser for command: ' + 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.shift()
}
popToken() {
if ( !this.tokens.length || this.tokens[0] === TERM ) throw new Error('Unexpected end of token stream!')
return this.tokens.shift()
}
popTokenInSet(set) {
const tok = this.popToken()
if ( !set.includes(tok) ) 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
return this.popToken()
}
popOptionalTokenInSet(set, fallback=undefined) {
if ( !this.tokens.length || this.tokens[0] === TERM ) return fallback
const tok = this.popToken()
if ( !set.includes(tok) ) throw new Error(`Invalid token "${tok}" (expected one of: ${set.join(',')})`)
return tok
}
}
class VM {
subject = ''
subjectBackHistory = []
subjectForwardHistory = []
rl = undefined
question = undefined
shouldExit = false
getState() {
return {
subject: this.subject,
subjectBackHistory: this.subjectBackHistory,
subjectForwardHistory: this.subjectForwardHistory,
globalState: state,
}
}
loadState(saved) {
this.subject = saved.subject
this.subjectBackHistory = saved.subjectBackHistory
this.subjectForwardHistory = saved.subjectForwardHistory
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('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.')
}
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() || ''
}
}
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 ) console.log(`\n---------------\n${this.subject}\n---------------\n`)
return this
}
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}"`])
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(cmd.file).toString('utf-8'),
outfile: () => {
fs.writeFileSync(cmd.file, subject)
return subject
},
save: () => {
fs.writeFileSync(cmd.file || state.session, JSON.stringify(this.getState()))
return PRESERVE_SUBJECT
},
load: () => {
this.loadState(JSON.parse(fs.readFileSync(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,
indent: () => {
const dent = cmd.with === 'spaces'
? ''.padStart(parseInt(String(cmd.level || '4')), ' ')
: ''.padStart(parseInt(String(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')
subject = subject.replace(leftRex, '')
}
if ( cmd.type === 'end' || cmd.type === 'right' || cmd.type === 'both' ) {
const rightRex = new RegExp(`${cmd.char || '\\s'}*$`, 's')
subject = subject.replace(rightRex, '')
}
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
}
return `${cmd.with || state.quote}${subject}${cmd.with || state.quote}`
},
unquote: () => {
const marks = state.commonQuotes
if ( cmd.mark ) marks.unshift(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: () => `${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(''),
// reparse,
help: () => {
this.help()
return PRESERVE_SUBJECT_NO_PRINT
},
show: () => PRESERVE_SUBJECT,
clear: () => '',
undo: () => {
for ( let i = 0; i < parseInt(cmd.steps); i += 1 ) {
this.undoSubject()
}
return PRESERVE_SUBJECT
},
redo: () => {
for ( let i = 0; i < parseInt(cmd.steps); i += 1 ) {
this.redoSubject()
}
return PRESERVE_SUBJECT
},
set: () => {
state[cmd.setting] = cmd.to || (makeState()[cmd.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])
}
return parts.join('')
},
contains: () => subject.includes(cmd.find) ? subject : '',
}
const runner = runners[cmd.command]
if ( !runner ) throw new Error('Invalid command: ' + cmd.command)
return runner()
}
}
(async () => {
const vm = new VM()
vm.welcome()
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)
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)
}
}
vm.closePrompt()
})();