diff --git a/src/index.ts b/src/index.ts index f5fead7..c1a3a7c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import {Input} from './vm/input.js' import {Lexer} from "./vm/lexer.js"; import {Parser} from "./vm/parser.js"; import {commands} from "./vm/commands/index.js"; +import {Executor} from "./vm/vm.js"; const lifecycle = new Lifecycle() const input = new Input() @@ -16,6 +17,9 @@ lexer.subscribe(token => log.verbose('token', token)) const parser = new Parser(commands, lexer) parser.subscribe(exec => log.verbose('exec', exec)) +const exec = new Executor(parser) +exec.subscribe(state => state.output()) + input.setupPrompt() process.on('SIGINT', () => lifecycle.close()) diff --git a/src/vm/commands/clear.ts b/src/vm/commands/clear.ts index 7249078..e79ef24 100644 --- a/src/vm/commands/clear.ts +++ b/src/vm/commands/clear.ts @@ -1,5 +1,7 @@ import {Command, ParseContext} from "./command.js"; import {LexInput} from "../lexer.js"; +import {StrVM} from "../vm.js"; +import {Awaitable} from "../../util/types.js"; export class Clear extends Command<{}> { isParseCandidate(token: LexInput): boolean { @@ -13,4 +15,10 @@ export class Clear extends Command<{}> { getDisplayName(): string { return 'clear' } + + execute(vm: StrVM): Awaitable { + return vm.inPlace(ctx => + ctx.replaceSubject(sub => + sub.replaceWith(''))) + } } diff --git a/src/vm/commands/command.ts b/src/vm/commands/command.ts index 284d7b8..abe21b9 100644 --- a/src/vm/commands/command.ts +++ b/src/vm/commands/command.ts @@ -7,12 +7,40 @@ import { UnexpectedEndOfInputError } from "../parse.js"; import {Awaitable, ElementType} from "../../util/types.js"; +import {StrVM} from "../vm.js"; export type StrLVal = { term: 'variable', name: string } -export type StrTerm = +export type StrRVal = { 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 { constructor( @@ -53,6 +81,11 @@ export class ParseContext { 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: return { term: 'string', value: input.value, literal: input.literal } } @@ -100,6 +133,10 @@ export abstract class Command { abstract getDisplayName(): string + execute(vm: StrVM, data: TData): Awaitable { + return vm // fixme: once implemented by all commands, make abstract + } + protected isKeyword(token: LexInput, keyword: string): boolean { return !token.literal && token.value === keyword } diff --git a/src/vm/commands/contains.ts b/src/vm/commands/contains.ts index f89b7ee..0d007ce 100644 --- a/src/vm/commands/contains.ts +++ b/src/vm/commands/contains.ts @@ -1,5 +1,7 @@ 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 }> { attemptParse(context: ParseContext): { find: StrTerm } { @@ -15,4 +17,11 @@ export class Contains extends Command<{ find: StrTerm }> { isParseCandidate(token: LexInput): boolean { return this.isKeyword(token, 'contains') } + + execute(vm: StrVM, data: { find: StrTerm }): Awaitable { + return vm.inPlace(ctx => + ctx.replaceSubject(sub => + sub.emptyUnlessCondition(s => + s.includes(ctx.resolveString(data.find))))) + } } diff --git a/src/vm/commands/enclose.ts b/src/vm/commands/enclose.ts index 7dbfec6..84cdf99 100644 --- a/src/vm/commands/enclose.ts +++ b/src/vm/commands/enclose.ts @@ -1,5 +1,7 @@ import {Command, ParseContext, StrTerm} from "./command.js"; import {LexInput} from "../lexer.js"; +import {StrVM} from "../vm.js"; +import {Awaitable} from "../../util/types.js"; export type EncloseData = { left?: StrTerm, @@ -21,4 +23,33 @@ export class Enclose extends Command { isParseCandidate(token: LexInput): boolean { return this.isKeyword(token, 'enclose') } + + execute(vm: StrVM, data: EncloseData): Awaitable { + 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] + } } diff --git a/src/vm/commands/lower.ts b/src/vm/commands/lower.ts index 5d79b4f..99b3442 100644 --- a/src/vm/commands/lower.ts +++ b/src/vm/commands/lower.ts @@ -1,5 +1,7 @@ import {Command, ParseContext} from "./command.js"; import {LexInput} from "../lexer.js"; +import {Awaitable} from "../../util/types.js"; +import {StrVM} from "../vm.js"; export class Lower extends Command<{}> { isParseCandidate(token: LexInput): boolean { @@ -13,4 +15,10 @@ export class Lower extends Command<{}> { getDisplayName(): string { return 'lower' } + + execute(vm: StrVM): Awaitable { + return vm.inPlace(ctx => + ctx.replaceSubject(sub => + sub.modify(s => s.toLowerCase()))) + } } diff --git a/src/vm/commands/lsub.ts b/src/vm/commands/lsub.ts index 43e1f84..7840823 100644 --- a/src/vm/commands/lsub.ts +++ b/src/vm/commands/lsub.ts @@ -1,5 +1,7 @@ import {Command, ParseContext, StrTerm} from "./command.js"; import {LexInput} from "../lexer.js"; +import {StrVM} from "../vm.js"; +import {Awaitable} from "../../util/types.js"; export type LSubData = { offset: StrTerm, @@ -21,4 +23,14 @@ export class LSub extends Command { isParseCandidate(token: LexInput): boolean { return this.isKeyword(token, 'lsub') } + + execute(vm: StrVM, data: LSubData): Awaitable { + 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) + }))) + } } diff --git a/src/vm/commands/missing.ts b/src/vm/commands/missing.ts index 7d6afa9..c4293d5 100644 --- a/src/vm/commands/missing.ts +++ b/src/vm/commands/missing.ts @@ -1,5 +1,7 @@ import { LexInput } from "../lexer.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 }> { attemptParse(context: ParseContext): { find: StrTerm } { @@ -15,4 +17,11 @@ export class Missing extends Command<{ find: StrTerm }> { isParseCandidate(token: LexInput): boolean { return this.isKeyword(token, 'missing') } + + execute(vm: StrVM, data: { find: StrTerm }): Awaitable { + return vm.inPlace(ctx => + ctx.replaceSubject(sub => + sub.emptyWhenCondition(s => + s.includes(ctx.resolveString(data.find))))) + } } diff --git a/src/vm/commands/prefix.ts b/src/vm/commands/prefix.ts index 71fc078..c794563 100644 --- a/src/vm/commands/prefix.ts +++ b/src/vm/commands/prefix.ts @@ -1,5 +1,7 @@ import {Command, ParseContext, StrTerm} from "./command.js"; import {LexInput} from "../lexer.js"; +import {StrVM} from "../vm.js"; +import {Awaitable} from "../../util/types.js"; export class Prefix extends Command<{ with: StrTerm }> { attemptParse(context: ParseContext): { with: StrTerm } { @@ -15,4 +17,10 @@ export class Prefix extends Command<{ with: StrTerm }> { isParseCandidate(token: LexInput): boolean { return this.isKeyword(token, 'prefix') } + + execute(vm: StrVM, data: { with: StrTerm }): Awaitable { + return vm.inPlace(ctx => + ctx.replaceSubject(sub => + sub.modify(s => `${ctx.resolveString(data.with)}${s}`))) + } } diff --git a/src/vm/commands/quote.ts b/src/vm/commands/quote.ts index dbf0006..12e2e89 100644 --- a/src/vm/commands/quote.ts +++ b/src/vm/commands/quote.ts @@ -1,5 +1,26 @@ import {Command, ParseContext, StrTerm} from "./command.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 }> { attemptParse(context: ParseContext): { with?: StrTerm } { @@ -15,4 +36,18 @@ export class Quote extends Command<{ with?: StrTerm }> { isParseCandidate(token: LexInput): boolean { return this.isKeyword(token, 'quote') } + + execute(vm: StrVM, data: { with?: StrTerm }): Awaitable { + 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}` + }))) + } } diff --git a/src/vm/commands/rsub.ts b/src/vm/commands/rsub.ts index 3ae388b..b789397 100644 --- a/src/vm/commands/rsub.ts +++ b/src/vm/commands/rsub.ts @@ -1,5 +1,8 @@ import {Command, ParseContext, StrTerm} from "./command.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 = { offset: StrTerm, @@ -21,4 +24,18 @@ export class RSub extends Command { isParseCandidate(token: LexInput): boolean { return this.isKeyword(token, 'rsub') } + + execute(vm: StrVM, data: LSubData): Awaitable { + 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('') + }))) + } } diff --git a/src/vm/commands/show.ts b/src/vm/commands/show.ts index 3ed88c4..ac4dd45 100644 --- a/src/vm/commands/show.ts +++ b/src/vm/commands/show.ts @@ -1,5 +1,7 @@ import {Command, ParseContext} from "./command.js"; import {LexInput} from "../lexer.js"; +import {StrVM} from "../vm.js"; +import {Awaitable} from "../../util/types.js"; export class Show extends Command<{}> { isParseCandidate(token: LexInput): boolean { @@ -13,4 +15,8 @@ export class Show extends Command<{}> { getDisplayName(): string { return 'show' } + + execute(vm: StrVM): Awaitable { + return vm + } } diff --git a/src/vm/commands/suffix.ts b/src/vm/commands/suffix.ts index fea854d..81f610c 100644 --- a/src/vm/commands/suffix.ts +++ b/src/vm/commands/suffix.ts @@ -1,5 +1,7 @@ import {Command, ParseContext, StrTerm} from "./command.js"; import {LexInput} from "../lexer.js"; +import {StrVM} from "../vm.js"; +import {Awaitable} from "../../util/types.js"; export class Suffix extends Command<{ with: StrTerm }> { attemptParse(context: ParseContext): { with: StrTerm } { @@ -15,4 +17,10 @@ export class Suffix extends Command<{ with: StrTerm }> { isParseCandidate(token: LexInput): boolean { return this.isKeyword(token, 'suffix') } + + execute(vm: StrVM, data: { with: StrTerm }): Awaitable { + return vm.inPlace(ctx => + ctx.replaceSubject(sub => + sub.modify(s => `${s}${ctx.resolveString(data.with)}`))) + } } diff --git a/src/vm/commands/trim.ts b/src/vm/commands/trim.ts index af77c9f..13bbfed 100644 --- a/src/vm/commands/trim.ts +++ b/src/vm/commands/trim.ts @@ -1,5 +1,8 @@ import {Command, ParseContext, StrTerm} from "./command.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 = { type?: 'start'|'end'|'both'|'left'|'right'|'lines', @@ -21,4 +24,32 @@ export class Trim extends Command { isParseCandidate(token: LexInput): boolean { return this.isKeyword(token, 'trim') } + + execute(vm: StrVM, data: TrimData): Awaitable { + 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 + }))) + } } diff --git a/src/vm/commands/unquote.ts b/src/vm/commands/unquote.ts index 9b65001..4dbbeda 100644 --- a/src/vm/commands/unquote.ts +++ b/src/vm/commands/unquote.ts @@ -1,5 +1,8 @@ import {Command, ParseContext, StrTerm} from "./command.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 }> { attemptParse(context: ParseContext): { with?: StrTerm } { @@ -15,4 +18,17 @@ export class Unquote extends Command<{ with?: StrTerm }> { isParseCandidate(token: LexInput): boolean { return this.isKeyword(token, 'unquote') } + + execute(vm: StrVM, data: { with?: StrTerm }): Awaitable { + 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) + }))) + } } diff --git a/src/vm/commands/upper.ts b/src/vm/commands/upper.ts index ac0fd6e..2671cbb 100644 --- a/src/vm/commands/upper.ts +++ b/src/vm/commands/upper.ts @@ -1,5 +1,7 @@ import {Command, ParseContext} from "./command.js"; import {LexInput} from "../lexer.js"; +import {StrVM} from "../vm.js"; +import {Awaitable} from "../../util/types.js"; export class Upper extends Command<{}> { isParseCandidate(token: LexInput): boolean { @@ -13,4 +15,10 @@ export class Upper extends Command<{}> { getDisplayName(): string { return 'upper' } + + execute(vm: StrVM): Awaitable { + return vm.inPlace(ctx => + ctx.replaceSubject(sub => + sub.modify(s => s.toUpperCase()))) + } } diff --git a/src/vm/index.ts b/src/vm/index.ts deleted file mode 100644 index b8b0e2e..0000000 --- a/src/vm/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import {Input} from './input.js' - -export class StrVM { - constructor( - private input: Input, - ) {} -} diff --git a/src/vm/string.ts b/src/vm/string.ts index 1b58345..9054f31 100644 --- a/src/vm/string.ts +++ b/src/vm/string.ts @@ -3,6 +3,9 @@ export type Whitespace = { type: 'space', value: string } export type Component = Word | Whitespace +export const rexEscape = (s: string) => + s.replace(/[/\-\\^$*+?.()|[\]{}]/g, '\\$&') + export const isWord = (cmp: Component): cmp is Word => cmp.type === 'word' diff --git a/src/vm/vm.ts b/src/vm/vm.ts new file mode 100644 index 0000000..4621476 --- /dev/null +++ b/src/vm/vm.ts @@ -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 = {} + + 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) { + 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): Promise { + await operator(this.context) + return this + } + + output() { + console.log('---------------') + console.log(this.context.unwrapSubject()) + console.log('---------------') + } +} + +export class Executor extends BehaviorSubject { + private logger: StreamLogger + + constructor(parser?: Parser) { + super() + this.logger = log.getStreamLogger('executor') + parser?.subscribe(exec => this.handleExecutable(exec)) + } + + async handleExecutable(exec: Executable) { + const vm = this.currentValue || StrVM.make() + await this.next(await exec.command.execute(vm, exec.data)) + } +}