[WIP] Start implementing execution in the new TS version

This commit is contained in:
Garrett Mills 2026-02-09 18:09:47 -06:00
parent aaff8a5011
commit 82eda43dad
19 changed files with 378 additions and 10 deletions

View File

@ -4,6 +4,7 @@ import {Input} from './vm/input.js'
import {Lexer} from "./vm/lexer.js"; import {Lexer} from "./vm/lexer.js";
import {Parser} from "./vm/parser.js"; import {Parser} from "./vm/parser.js";
import {commands} from "./vm/commands/index.js"; import {commands} from "./vm/commands/index.js";
import {Executor} from "./vm/vm.js";
const lifecycle = new Lifecycle() const lifecycle = new Lifecycle()
const input = new Input() const input = new Input()
@ -16,6 +17,9 @@ lexer.subscribe(token => log.verbose('token', token))
const parser = new Parser(commands, lexer) const parser = new Parser(commands, lexer)
parser.subscribe(exec => log.verbose('exec', exec)) parser.subscribe(exec => log.verbose('exec', exec))
const exec = new Executor(parser)
exec.subscribe(state => state.output())
input.setupPrompt() input.setupPrompt()
process.on('SIGINT', () => lifecycle.close()) process.on('SIGINT', () => lifecycle.close())

View File

