Introduce TermOperator helper and refactor execute()s to use replaceSubjectMatchingTerm

This commit is contained in:
Garrett Mills 2026-02-09 23:49:30 -06:00
parent feba84051a
commit f36621c646
25 changed files with 243 additions and 159 deletions

View File

@ -17,6 +17,6 @@ export class Clear extends Command<{}> {
}
execute(vm: StrVM): Awaitable<StrVM> {
return vm.tapInPlace(ctx => ctx.replaceSubjectAsString(''))
return vm.replaceContextMatchingTerm({ override: '' })
}
}

View File

@ -55,6 +55,11 @@ export const unwrapString = (term: StrRVal): string => {
return term.value
}
export const wrapInt = (val: number): StrRVal => ({
term: 'int',
value: val,
})
export const unwrapInt = (term: StrRVal): number => {
if ( term.term !== 'int' ) {
throw new Error('Unexpected error: cannot unwrap term: is not an int')

View File

@ -19,10 +19,10 @@ export class Contains extends Command<{ find: StrTerm }> {
}
execute(vm: StrVM, data: { find: StrTerm }): Awaitable<StrVM> {
return vm.tapInPlace(ctx =>
ctx.replaceSubjectAsString(sub =>
sub.includes(ctx.resolveString(data.find))
? sub
: ''))
return vm.replaceContextMatchingTerm(ctx => ({
string: sub => sub.includes(ctx.resolveString(data.find)) ? sub : '',
destructured: parts => parts.filter(part =>
part.value.includes(ctx.resolveString(data.find))),
}))
}
}

View File

@ -30,8 +30,8 @@ export class Each extends Command<EachData> {
.map(async part => ({
prefix: part.prefix,
value: await vm.runInChild(async (child, ctx) => {
await child.replaceContextMatchingTerm({ override: part.value })
return child.runInPlace(async ctx => {
await ctx.replaceSubjectAsString(part.value)
await data.exec.command.execute(child, data.exec.data)
return unwrapString(ctx.getSubject())
})

View File

@ -25,14 +25,16 @@ export class Enclose extends Command<EncloseData> {
}
execute(vm: StrVM, data: EncloseData): Awaitable<StrVM> {
return vm.tapInPlace(ctx => {
const [left, right] = this.determineSurroundingStrings(
data.left ? ctx.resolveString(data.left) : undefined,
data.right ? ctx.resolveString(data.right) : undefined,
)
return vm.replaceContextMatchingTerm(ctx => ({
string: sub => {
const [left, right] = this.determineSurroundingStrings(
data.left ? ctx.resolveString(data.left) : undefined,
data.right ? ctx.resolveString(data.right) : undefined,
)
return ctx.replaceSubjectAsString(sub => `${left}${sub}${right}`)
})
return `${left}${sub}${right}`
}
}))
}
private determineSurroundingStrings(left?: string, right?: string): [string, string] {

View File

@ -17,8 +17,8 @@ export class From extends Command<{ var: StrLVal }> {
}
execute(vm: StrVM, data: { var: StrLVal }): Awaitable<StrVM> {
return vm.tapInPlace(ctx =>
ctx.replaceSubject(() =>
ctx.resolveRequired(data.var)))
return vm.replaceContextMatchingTerm(ctx => ({
override: ctx.resolveRequired(data.var),
}))
}
}

View File

@ -19,16 +19,16 @@ export class Join extends Command<{ with?: StrTerm }> {
}
execute(vm: StrVM, data: { with?: StrTerm }): Awaitable<StrVM> {
return vm.tapInPlace(ctx =>
ctx.replaceSubject(sub => {
return vm.replaceContextMatchingTerm(ctx => ({
restructure: parts => {
if ( data.with ) {
return wrapString(
unwrapDestructured(sub)
.map(part => part.value)
.join(ctx.resolveString(data.with)))
return parts
.map(part => part.value)
.join(ctx.resolveString(data.with))
}
return wrapString(joinDestructured(unwrapDestructured(sub)))
}))
return joinDestructured(parts)
}
}))
}
}

View File

@ -28,13 +28,10 @@ export class Line extends Command<LineData> {
execute(vm: StrVM, data: LineData): Awaitable<StrVM> {
// `line <cmd>` is equivalent to `lines` -> `each <cmd>` -> `join`, so just do that
return vm.tapInPlace(ctx =>
ctx.replaceSubject(async sub =>
vm.runInChild(async (child, ctx) => {
await (new Lines).execute(child, {})
await (new Each).execute(child, { exec: data.exec })
await (new Join).execute(child, {})
return ctx.getSubject()
})))
return vm.replaceContextFromChild(async child => {
await (new Lines).execute(child)
await (new Each).execute(child, { exec: data.exec })
await (new Join).execute(child, {})
})
}
}

View File

@ -3,10 +3,8 @@ import {LexInput} from "../lexer.js";
import {StrVM} from "../vm.js";
import {Awaitable} from "../../util/types.js";
export type LinesData = {}
export class Lines extends Command<LinesData> {
attemptParse(context: ParseContext): LinesData {
export class Lines extends Command<{}> {
attemptParse(context: ParseContext): {} {
return {}
}
@ -18,13 +16,15 @@ export class Lines extends Command<LinesData> {
return this.isKeyword(token, 'lines')
}
execute(vm: StrVM, data: LinesData): Awaitable<StrVM> {
return vm.tapInPlace(ctx =>
ctx.replaceSubject(sub => ({
term: 'destructured',
value: unwrapString(sub)
.split('\n')
.map((line, idx) => ({ prefix: idx ? '\n' : undefined, value: line })),
})))
execute(vm: StrVM): Awaitable<StrVM> {
return vm.replaceContextMatchingTerm({
destructure: sub => {
return sub.split('\n')
.map((line, idx) => ({
prefix: idx ? '\n' : undefined,
value: line,
}))
},
})
}
}

View File

@ -17,7 +17,8 @@ export class Lower extends Command<{}> {
}
execute(vm: StrVM): Awaitable<StrVM> {
return vm.tapInPlace(ctx =>
ctx.replaceSubjectAsString(sub => sub.toLowerCase()))
return vm.replaceContextMatchingTerm({
stringOrDestructuredPart: sub => sub.toLowerCase(),
})
}
}

View File

@ -25,11 +25,17 @@ export class LSub extends Command<LSubData> {
}
execute(vm: StrVM, data: LSubData): Awaitable<StrVM> {
return vm.tapInPlace(ctx =>
ctx.replaceSubjectAsString(sub =>{
return vm.replaceContextMatchingTerm(ctx => ({
string: sub => {
const offset = ctx.resolveInt(data.offset)
const length = data.length ? ctx.resolveInt(data.length) : sub.length
return sub.slice(offset, offset + length)
}))
},
destructured: parts => {
const offset = ctx.resolveInt(data.offset)
const length = data.length ? ctx.resolveInt(data.length) : parts.length
return parts.slice(offset, offset + length)
},
}))
}
}

View File

@ -19,10 +19,10 @@ export class Missing extends Command<{ find: StrTerm }> {
}
execute(vm: StrVM, data: { find: StrTerm }): Awaitable<StrVM> {
return vm.tapInPlace(ctx =>
ctx.replaceSubjectAsString(sub =>
sub.includes(ctx.resolveString(data.find))
? ''
: sub))
return vm.replaceContextMatchingTerm(ctx => ({
string: sub => sub.includes(ctx.resolveString(data.find)) ? '' : sub,
destructured: parts => parts.filter(part =>
!part.value.includes(ctx.resolveString(data.find))),
}))
}
}

View File

@ -19,7 +19,8 @@ export class Prefix extends Command<{ with: StrTerm }> {
}
execute(vm: StrVM, data: { with: StrTerm }): Awaitable<StrVM> {
return vm.tapInPlace(ctx =>
ctx.replaceSubjectAsString(sub => `${ctx.resolveString(data.with)}${sub}`))
return vm.replaceContextMatchingTerm(ctx => ({
string: sub => `${ctx.resolveString(data.with)}${sub}`,
}))
}
}

View File

@ -38,8 +38,8 @@ export class Quote extends Command<{ with?: StrTerm }> {
}
execute(vm: StrVM, data: { with?: StrTerm }): Awaitable<StrVM> {
return vm.tapInPlace(ctx =>
ctx.replaceSubjectAsString(sub => {
return vm.replaceContextMatchingTerm(ctx => ({
string: sub => {
let quote = '\''
if ( data.with ) {
quote = ctx.resolveString(data.with)
@ -47,6 +47,7 @@ export class Quote extends Command<{ with?: StrTerm }> {
sub = stripQuotemarkLayer(sub)
return `${quote}${sub}${quote}`
}))
}
}))
}
}

View File

@ -28,12 +28,9 @@ export class Replace extends Command<ReplaceData> {
execute(vm: StrVM, data: ReplaceData): Awaitable<StrVM> {
// `replace <a> <b>` is equivalent to: `split <a>` -> `join <b>`, so do that:
return vm.tapInPlace(ctx =>
ctx.replaceSubject(async sub =>
vm.runInChild(async (child, ctx) => {
await (new Split).execute(child, { on: data.find })
await (new Join).execute(child, { with: data.with })
return ctx.getSubject()
})))
return vm.replaceContextFromChild(async child => {
await (new Split).execute(child, { on: data.find })
await (new Join).execute(child, { with: data.with })
})
}
}

View File

@ -26,8 +26,8 @@ export class RSub extends Command<RSubData> {
}
execute(vm: StrVM, data: LSubData): Awaitable<StrVM> {
return vm.tapInPlace(ctx =>
ctx.replaceSubjectAsString(sub => {
return vm.replaceContextMatchingTerm(ctx => ({
string: sub => {
const offset = ctx.resolveInt(data.offset)
const length = data.length ? ctx.resolveInt(data.length) : sub.length
return sub.split('') // fixme: do the math so we don't have to do this bs
@ -35,6 +35,14 @@ export class RSub extends Command<RSubData> {
.slice(offset, offset + length)
.reverse()
.join('')
}))
},
destructured: parts => {
const offset = ctx.resolveInt(data.offset)
const length = data.length ? ctx.resolveInt(data.length) : parts.length
return parts.reverse()
.slice(offset, offset + length)
.reverse()
},
}))
}
}

View File

@ -1,4 +1,4 @@
import {Command, ParseContext, StrTerm, unwrapString} from "./command.js";
import {Command, ParseContext, StrTerm, unwrapString, wrapDestructured} from "./command.js";
import {LexInput} from "../lexer.js";
import {StrVM} from "../vm.js";
import {Awaitable} from "../../util/types.js";
@ -24,15 +24,15 @@ export class Split extends Command<SplitData> {
}
execute(vm: StrVM, data: SplitData): Awaitable<StrVM> {
return vm.tapInPlace(ctx =>
ctx.replaceSubject(sub => {
return vm.replaceContextMatchingTerm(ctx => ({
destructure: sub => {
const prefix = ctx.resolveString(data.on)
const resolved = unwrapString(sub)
return {
term: 'destructured',
value: resolved.split(prefix)
.map((segment, idx) => ({ prefix: idx ? prefix : undefined, value: segment })),
}
}))
return sub.split(prefix)
.map((segment, idx) => ({
prefix: idx ? prefix : undefined,
value: segment,
}))
}
}))
}
}

View File

@ -19,7 +19,8 @@ export class Suffix extends Command<{ with: StrTerm }> {
}
execute(vm: StrVM, data: { with: StrTerm }): Awaitable<StrVM> {
return vm.tapInPlace(ctx =>
ctx.replaceSubjectAsString(sub => `${sub}${ctx.resolveString(data.with)}`))
return vm.replaceContextMatchingTerm(ctx => ({
string: sub => `${sub}${ctx.resolveString(data.with)}`,
}))
}
}

View File

@ -26,8 +26,8 @@ export class Trim extends Command<TrimData> {
}
execute(vm: StrVM, data: TrimData): Awaitable<StrVM> {
return vm.tapInPlace(ctx =>
ctx.replaceSubjectAsString(sub => {
return vm.replaceContextMatchingTerm(ctx => ({
string: sub => {
const char = data.char
? rexEscape(ctx.resolveString(data.char))
: '\\s'
@ -49,6 +49,7 @@ export class Trim extends Command<TrimData> {
}
return sub
}))
},
}))
}
}

View File

@ -17,20 +17,19 @@ export class Unique extends Command<{}> {
}
execute(vm: StrVM): Awaitable<StrVM> {
return vm.tapInPlace(ctx =>
ctx.replaceSubject(sub => {
return vm.replaceContextMatchingTerm({
destructured: sub => {
const seen: Record<string, boolean> = {}
return wrapDestructured(
unwrapDestructured(sub)
.filter(part => {
const hash = hashStrRVal(wrapString(part.value))
if ( seen[hash] ) {
return false
}
return sub.filter(part => {
const hash = hashStrRVal(wrapString(part.value))
if ( seen[hash] ) {
return false
}
seen[hash] = true
return true
}))
}))
seen[hash] = true
return true
})
},
})
}
}

View File

@ -20,14 +20,15 @@ export class Unquote extends Command<{ with?: StrTerm }> {
}
execute(vm: StrVM, data: { with?: StrTerm }): Awaitable<StrVM> {
return vm.tapInPlace(ctx =>
ctx.replaceSubjectAsString(sub => {
return vm.replaceContextMatchingTerm(ctx => ({
string: sub => {
let marks: string[]|undefined = undefined
if ( data.with ) {
marks = [ctx.resolveString(data.with)]
}
return stripQuotemarkLayer(sub, marks)
}))
},
}))
}
}

View File

@ -17,7 +17,8 @@ export class Upper extends Command<{}> {
}
execute(vm: StrVM): Awaitable<StrVM> {
return vm.tapInPlace(ctx =>
ctx.replaceSubjectAsString(sub => sub.toUpperCase()))
return vm.replaceContextMatchingTerm({
stringOrDestructuredPart: sub => sub.toUpperCase(),
})
}
}

