diff --git a/src/vm/commands/command.ts b/src/vm/commands/command.ts index a199165..b53232d 100644 --- a/src/vm/commands/command.ts +++ b/src/vm/commands/command.ts @@ -7,11 +7,18 @@ import { IsNotKeywordError, UnexpectedEndOfInputError } from "../parse.js"; -import {Awaitable, ElementType} from "../../util/types.js"; +import {Awaitable, ElementType, hasOwnProperty} from "../../util/types.js"; import {StrVM} from "../vm.js"; +import os from "node:os"; export type StrLVal = { term: 'variable', name: string } +export const isStrLVal = (val: unknown): val is StrLVal => + !!(typeof val === 'object' + && val + && hasOwnProperty(val, 'term') && val.term === 'variable' + && hasOwnProperty(val, 'name') && typeof val.name === 'string') + export type StrDestructured = { term: 'destructured', value: { prefix?: string, value: string }[] } export const joinDestructured = (val: StrDestructured['value']): string => @@ -40,8 +47,17 @@ export const hashStrRVal = (val: StrRVal): string => { export type StrTerm = StrRVal | StrLVal +export const isStrTerm = (val: unknown): val is StrTerm => + !!( + isStrLVal(val) + || ( + typeof val === 'object' + && val + && hasOwnProperty(val, 'term') && ['string', 'int', 'destructured'].includes(val.term as string) + && hasOwnProperty(val, 'value'))) + export const isStrRVal = (term: StrTerm): term is StrRVal => - term.term === 'string' || term.term === 'int' + term.term === 'string' || term.term === 'int' || term.term === 'destructured' export const unwrapString = (term: StrRVal): string => { if ( term.term === 'int' ) { @@ -55,6 +71,14 @@ export const unwrapString = (term: StrRVal): string => { return term.value } +export const coerceString = (term: StrRVal): string => { + if ( term.term === 'destructured' ) { + return joinDestructured(term.value) + } + + return unwrapString(term) +} + export const wrapInt = (val: number): StrRVal => ({ term: 'int', value: val, @@ -87,6 +111,13 @@ export const wrapString = (str: string): StrRVal => ({ literal: true, }) +export const processPath = (path: string): string => { + if ( path.startsWith('~/') ) { + path = `${os.homedir()}/${path.substring(2)}` + } + return path +} + export class ParseContext { constructor( private inputs: LexInput[], @@ -116,7 +147,19 @@ export class ParseContext { } const input = this.inputs.shift()! + return this.parseInputToTerm(input) + } + peekTerm(): StrTerm|undefined { + if ( !this.inputs.length ) { + return undefined + } + + const input = this.inputs[0] + return this.parseInputToTerm(input) + } + + private parseInputToTerm(input: LexInput): StrTerm { // Check if the token is a literal variable name: if ( !input.literal && input.value.startsWith('$') ) { if ( !input.value.match(/^\$[a-zA-Z0-9_]+$/) ) { diff --git a/src/vm/commands/edit.ts b/src/vm/commands/edit.ts index f371245..d2f6671 100644 --- a/src/vm/commands/edit.ts +++ b/src/vm/commands/edit.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 Edit extends Command<{}> { isParseCandidate(token: LexInput): boolean { @@ -13,4 +15,13 @@ export class Edit extends Command<{}> { getDisplayName(): string { return 'edit' } + + execute(vm: StrVM, data: {}): Awaitable { + return vm.replaceContextMatchingTerm({ + string: async sub => { + + return sub + } + }) + } } diff --git a/src/vm/commands/exit.ts b/src/vm/commands/exit.ts index aeb2863..ff1db64 100644 --- a/src/vm/commands/exit.ts +++ b/src/vm/commands/exit.ts @@ -1,5 +1,6 @@ import {Command, ParseContext} from "./command.js"; import {LexInput} from "../lexer.js"; +import {StrVM} from "../vm.js"; export class Exit extends Command<{}> { isParseCandidate(token: LexInput): boolean { @@ -13,4 +14,9 @@ export class Exit extends Command<{}> { getDisplayName(): string { return 'exit' } + + async execute(vm: StrVM): Promise { + await vm.control$.next({ cmd: 'exit' }) + return vm + } } diff --git a/src/vm/commands/help.ts b/src/vm/commands/help.ts index 7865d39..3792908 100644 --- a/src/vm/commands/help.ts +++ b/src/vm/commands/help.ts @@ -1,5 +1,12 @@ import {Command, ParseContext} from "./command.js"; import {LexInput} from "../lexer.js"; +import {dirname} from "node:path"; +import {fileURLToPath} from "node:url"; +import {StrVM} from "../vm.js"; +import fs from "node:fs"; + +const helpFile = () => `${dirname(fileURLToPath(import.meta.url))}/../../../HELP.txt` +const helpContents = () => fs.readFileSync(helpFile(), 'utf8') export class Help extends Command<{}> { isParseCandidate(token: LexInput): boolean { @@ -13,4 +20,12 @@ export class Help extends Command<{}> { getDisplayName(): string { return 'help' } + + async execute(vm: StrVM): Promise { + return vm.withOutput(async output => { + await output.display.showRaw(helpContents()) + await vm.control$.next({ cmd: 'no-show' }) + return vm + }) + } } diff --git a/src/vm/commands/infile.ts b/src/vm/commands/infile.ts index a3503b3..bb053f0 100644 --- a/src/vm/commands/infile.ts +++ b/src/vm/commands/infile.ts @@ -1,5 +1,8 @@ -import {Command, ParseContext, StrTerm} from "./command.js"; +import {Command, ParseContext, processPath, StrTerm, wrapString} from "./command.js"; import {LexInput} from "../lexer.js"; +import {StrVM} from "../vm.js"; +import {Awaitable} from "../../util/types.js"; +import fs from "node:fs/promises"; export class InFile extends Command<{ path: StrTerm }> { isParseCandidate(token: LexInput): boolean { @@ -13,4 +16,14 @@ export class InFile extends Command<{ path: StrTerm }> { getDisplayName(): string { return 'infile' } + + execute(vm: StrVM, data: { path: StrTerm }): Awaitable { + return vm.replaceContextMatchingTerm(ctx => ({ + override: async () => { + const path = processPath(ctx.resolveString(data.path)) + const content = await fs.readFile(path, 'utf8') + return wrapString(content) + } + })) + } } diff --git a/src/vm/commands/lipsum.ts b/src/vm/commands/lipsum.ts index 8f4253a..4b8db1e 100644 --- a/src/vm/commands/lipsum.ts +++ b/src/vm/commands/lipsum.ts @@ -1,13 +1,59 @@ -import {Command, ParseContext, StrTerm} from './command.js' +import {Command, ParseContext, StrTerm, wrapString} from './command.js' import {LexInput} from '../lexer.js' +import {StrVM} from "../vm.js"; +import {Awaitable} from "../../util/types.js"; +import fs from "node:fs"; +import {dirname} from "node:path"; +import {fileURLToPath} from "node:url"; + +export type LipsumData = { length: StrTerm, type: 'word'|'words'|'line'|'lines'|'para'|'paras' } + +const randomInt = (min=0, max=100) => { + min = Math.ceil(min) + max = Math.floor(max) + return Math.floor(Math.random() * (max - min + 1)) + min +} + +const coinFlip = (chance=0.5) => Math.random() < chance + +const capFirst = (s: string) => `${s[0].toUpperCase()}${s.slice(1)}` + +const lipsumFile = () => `${dirname(fileURLToPath(import.meta.url))}/../../../lipsum.txt` + +let lipsumDict: string[] = [] +const getLipsumDict = () => { + if ( !lipsumDict.length ) { + lipsumDict = fs.readFileSync(lipsumFile()) + .toString('utf-8') + .split('\n') + .map(x => x.trim()) + } + return lipsumDict +} + +const getRandomLipsum = (i?: number) => { + if ( i === 0 ) return 'lorem' + if ( i === 1 ) return 'ipsum' + const dict = getLipsumDict() + return dict[Math.floor(Math.random() * dict.length)] +} + +const genLipsumSentence = (i: number = 0) => { + const words = Array(randomInt(7, 18)) + .fill(undefined) + .map((_, j) => getRandomLipsum(i + j) + (coinFlip(0.2) ? ',' : '')) + + let line = words.join(' ') + if ( line.endsWith(',') ) line = line.slice(0, -1) + return capFirst(line) + '.' +} -export type LipsumData = { length: StrTerm, type: 'word'|'line'|'para' } export class Lipsum extends Command { attemptParse(context: ParseContext): LipsumData { return { length: context.popTerm(), - type: context.popKeywordInSet(['word', 'line', 'para']).value, + type: context.popKeywordInSet(['word', 'words', 'line', 'lines', 'para', 'paras']).value, } } @@ -18,4 +64,38 @@ export class Lipsum extends Command { isParseCandidate(token: LexInput): boolean { return this.isKeyword(token, 'lipsum') } + + execute(vm: StrVM, data: LipsumData): Awaitable { + return vm.replaceContextMatchingTerm(ctx => ({ + override: sub => { + const len = ctx.resolveInt(data.length) + + let joiner: string + if ( data.type === 'word' || data.type === 'words' ) { + joiner = ' ' + } else if ( data.type === 'line' || data.type === 'lines' ) { + joiner = '\n' + } else { + joiner = '\n\n' + } + + const parts: string[] = [] + for ( let i = 0; i < len; i++ ) { + if ( data.type === 'word' || data.type === 'words' ) { + parts.push(getRandomLipsum(i)) + } else if ( data.type === 'line' || data.type === 'lines' ) { + parts.push(genLipsumSentence(i)) + } else { + const para: string[] = [] + for ( let j = 0; j < randomInt(2, 6); j++ ) { + para.push(genLipsumSentence(i + j)) + } + parts.push(para.join(' ')) + } + } + + return wrapString(parts.join(joiner)) + }, + })) + } } diff --git a/src/vm/commands/load.ts b/src/vm/commands/load.ts index 6b49fbc..063ba78 100644 --- a/src/vm/commands/load.ts +++ b/src/vm/commands/load.ts @@ -1,5 +1,10 @@ -import {Command, ParseContext, StrTerm} from "./command.js"; +import {Command, ParseContext, processPath, StrTerm} from "./command.js"; import {LexInput} from "../lexer.js"; +import {StrVM} from "../vm.js"; +import {Awaitable} from "../../util/types.js"; +import os from "node:os"; +import fs from "node:fs/promises"; +import {isSaveData} from "./save.js"; export class Load extends Command<{ path?: StrTerm }> { isParseCandidate(token: LexInput): boolean { @@ -13,4 +18,23 @@ export class Load extends Command<{ path?: StrTerm }> { getDisplayName(): string { return 'load' } + + execute(vm: StrVM, data: { path?: StrTerm }): Awaitable { + return vm.replaceContextMatchingTerm(ctx => ({ + override: async () => { + const path = processPath( + data.path + ? ctx.resolveString(data.path) + : `~/.str.json`) + + const content = await fs.readFile(path, 'utf8') + const saveData = JSON.parse(content) + if ( !isSaveData(saveData) ) { + throw new Error('Cannot load: invalid save data') + } + + return saveData.subject + } + })) + } } diff --git a/src/vm/commands/on.ts b/src/vm/commands/on.ts index b848e4b..b0a4de7 100644 --- a/src/vm/commands/on.ts +++ b/src/vm/commands/on.ts @@ -1,17 +1,46 @@ -import {Command, CommandData, ParseContext, StrTerm} from "./command.js"; +import {Command, CommandData, ParseContext, StrTerm, unwrapString, wrapString} from "./command.js"; import {Executable} from "../parse.js"; import {LexInput} from "../lexer.js"; +import {StrVM} from "../vm.js"; +import {Lines} from "./lines.js"; +import {Words} from "./words.js"; +import {Join} from "./join.js"; export type OnData = { - type: 'line'|'word', + type: 'line'|'word'|'index', specific: StrTerm, exec: Executable, } +/** + * This command has a few forms: + * + * on line 3 + * Assume the subject is a string and perform the given exec on line 3 + * + * on word 3 + * Assume the subject is a string and perform the given exec on word 3 + * + * on index 3 + * on 3 + * Assume the subject is a destructured and perform the given exec on the item at index 3. + */ export class On extends Command { async attemptParse(context: ParseContext): Promise { + // Check if the next term we received is an int or a variable. + // If so, we got the "on 3 " form of the command. + const next = context.peekTerm() + if ( next?.term === 'int' || next?.term === 'variable' ) { + return { + type: 'index', + specific: context.popTerm(), + exec: await context.popExecutable(), + } + } + + // Otherwise, assume we got the "on " form: return { - type: context.popKeywordInSet(['line', 'word']).value, + type: context.popKeywordInSet(['line', 'word', 'index']).value, specific: context.popTerm(), exec: await context.popExecutable(), } @@ -24,4 +53,50 @@ export class On extends Command { isParseCandidate(token: LexInput): boolean { return this.isKeyword(token, 'on') } + + async execute(vm: StrVM, data: OnData): Promise { + // If the type is line|word, first destructure the subject accordingly: + let rejoin = false + if ( data.type === 'line' ) { + vm = await (new Lines).execute(vm) + rejoin = true + } else if ( data.type === 'word' ) { + vm = await (new Words).execute(vm) + rejoin = true + } + + // Then, apply the given command to the specified index of the subject: + vm = await vm.replaceContextMatchingTerm(ctx => ({ + destructured: async sub => { + // Retrieve the specific item in the destructured we're operating over: + const idx = ctx.resolveInt(data.specific) + const operand = sub[idx] + if ( !operand ) { + throw new Error(`Invalid ${data.type} ${idx}`) + } + + // Apply the command to the value of the given index: + const result = await vm.runInChild(async (child, childCtx) => { + await childCtx.replaceSubject(() => wrapString(operand.value)) + await data.exec.command.execute(child, data.exec.data) + return unwrapString(childCtx.getSubject()) + }) + + // Replace the specific index back into the destructured: + sub[idx] = { + ...operand, + value: result, + } + + return sub + }, + })) + + // If we previously split the value (i.e. for type = line|word), rejoin it: + if ( rejoin ) { + vm = await (new Join).execute(vm, {}) + } + + return vm + } } diff --git a/src/vm/commands/outfile.ts b/src/vm/commands/outfile.ts index c333bdc..599021d 100644 --- a/src/vm/commands/outfile.ts +++ b/src/vm/commands/outfile.ts @@ -1,5 +1,8 @@ -import {Command, ParseContext, StrTerm} from "./command.js"; +import {coerceString, Command, ParseContext, processPath, StrTerm} from "./command.js"; import {LexInput} from "../lexer.js"; +import {StrVM} from "../vm.js"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; export class OutFile extends Command<{ path: StrTerm }> { isParseCandidate(token: LexInput): boolean { @@ -13,4 +16,12 @@ export class OutFile extends Command<{ path: StrTerm }> { getDisplayName(): string { return 'outfile' } + + async execute(vm: StrVM, data: { path: StrTerm }): Promise { + return vm.tapInPlace(async ctx => { + const path = processPath(ctx.resolveString(data.path)) + const subject = ctx.getSubject() + await fs.writeFile(path, coerceString(subject)) + }) + } } diff --git a/src/vm/commands/over.ts b/src/vm/commands/over.ts index 719923f..2620918 100644 --- a/src/vm/commands/over.ts +++ b/src/vm/commands/over.ts @@ -1,6 +1,7 @@ import {Command, CommandData, ParseContext, StrLVal} from "./command.js"; import {Executable} from "../parse.js"; import {LexInput} from "../lexer.js"; +import {StrVM} from "../vm.js"; export type OverData = { subject: StrLVal, @@ -22,4 +23,18 @@ export class Over extends Command { isParseCandidate(token: LexInput): boolean { return this.isKeyword(token, 'over') } + + async execute(vm: StrVM, data: OverData): Promise { + return vm.tapInPlace(async parentCtx => { + const oldValue = parentCtx.resolveRequired(data.subject) + const newValue = await vm.runInChild(async (child, childCtx) => { + await childCtx.replaceSubject(() => oldValue) + await data.exec.command.execute(child, data.exec.data) + return childCtx.getSubject() + }) + + parentCtx.inScope(scope => + scope.setOrShadowValue(data.subject, newValue)) + }) + } } diff --git a/src/vm/commands/redo.ts b/src/vm/commands/redo.ts index 805d74d..0f86c98 100644 --- a/src/vm/commands/redo.ts +++ b/src/vm/commands/redo.ts @@ -1,18 +1,34 @@ import {Command, ParseContext, StrTerm} from "./command.js"; import {LexInput} from "../lexer.js"; +import {StrVM} from "../vm.js"; -export class Redo extends Command<{ steps: StrTerm }> { +export class Redo extends Command<{ steps?: StrTerm }> { isParseCandidate(token: LexInput): boolean { return this.isKeyword(token, 'redo') } - attemptParse(context: ParseContext): { steps: StrTerm } { + attemptParse(context: ParseContext): { steps?: StrTerm } { return { - steps: context.popTerm(), + steps: context.popOptionalTerm(), } } getDisplayName(): string { return 'redo' } + + async execute(vm: StrVM, data: { steps?: StrTerm }): Promise { + return vm.tapInPlace(async ctx => { + const steps = data.steps + ? ctx.resolveInt(data.steps) + : 1 + + await vm.control$.next({ cmd: 'preserve-history' }) + for ( let i = 0; i <= steps; i++) { + await vm.control$.next({ cmd: 'redo' }) + } + + return vm + }) + } } diff --git a/src/vm/commands/save.ts b/src/vm/commands/save.ts index 02b3deb..62e9358 100644 --- a/src/vm/commands/save.ts +++ b/src/vm/commands/save.ts @@ -1,5 +1,20 @@ -import {Command, ParseContext, StrTerm} from "./command.js"; +import {Command, isStrRVal, isStrTerm, ParseContext, processPath, StrRVal, StrTerm} from "./command.js"; import {LexInput} from "../lexer.js"; +import * as fs from "fs/promises"; +import * as os from "os"; +import {StrVM} from "../vm.js"; +import {hasOwnProperty, JSONData} from "../../util/types.js"; + +export type SaveData = JSONData & { + subject: StrRVal, +} + +export const isSaveData = (data: JSONData): data is SaveData => + typeof data === 'object' + && data + && hasOwnProperty(data, 'subject') + && isStrTerm(data.subject) + && isStrRVal(data.subject) export class Save extends Command<{ path?: StrTerm }> { isParseCandidate(token: LexInput): boolean { @@ -13,4 +28,21 @@ export class Save extends Command<{ path?: StrTerm }> { getDisplayName(): string { return 'save' } + + async execute(vm: StrVM, data: { path?: StrTerm }): Promise { + return vm.tapInPlace(async ctx => { + const path = processPath( + data.path + ? ctx.resolveString(data.path) + : `~/.str.json`) + + await fs.writeFile( + path, + JSON.stringify({ + subject: ctx.getSubject(), + }), + 'utf8', + ) + }) + } } diff --git a/src/vm/commands/undo.ts b/src/vm/commands/undo.ts index ab9b5c2..86d490d 100644 --- a/src/vm/commands/undo.ts +++ b/src/vm/commands/undo.ts @@ -1,18 +1,34 @@ import {Command, ParseContext, StrTerm} from "./command.js"; import {LexInput} from "../lexer.js"; +import {StrVM} from "../vm.js"; -export class Undo extends Command<{ steps: StrTerm }> { +export class Undo extends Command<{ steps?: StrTerm }> { isParseCandidate(token: LexInput): boolean { return this.isKeyword(token, 'undo') } - attemptParse(context: ParseContext): { steps: StrTerm } { + attemptParse(context: ParseContext): { steps?: StrTerm } { return { - steps: context.popTerm(), + steps: context.popOptionalTerm(), } } getDisplayName(): string { return 'undo' } + + async execute(vm: StrVM, data: { steps?: StrTerm }): Promise { + return vm.tapInPlace(async ctx => { + const steps = data.steps + ? ctx.resolveInt(data.steps) + : 1 + + await vm.control$.next({ cmd: 'preserve-history' }) + for ( let i = 0; i <= steps; i++) { + await vm.control$.next({ cmd: 'undo' }) + } + + return vm + }) + } } diff --git a/src/vm/output.ts b/src/vm/output.ts index 164bccf..978d9ac 100644 --- a/src/vm/output.ts +++ b/src/vm/output.ts @@ -18,16 +18,22 @@ export const getSubjectDisplay = (sub: StrRVal): string => { export type Display = { showSubject(sub: StrRVal): Awaitable + showRaw(str: string): Awaitable } export class ConsoleDisplay implements Display { showSubject(sub: StrRVal) { console.log(`\n---------------\n${getSubjectDisplay(sub)}\n---------------\n`) } + + showRaw(str: string) { + console.log(str) + } } export class NullDisplay implements Display { showSubject() {} + showRaw() {} } export type Clipboard = { diff --git a/src/vm/vm.ts b/src/vm/vm.ts index 2521a38..4dc2449 100644 --- a/src/vm/vm.ts +++ b/src/vm/vm.ts @@ -1,4 +1,4 @@ -import {Awaitable} from "../util/types.js"; +import {Awaitable, JSONData} from "../util/types.js"; import { CommandData, isStrRVal, StrDestructured, @@ -88,14 +88,34 @@ export type TermOperator = { export class ExecutionContext { private history: [StrRVal, Scope][] = [] + private forwardHistory: [StrRVal, Scope][] = [] constructor( private subject: StrRVal, private scope: Scope, ) {} + public async asTransaction(operator: (priorSubject: StrRVal, priorScope: Scope) => Awaitable): Promise { + const priorSubject = {...this.subject} + const priorScope = this.scope.clone() + + try { + return await operator(priorSubject, priorScope) + } catch (e: unknown) { + // We failed! Restore the prior state. + this.subject = priorSubject + this.scope = priorScope + throw e + } + } + public pushHistory() { - this.history.push([this.subject, this.scope.clone()]) + this.pushAsHistory(this.subject, this.scope.clone()) + } + + public pushAsHistory(subject: StrRVal, scope: Scope) { + this.forwardHistory = [] + this.history.push([subject, scope]) } public restoreHistory() { @@ -109,9 +129,25 @@ export class ExecutionContext { if ( !state ) { throw new Error('No history to undo') } + this.forwardHistory.push(state) return state } + public popForwardHistory(): [StrRVal, Scope] { + const state = this.forwardHistory.pop() + if ( !state ) { + throw new Error('No history to redo') + } + return state + } + + public restoreForwardHistory() { + const [subject, scope] = this.popForwardHistory() + this.history.push([this.subject, this.scope.clone()]) + this.subject = subject + this.scope = scope + } + async replaceSubject(operator: (sub: StrRVal) => Awaitable) { this.subject = await operator(this.subject) } @@ -213,6 +249,9 @@ export class ExecutionContext { } } +export type Control = + { cmd: 'undo' | 'redo' | 'exit' | 'no-show' | 'preserve-history' } + export class StrVM { public static make(output: OutputManager): StrVM { return new StrVM( @@ -221,20 +260,42 @@ export class StrVM { ) } + private noShowNext: boolean = false + private preserveHistoryNext: boolean = false + public readonly control$: BehaviorSubject = new BehaviorSubject() + constructor( private context: ExecutionContext, private output: OutputManager, - ) {} + ) { + this.control$.subscribe((control: Control) => + this.handleControl(control)) + } + + private async handleControl(control: Control) { + if ( control.cmd === 'no-show' ) { + this.noShowNext = true + } else if ( control.cmd === 'preserve-history' ) { + this.preserveHistoryNext = true + } else if ( control.cmd === 'undo' ) { + this.context.restoreHistory() + } else if ( control.cmd === 'redo' ) { + this.context.restoreForwardHistory() + } + } public async runInPlace(operator: (ctx: ExecutionContext) => Awaitable): Promise { - this.context.pushHistory() - try { - return await operator(this.context) - } catch (e: unknown) { - // If we got an error, return to the previous state: - this.context.restoreHistory() - throw e - } + return this.context.asTransaction(async (priorSubject, priorScope) => { + const result = await operator(this.context) + + if ( !this.preserveHistoryNext ) { + this.context.pushAsHistory(priorSubject, priorScope) + } else { + this.preserveHistoryNext = false + } + + return result + }) } public async tapInPlace(operator: (ctx: ExecutionContext) => Awaitable): Promise { @@ -275,6 +336,11 @@ export class StrVM { } async outputSubject(): Promise { + if ( this.noShowNext ) { + this.noShowNext = false + return + } + await this.output.display.showSubject(this.context.getSubject()) } }