@ -1,5 +1,7 @@
import {Command, ParseContext} from "./command.js"; import {Command, ParseContext} from "./command.js";
import {LexInput} from "../lexer.js"; import {LexInput} from "../lexer.js";
import {StrVM} from "../vm.js";
import {Awaitable} from "../../util/types.js";
export class Clear extends Command<{}> { export class Clear extends Command<{}> {
isParseCandidate(token: LexInput): boolean { isParseCandidate(token: LexInput): boolean {
@ -13,4 +15,10 @@ export class Clear extends Command<{}> {
getDisplayName(): string { getDisplayName(): string {
return 'clear' return 'clear'
} }
execute(vm: StrVM): Awaitable<StrVM> {
return vm.inPlace(ctx =>
ctx.replaceSubject(sub =>
sub.replaceWith('')))
}
} }

View File

@ -7,12 +7,40 @@ import {
UnexpectedEndOfInputError UnexpectedEndOfInputError
} from "../parse.js"; } from "../parse.js";
import {Awaitable, ElementType} from "../../util/types.js"; import {Awaitable, ElementType} from "../../util/types.js";
import {StrVM} from "../vm.js";
export type StrLVal = { term: 'variable', name: string } export type StrLVal = { term: 'variable', name: string }
export type StrTerm = export type StrRVal =
{ term: 'string', value: string, literal?: true } { term: 'string', value: string, literal?: true }
| StrLVal | { term: 'int', value: number }
export type StrTerm = StrRVal | StrLVal
export const isStrRVal = (term: StrTerm): term is StrRVal =>
term.term === 'string' || term.term === 'int'
export const unwrapString = (term: StrRVal): string => {
if ( term.term === 'int' ) {
return String(term.value)
}
return term.value
}
export const unwrapInt = (term: StrRVal): number => {
if ( term.term !== 'int' ) {
throw new Error('Unexpected error: cannot unwrap term: is not an int')
}
return term.value
}
export const wrapString = (str: string): StrRVal => ({
term: 'string',
value: str,
literal: true,
})
export class ParseContext { export class ParseContext {
constructor( constructor(
@ -53,6 +81,11 @@ export class ParseContext {
return { term: 'variable', name: input.value } return { term: 'variable', name: input.value }
} }
// Check if the token is a valid integer:
if ( /^-?[1-9][0-9]*$/.test(input.value) ) {
return { term: 'int', value: parseInt(input.value, 10) }
}
// Otherwise, parse it as a string literal: // Otherwise, parse it as a string literal:
return { term: 'string', value: input.value, literal: input.literal } return { term: 'string', value: input.value, literal: input.literal }
} }
@ -100,6 +133,10 @@ export abstract class Command<TData extends CommandData> {
abstract getDisplayName(): string abstract getDisplayName(): string
execute(vm: StrVM, data: TData): Awaitable<StrVM> {
return vm // fixme: once implemented by all commands, make abstract
}
protected isKeyword(token: LexInput, keyword: string): boolean { protected isKeyword(token: LexInput, keyword: string): boolean {
return !token.literal && token.value === keyword return !token.literal && token.value === keyword
} }

View File

@ -1,5 +1,7 @@
import { LexInput } from "../lexer.js"; import { LexInput } from "../lexer.js";
import {Command, ParseContext, StrTerm} from "./command.js"; import {Command, ParseContext, StrTerm, unwrapString} from "./command.js";
import {StrVM} from "../vm.js";
import {Awaitable} from "../../util/types.js";
export class Contains extends Command<{ find: StrTerm }> { export class Contains extends Command<{ find: StrTerm }> {
attemptParse(context: ParseContext): { find: StrTerm } { attemptParse(context: ParseContext): { find: StrTerm } {
@ -15,4 +17,11 @@ export class Contains extends Command<{ find: StrTerm }> {
isParseCandidate(token: LexInput): boolean { isParseCandidate(token: LexInput): boolean {
return this.isKeyword(token, 'contains') return this.isKeyword(token, 'contains')
} }
execute(vm: StrVM, data: { find: StrTerm }): Awaitable<StrVM> {
return vm.inPlace(ctx =>
ctx.replaceSubject(sub =>
sub.emptyUnlessCondition(s =>
s.includes(ctx.resolveString(data.find)))))
}
} }

View File

@ -1,5 +1,7 @@
import {Command, ParseContext, StrTerm} from "./command.js"; import {Command, ParseContext, StrTerm} from "./command.js";
import {LexInput} from "../lexer.js"; import {LexInput} from "../lexer.js";
import {StrVM} from "../vm.js";
import {Awaitable} from "../../util/types.js";
export type EncloseData = { export type EncloseData = {
left?: StrTerm, left?: StrTerm,
@ -21,4 +23,33 @@ export class Enclose extends Command<EncloseData> {
isParseCandidate(token: LexInput): boolean { isParseCandidate(token: LexInput): boolean {
return this.isKeyword(token, 'enclose') return this.isKeyword(token, 'enclose')
} }
execute(vm: StrVM, data: EncloseData): Awaitable<StrVM> {
return vm.inPlace(ctx => {
const [left, right] = this.determineSurroundingStrings(
data.left ? ctx.resolveString(data.left) : undefined,
data.right ? ctx.resolveString(data.right) : undefined,
)
return ctx.replaceSubject(sub =>
sub.modify(s => `${left}${s}${right}`))
})
}
private determineSurroundingStrings(left?: string, right?: string): [string, string] {
if ( !left ) {
left = '('
}
if ( !right ) {
right = ({
'(': ')',
'[': ']',
'{': '}',
'<': '>',
})[left] ?? left
}
return [left, right]
}
} }

View File

@ -1,5 +1,7 @@
import {Command, ParseContext} from "./command.js"; import {Command, ParseContext} from "./command.js";
import {LexInput} from "../lexer.js"; import {LexInput} from "../lexer.js";
import {Awaitable} from "../../util/types.js";
import {StrVM} from "../vm.js";
export class Lower extends Command<{}> { export class Lower extends Command<{}> {
isParseCandidate(token: LexInput): boolean { isParseCandidate(token: LexInput): boolean {
@ -13,4 +15,10 @@ export class Lower extends Command<{}> {
getDisplayName(): string { getDisplayName(): string {
return 'lower' return 'lower'
} }
execute(vm: StrVM): Awaitable<StrVM> {
return vm.inPlace(ctx =>
ctx.replaceSubject(sub =>
sub.modify(s => s.toLowerCase())))
}
} }

View File

@ -1,5 +1,7 @@
import {Command, ParseContext, StrTerm} from "./command.js"; import {Command, ParseContext, StrTerm} from "./command.js";
import {LexInput} from "../lexer.js"; import {LexInput} from "../lexer.js";
import {StrVM} from "../vm.js";
import {Awaitable} from "../../util/types.js";
export type LSubData = { export type LSubData = {
offset: StrTerm, offset: StrTerm,
@ -21,4 +23,14 @@ export class LSub extends Command<LSubData> {
isParseCandidate(token: LexInput): boolean { isParseCandidate(token: LexInput): boolean {
return this.isKeyword(token, 'lsub') return this.isKeyword(token, 'lsub')
} }
execute(vm: StrVM, data: LSubData): Awaitable<StrVM> {
return vm.inPlace(ctx =>
ctx.replaceSubject(sub =>
sub.modify(s => {
const offset = ctx.resolveInt(data.offset)
const length = data.length ? ctx.resolveInt(data.length) : s.length
return s.slice(offset, offset + length)
})))
}
} }

View File

@ -1,5 +1,7 @@
import { LexInput } from "../lexer.js"; import { LexInput } from "../lexer.js";
import {Command, ParseContext, StrTerm} from "./command.js"; import {Command, ParseContext, StrTerm} from "./command.js";
import {StrVM} from "../vm.js";
import {Awaitable} from "../../util/types.js";
export class Missing extends Command<{ find: StrTerm }> { export class Missing extends Command<{ find: StrTerm }> {
attemptParse(context: ParseContext): { find: StrTerm } { attemptParse(context: ParseContext): { find: StrTerm } {
@ -15,4 +17,11 @@ export class Missing extends Command<{ find: StrTerm }> {
isParseCandidate(token: LexInput): boolean { isParseCandidate(token: LexInput): boolean {
return this.isKeyword(token, 'missing') return this.isKeyword(token, 'missing')
} }
execute(vm: StrVM, data: { find: StrTerm }): Awaitable<StrVM> {
return vm.inPlace(ctx =>
ctx.replaceSubject(sub =>
sub.emptyWhenCondition(s =>
s.includes(ctx.resolveString(data.find)))))
}
} }

View File

@ -1,5 +1,7 @@
import {Command, ParseContext, StrTerm} from "./command.js"; import {Command, ParseContext, StrTerm} from "./command.js";
import {LexInput} from "../lexer.js"; import {LexInput} from "../lexer.js";
import {StrVM} from "../vm.js";
import {Awaitable} from "../../util/types.js";
export class Prefix extends Command<{ with: StrTerm }> { export class Prefix extends Command<{ with: StrTerm }> {
attemptParse(context: ParseContext): { with: StrTerm } { attemptParse(context: ParseContext): { with: StrTerm } {
@ -15,4 +17,10 @@ export class Prefix extends Command<{ with: StrTerm }> {
isParseCandidate(token: LexInput): boolean { isParseCandidate(token: LexInput): boolean {
return this.isKeyword(token, 'prefix') return this.isKeyword(token, 'prefix')
} }
execute(vm: StrVM, data: { with: StrTerm }): Awaitable<StrVM> {
return vm.inPlace(ctx =>
ctx.replaceSubject(sub =>
sub.modify(s => `${ctx.resolveString(data.with)}${s}`)))
}
} }

View File

@ -1,5 +1,26 @@
import {Command, ParseContext, StrTerm} from "./command.js"; import {Command, ParseContext, StrTerm} from "./command.js";
import {LexInput} from "../lexer.js"; import {LexInput} from "../lexer.js";
import {StrVM} from "../vm.js";
import {Awaitable} from "../../util/types.js";
export const QUOTEMARKS = ['"', '\'', '`']
export const stripQuotemarkLayer = (s: string, marks?: string[]): string => {
if ( !marks ) {
marks = QUOTEMARKS
}
for ( const mark of marks ) {
if ( !s.startsWith(mark) || !s.endsWith(mark) ) {
continue
}
s = s.substring(mark.length, s.length - mark.length)
break
}
return s
}
export class Quote extends Command<{ with?: StrTerm }> { export class Quote extends Command<{ with?: StrTerm }> {
attemptParse(context: ParseContext): { with?: StrTerm } { attemptParse(context: ParseContext): { with?: StrTerm } {
@ -15,4 +36,18 @@ export class Quote extends Command<{ with?: StrTerm }> {
isParseCandidate(token: LexInput): boolean { isParseCandidate(token: LexInput): boolean {
return this.isKeyword(token, 'quote') return this.isKeyword(token, 'quote')
} }
execute(vm: StrVM, data: { with?: StrTerm }): Awaitable<StrVM> {
return vm.inPlace(ctx =>
ctx.replaceSubject(sub =>
sub.modify(s => {
let quote = '\''
if ( data.with ) {
quote = ctx.resolveString(data.with)
}
s = stripQuotemarkLayer(s)
return `${quote}${s}${quote}`
})))
}
} }

View File

@ -1,5 +1,8 @@
import {Command, ParseContext, StrTerm} from "./command.js"; import {Command, ParseContext, StrTerm} from "./command.js";
import {LexInput} from "../lexer.js"; import {LexInput} from "../lexer.js";
import {StrVM} from "../vm.js";
import {Awaitable} from "../../util/types.js";
import {LSubData} from "./lsub.js";
export type RSubData = { export type RSubData = {
offset: StrTerm, offset: StrTerm,
@ -21,4 +24,18 @@ export class RSub extends Command<RSubData> {
isParseCandidate(token: LexInput): boolean { isParseCandidate(token: LexInput): boolean {
return this.isKeyword(token, 'rsub') return this.isKeyword(token, 'rsub')
} }
execute(vm: StrVM, data: LSubData): Awaitable<StrVM> {
return vm.inPlace(ctx =>
ctx.replaceSubject(sub =>
sub.modify(s => {
const offset = ctx.resolveInt(data.offset)
const length = data.length ? ctx.resolveInt(data.length) : s.length
return s.split('') // fixme: do the math so we don't have to do this bs
.reverse()
.slice(offset, offset + length)
.reverse()
.join('')
})))
}
} }

View File

@ -1,5 +1,7 @@
import {Command, ParseContext} from "./command.js"; import {Command, ParseContext} from "./command.js";
import {LexInput} from "../lexer.js"; import {LexInput} from "../lexer.js";
import {StrVM} from "../vm.js";
import {Awaitable} from "../../util/types.js";
export class Show extends Command<{}> { export class Show extends Command<{}> {
isParseCandidate(token: LexInput): boolean { isParseCandidate(token: LexInput): boolean {
@ -13,4 +15,8 @@ export class Show extends Command<{}> {
getDisplayName(): string { getDisplayName(): string {
return 'show' return 'show'
} }
execute(vm: StrVM): Awaitable<StrVM> {
return vm
}
} }

View File

@ -1,5 +1,7 @@
import {Command, ParseContext, StrTerm} from "./command.js"; import {Command, ParseContext, StrTerm} from "./command.js";
import {LexInput} from "../lexer.js"; import {LexInput} from "../lexer.js";
import {StrVM} from "../vm.js";
import {Awaitable} from "../../util/types.js";
export class Suffix extends Command<{ with: StrTerm }> { export class Suffix extends Command<{ with: StrTerm }> {
attemptParse(context: ParseContext): { with: StrTerm } { attemptParse(context: ParseContext): { with: StrTerm } {
@ -15,4 +17,10 @@ export class Suffix extends Command<{ with: StrTerm }> {
isParseCandidate(token: LexInput): boolean { isParseCandidate(token: LexInput): boolean {
return this.isKeyword(token, 'suffix') return this.isKeyword(token, 'suffix')
} }
execute(vm: StrVM, data: { with: StrTerm }): Awaitable<StrVM> {
return vm.inPlace(ctx =>
ctx.replaceSubject(sub =>
sub.modify(s => `${s}${ctx.resolveString(data.with)}`)))
}
} }

View File

@ -1,5 +1,8 @@
import {Command, ParseContext, StrTerm} from "./command.js"; import {Command, ParseContext, StrTerm} from "./command.js";
import {LexInput} from "../lexer.js"; import {LexInput} from "../lexer.js";
import {StrVM} from "../vm.js";
import {Awaitable} from "../../util/types.js";
import {rexEscape} from "../string.js";
export type TrimData = { export type TrimData = {
type?: 'start'|'end'|'both'|'left'|'right'|'lines', type?: 'start'|'end'|'both'|'left'|'right'|'lines',
@ -21,4 +24,32 @@ export class Trim extends Command<TrimData> {
isParseCandidate(token: LexInput): boolean { isParseCandidate(token: LexInput): boolean {
return this.isKeyword(token, 'trim') return this.isKeyword(token, 'trim')
} }
execute(vm: StrVM, data: TrimData): Awaitable<StrVM> {
return vm.inPlace(ctx =>
ctx.replaceSubject(sub =>
sub.modify(s => {
const char = data.char
? rexEscape(ctx.resolveString(data.char))
: '\\s'
if ( !data.type || ['start', 'left', 'both'].includes(data.type) ) {
const leftRex = new RegExp(`^${char || '\\s'}*`, 's')
s = s.replace(leftRex, '')
}
if ( !data.type || ['end', 'right', 'both'].includes(data.type) ) {
const rightRex = new RegExp(`${char || '\\s'}*$`, 's')
s = s.replace(rightRex, '')
}
if ( data.type === 'lines' ) {
s = s.split('\n')
.filter(l => l.trim())
.join('\n')
}
return s
})))
}
} }

View File

@ -1,5 +1,8 @@
import {Command, ParseContext, StrTerm} from "./command.js"; import {Command, ParseContext, StrTerm} from "./command.js";
import {LexInput} from "../lexer.js"; import {LexInput} from "../lexer.js";
import {stripQuotemarkLayer} from "./quote.js";
import {StrVM} from "../vm.js";
import {Awaitable} from "../../util/types.js";
export class Unquote extends Command<{ with?: StrTerm }> { export class Unquote extends Command<{ with?: StrTerm }> {
attemptParse(context: ParseContext): { with?: StrTerm } { attemptParse(context: ParseContext): { with?: StrTerm } {
@ -15,4 +18,17 @@ export class Unquote extends Command<{ with?: StrTerm }> {
isParseCandidate(token: LexInput): boolean { isParseCandidate(token: LexInput): boolean {
return this.isKeyword(token, 'unquote') return this.isKeyword(token, 'unquote')
} }
execute(vm: StrVM, data: { with?: StrTerm }): Awaitable<StrVM> {
return vm.inPlace(ctx =>
ctx.replaceSubject(sub =>
sub.modify(s => {
let marks: string[]|undefined = undefined
if ( data.with ) {
marks = [ctx.resolveString(data.with)]
}
return stripQuotemarkLayer(s, marks)
})))
}
} }

View File

@ -1,5 +1,7 @@
import {Command, ParseContext} from "./command.js"; import {Command, ParseContext} from "./command.js";
import {LexInput} from "../lexer.js"; import {LexInput} from "../lexer.js";
import {StrVM} from "../vm.js";
import {Awaitable} from "../../util/types.js";
export class Upper extends Command<{}> { export class Upper extends Command<{}> {
isParseCandidate(token: LexInput): boolean { isParseCandidate(token: LexInput): boolean {
@ -13,4 +15,10 @@ export class Upper extends Command<{}> {
getDisplayName(): string { getDisplayName(): string {
return 'upper' return 'upper'
} }
execute(vm: StrVM): Awaitable<StrVM> {
return vm.inPlace(ctx =>
ctx.replaceSubject(sub =>
sub.modify(s => s.toUpperCase())))
}
} }

View File

@ -1,7 +0,0 @@
import {Input} from './input.js'
export class StrVM {
constructor(
private input: Input,
) {}
}

View File

@ -3,6 +3,9 @@ export type Whitespace = { type: 'space', value: string }
export type Component = Word | Whitespace export type Component = Word | Whitespace
export const rexEscape = (s: string) =>
s.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&')
export const isWord = (cmp: Component): cmp is Word => export const isWord = (cmp: Component): cmp is Word =>
cmp.type === 'word' cmp.type === 'word'

125
src/vm/vm.ts Normal file
View File

@ -0,0 +1,125 @@
import {Awaitable} from "../util/types.js";
import {CommandData, isStrRVal, StrLVal, StrRVal, StrTerm, unwrapInt, unwrapString} from "./commands/command.js";
import {BehaviorSubject} from "../util/subject.js";
import {StreamLogger} from "../util/log.js";
import {Commands} from "./commands/index.js";
import {Parser} from "./parser.js";
import {log} from "../log.js";
import {Executable} from "./parse.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> = {}
constructor() {}
resolve(lval: StrLVal): StrRVal|undefined {
return this.entries[lval.name]
}
}
export class ExecutionContext {
constructor(
private subject: StringRange,
private scope: Scope,
) {}
async replaceSubject(operator: (sub: StringRange) => Awaitable<StringRange>) {
this.subject = await operator(this.subject)
}
resolve(term: StrTerm): StrRVal|undefined {
if ( isStrRVal(term) ) {
return term
}
return this.scope.resolve(term)
}
resolveRequired(term: StrTerm): StrRVal {
const rval = this.resolve(term)
if ( !rval ) {
throw new Error('FIXME: undefined term')
}
return rval
}
resolveString(term: StrTerm): string {
return unwrapString(this.resolveRequired(term))
}
resolveInt(term: StrTerm): number {
return unwrapInt(this.resolveRequired(term))
}
unwrapSubject(): string {
return this.subject.getSubject()
}
}
export class StrVM {
public static make(): StrVM {
return new StrVM(
new ExecutionContext(
new StringRange(''),
new Scope()))
}
constructor(
private context: ExecutionContext,
) {}
public async inPlace(operator: (ctx: ExecutionContext) => Awaitable<unknown>): Promise<this> {
await operator(this.context)
return this
}
output() {
console.log('---------------')
console.log(this.context.unwrapSubject())
console.log('---------------')
}
}
export class Executor extends BehaviorSubject<StrVM> {
private logger: StreamLogger
constructor(parser?: Parser) {
super()
this.logger = log.getStreamLogger('executor')
parser?.subscribe(exec => this.handleExecutable(exec))
}
async handleExecutable(exec: Executable<CommandData>) {
const vm = this.currentValue || StrVM.make()
await this.next(await exec.command.execute(vm, exec.data))
}
}