View File

@ -28,13 +28,10 @@ export class Word extends Command<WordData> {
execute(vm: StrVM, data: WordData): Awaitable<StrVM> {
// `word <cmd>` is equivalent to `words` -> `each <cmd>` -> `join`, so just do that
return vm.tapInPlace(ctx =>
ctx.replaceSubject(async sub =>
vm.runInChild(async (child, ctx) => {
await (new Words).execute(child)
await (new Each).execute(child, { exec: data.exec })
await (new Join).execute(child, {})
return ctx.getSubject()
})))
return vm.replaceContextFromChild(async child => {
await (new Words).execute(child)
await (new Each).execute(child, { exec: data.exec })
await (new Join).execute(child, {})
})
}
}

View File

@ -1,4 +1,4 @@
import {Command, ParseContext, unwrapString, wrapDestructured} from "./command.js";
import {Command, ParseContext} from "./command.js";
import {LexInput} from "../lexer.js";
import {StrVM} from "../vm.js";
import {Awaitable} from "../../util/types.js";
@ -17,17 +17,16 @@ export class Words extends Command<{}> {
}
execute(vm: StrVM): Awaitable<StrVM> {
return vm.tapInPlace(ctx =>
ctx.replaceSubject(sub => {
const val = unwrapString(sub)
const separators = [...val.matchAll(/\s+/sg)]
const parts = val.split(/\s+/sg)
return vm.replaceContextMatchingTerm({
destructure: sub => {
const separators = [...sub.matchAll(/\s+/sg)]
const parts = sub.split(/\s+/sg)
return wrapDestructured(
parts.map((part, idx) => ({
prefix: idx ? separators[idx - 1][0] : undefined,
value: part,
})))
}))
return parts.map((part, idx) => ({
prefix: idx ? separators[idx - 1][0] : undefined,
value: part,
}))
}
})
}
}

View File

@ -1,12 +1,12 @@
import {Awaitable} from "../util/types.js";
import {
CommandData,
isStrRVal,
isStrRVal, StrDestructured,
StrLVal,
StrRVal,
StrTerm,
StrTerm, unwrapDestructured,
unwrapInt,
unwrapString,
unwrapString, wrapDestructured, wrapInt,
wrapString
} from "./commands/command.js";
import {BehaviorSubject} from "../util/subject.js";
@ -16,35 +16,6 @@ import {log} from "../log.js";
import {Executable} from "./parse.js";
import {getSubjectDisplay} from "./output.js";
export class StringRange {
constructor(
private subject: string,
private parentRef?: { range: StringRange, start: number, end: number },
) {}
replaceWith(subject: string): StringRange {
return new StringRange(subject, this.parentRef ? {...this.parentRef} : undefined)
}
modify(operation: (sub: string) => string): StringRange {
return new StringRange(operation(this.subject), this.parentRef ? {...this.parentRef} : undefined)
}
emptyUnlessCondition(condition: (sub: string) => boolean): StringRange {
return condition(this.subject)
? this
: this.replaceWith('')
}
emptyWhenCondition(condition: (sub: string) => boolean): StringRange {
return this.emptyUnlessCondition(s => !condition(s))
}
getSubject(): string {
return this.subject
}
}
export class Scope {
private entries: Record<string, StrRVal> = {}
@ -84,6 +55,31 @@ export class Scope {
}
}
/**
* Represents some operation that may be applied to a StrRVal.
* Depending on what type StrRVal is, different keys in the operator will be invoked
* and automatically wrapped/unwrapped. Keys are listed in order of precedence.
*/
export type TermOperator = {
/** Map `string` or `int` to `destructured`. */
destructure?: (sub: string) => Awaitable<StrDestructured['value']>,
/** Map `int` to `int`. */
int?: (sub: number) => Awaitable<number>,
/** Map `string` or `int` to `string`. */
string?: string | ((sub: string) => Awaitable<string>),
/** Map `destructured` to `string`. */
restructure?: (sub: StrDestructured['value']) => Awaitable<string>,
/** Map `destructured` to `destructured`. */
destructured?: (sub: StrDestructured['value']) => Awaitable<StrDestructured['value']>,
/**
* If `string`, map to `string`.
* If `destructured`, map each part individual element, but keep it as a `destructured`.
*/
stringOrDestructuredPart?: (sub: string) => Awaitable<string>,
/** If no other matches were made, just output the given rval. */
override?: string | StrRVal | ((sub: StrRVal) => Awaitable<StrRVal>),
}
export class ExecutionContext {
constructor(
private subject: StrRVal,
@ -94,12 +90,64 @@ export class ExecutionContext {
this.subject = await operator(this.subject)
}
async replaceSubjectAsString(operator: string|((sub: string) => Awaitable<string>)) {
if ( typeof operator === 'string' ) {
return this.replaceSubject(() => wrapString(operator))
async replaceSubjectMatchingTerm(operator: TermOperator) {
const sub = this.subject
if ( (sub.term === 'int' || sub.term === 'string') && operator.destructure ) {
this.subject = wrapDestructured(await operator.destructure(unwrapString(sub)))
return
}
return this.replaceSubject(async sub => wrapString(await operator(unwrapString(sub))))
if ( sub.term === 'int' && operator.int ) {
this.subject = wrapInt(await operator.int(unwrapInt(sub)))
return
}
if ( (sub.term === 'int' || sub.term === 'string') && (operator.string || operator.string === '') ) {
if ( typeof operator.string === 'string' ) {
this.subject = wrapString(operator.string)
} else {
this.subject = wrapString(await operator.string(unwrapString(sub)))
}
return
}
if ( (sub.term === 'int' || sub.term === 'string') && operator.stringOrDestructuredPart ) {
this.subject = wrapString(await operator.stringOrDestructuredPart(unwrapString(sub)))
return
}
if ( sub.term === 'destructured' && operator.restructure ) {
this.subject = wrapString(await operator.restructure(unwrapDestructured(sub)))
return
}
if ( sub.term === 'destructured' && operator.destructured ) {
this.subject = wrapDestructured(await operator.destructured(unwrapDestructured(sub)))
return
}
if ( sub.term === 'destructured' && operator.stringOrDestructuredPart ) {
this.subject = wrapDestructured(await Promise.all(
unwrapDestructured(sub)
.map(async part => ({
...part,
value: await operator.stringOrDestructuredPart!(part.value),
}))))
return
}
if ( operator.override || operator.override === '' ) {
if ( typeof operator.override === 'string' ) {
this.subject = wrapString(operator.override)
} else if ( typeof operator.override === 'function' ) {
this.subject = await operator.override(sub)
} else {
this.subject = operator.override
}
return
}
throw new Error('(todo: better error) Cannot replace subject: could not find an appropriate operation for the term type of the current subject')
}
resolve(term: StrTerm): StrRVal|undefined {
@ -158,11 +206,30 @@ export class StrVM {
return this
}
public async replaceContextMatchingTerm(operator: TermOperator|((ctx: ExecutionContext) => TermOperator)): Promise<this> {
return this.tapInPlace(ctx => {
if ( typeof operator === 'function' ) {
operator = operator(ctx)
}
ctx.replaceSubjectMatchingTerm(operator)
})
}
public async runInChild<TReturn>(operator: (child: StrVM, ctx: ExecutionContext) => Awaitable<TReturn>): Promise<TReturn> {
const vm = this.makeChild()
return vm.runInPlace(ctx => operator(vm, ctx))
}
public async replaceContextFromChild(operator: (child: StrVM, ctx: ExecutionContext) => Awaitable<unknown>): Promise<this> {
return this.tapInPlace(ctx =>
ctx.replaceSubject(async () =>
this.runInChild(async (childVm, childCtx) => {
await operator(childVm, childCtx)
return childCtx.getSubject()
})))
}
output() {
console.log('---------------')
console.log(getSubjectDisplay(this.context.getSubject()))