[WIP] Implement save, load, over, infile, outfile, on, lipsum, help + start implementing undo, redo, exit, edit

This commit is contained in:
2026-02-22 10:10:38 -06:00
parent 42e6b41879
commit d52b121fd7
15 changed files with 458 additions and 29 deletions

View File

@@ -7,11 +7,18 @@ import {
IsNotKeywordError, IsNotKeywordError,
UnexpectedEndOfInputError UnexpectedEndOfInputError
} from "../parse.js"; } from "../parse.js";
import {Awaitable, ElementType} from "../../util/types.js"; import {Awaitable, ElementType, hasOwnProperty} from "../../util/types.js";
import {StrVM} from "../vm.js"; import {StrVM} from "../vm.js";
import os from "node:os";
export type StrLVal = { term: 'variable', name: string } 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 type StrDestructured = { term: 'destructured', value: { prefix?: string, value: string }[] }
export const joinDestructured = (val: StrDestructured['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 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 => 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 => { export const unwrapString = (term: StrRVal): string => {
if ( term.term === 'int' ) { if ( term.term === 'int' ) {
@@ -55,6 +71,14 @@ export const unwrapString = (term: StrRVal): string => {
return term.value 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 => ({ export const wrapInt = (val: number): StrRVal => ({
term: 'int', term: 'int',
value: val, value: val,
@@ -87,6 +111,13 @@ export const wrapString = (str: string): StrRVal => ({
literal: true, literal: true,
}) })
export const processPath = (path: string): string => {
if ( path.startsWith('~/') ) {
path = `${os.homedir()}/${path.substring(2)}`
}
return path
}
export class ParseContext { export class ParseContext {
constructor( constructor(
private inputs: LexInput[], private inputs: LexInput[],
@@ -116,7 +147,19 @@ export class ParseContext {
} }
const input = this.inputs.shift()! 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: // Check if the token is a literal variable name:
if ( !input.literal && input.value.startsWith('$') ) { if ( !input.literal && input.value.startsWith('$') ) {
if ( !input.value.match(/^\$[a-zA-Z0-9_]+$/) ) { if ( !input.value.match(/^\$[a-zA-Z0-9_]+$/) ) {

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 Edit extends Command<{}> { export class Edit extends Command<{}> {
isParseCandidate(token: LexInput): boolean { isParseCandidate(token: LexInput): boolean {
@@ -13,4 +15,13 @@ export class Edit extends Command<{}> {
getDisplayName(): string { getDisplayName(): string {
return 'edit' return 'edit'
} }
execute(vm: StrVM, data: {}): Awaitable<StrVM> {
return vm.replaceContextMatchingTerm({
string: async sub => {
return sub
}
})
}
} }

View File

@@ -1,5 +1,6 @@
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";
export class Exit extends Command<{}> { export class Exit extends Command<{}> {
isParseCandidate(token: LexInput): boolean { isParseCandidate(token: LexInput): boolean {
@@ -13,4 +14,9 @@ export class Exit extends Command<{}> {
getDisplayName(): string { getDisplayName(): string {
return 'exit' return 'exit'
} }
async execute(vm: StrVM): Promise<StrVM> {
await vm.control$.next({ cmd: 'exit' })
return vm
}
} }

View File

@@ -1,5 +1,12 @@
import {Command, ParseContext} from "./command.js"; import {Command, ParseContext} from "./command.js";
import {LexInput} from "../lexer.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<{}> { export class Help extends Command<{}> {
isParseCandidate(token: LexInput): boolean { isParseCandidate(token: LexInput): boolean {
@@ -13,4 +20,12 @@ export class Help extends Command<{}> {
getDisplayName(): string { getDisplayName(): string {
return 'help' return 'help'
} }
async execute(vm: StrVM): Promise<StrVM> {
return vm.withOutput(async output => {
await output.display.showRaw(helpContents())
await vm.control$.next({ cmd: 'no-show' })
return vm
})
}
} }

View File

@@ -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 {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 }> { export class InFile extends Command<{ path: StrTerm }> {
isParseCandidate(token: LexInput): boolean { isParseCandidate(token: LexInput): boolean {
@@ -13,4 +16,14 @@ export class InFile extends Command<{ path: StrTerm }> {
getDisplayName(): string { getDisplayName(): string {
return 'infile' return 'infile'
} }
execute(vm: StrVM, data: { path: StrTerm }): Awaitable<StrVM> {
return vm.replaceContextMatchingTerm(ctx => ({
override: async () => {
const path = processPath(ctx.resolveString(data.path))
const content = await fs.readFile(path, 'utf8')
return wrapString(content)
}
}))
}
} }

View File

@@ -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 {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<LipsumData> { export class Lipsum extends Command<LipsumData> {
attemptParse(context: ParseContext): LipsumData { attemptParse(context: ParseContext): LipsumData {
return { return {
length: context.popTerm(), 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<LipsumData> {
isParseCandidate(token: LexInput): boolean { isParseCandidate(token: LexInput): boolean {
return this.isKeyword(token, 'lipsum') return this.isKeyword(token, 'lipsum')
} }
execute(vm: StrVM, data: LipsumData): Awaitable<StrVM> {
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))
},
}))
}
} }

View File

@@ -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 {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 }> { export class Load extends Command<{ path?: StrTerm }> {
isParseCandidate(token: LexInput): boolean { isParseCandidate(token: LexInput): boolean {
@@ -13,4 +18,23 @@ export class Load extends Command<{ path?: StrTerm }> {
getDisplayName(): string { getDisplayName(): string {
return 'load' return 'load'
} }
execute(vm: StrVM, data: { path?: StrTerm }): Awaitable<StrVM> {
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
}
}))
}
} }

View File

@@ -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 {Executable} from "../parse.js";
import {LexInput} from "../lexer.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 = { export type OnData = {
type: 'line'|'word', type: 'line'|'word'|'index',
specific: StrTerm, specific: StrTerm,
exec: Executable<CommandData>, exec: Executable<CommandData>,
} }
/**
* This command has a few forms:
*
* on line 3 <exec>
* Assume the subject is a string and perform the given exec on line 3
*
* on word 3 <exec>
* Assume the subject is a string and perform the given exec on word 3
*
* on index 3 <exec>
* on 3 <exec>
* Assume the subject is a destructured and perform the given exec on the item at index 3.
*/
export class On extends Command<OnData> { export class On extends Command<OnData> {
async attemptParse(context: ParseContext): Promise<OnData> { async attemptParse(context: ParseContext): Promise<OnData> {
// Check if the next term we received is an int or a variable.
// If so, we got the "on 3 <exec>" form of the command.
const next = context.peekTerm()
if ( next?.term === 'int' || next?.term === 'variable' ) {
return { return {
type: context.popKeywordInSet(['line', 'word']).value, type: 'index',
specific: context.popTerm(),
exec: await context.popExecutable(),
}
}
// Otherwise, assume we got the "on <type> <index> <exec>" form:
return {
type: context.popKeywordInSet(['line', 'word', 'index']).value,
specific: context.popTerm(), specific: context.popTerm(),
exec: await context.popExecutable(), exec: await context.popExecutable(),
} }
@@ -24,4 +53,50 @@ export class On extends Command<OnData> {
isParseCandidate(token: LexInput): boolean { isParseCandidate(token: LexInput): boolean {
return this.isKeyword(token, 'on') return this.isKeyword(token, 'on')
} }
async execute(vm: StrVM, data: OnData): Promise<StrVM> {
// 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
}
} }

View File

@@ -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 {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 }> { export class OutFile extends Command<{ path: StrTerm }> {
isParseCandidate(token: LexInput): boolean { isParseCandidate(token: LexInput): boolean {
@@ -13,4 +16,12 @@ export class OutFile extends Command<{ path: StrTerm }> {
getDisplayName(): string { getDisplayName(): string {
return 'outfile' return 'outfile'
} }
async execute(vm: StrVM, data: { path: StrTerm }): Promise<StrVM> {
return vm.tapInPlace(async ctx => {
const path = processPath(ctx.resolveString(data.path))
const subject = ctx.getSubject()
await fs.writeFile(path, coerceString(subject))
})
}
} }

View File

@@ -1,6 +1,7 @@
import {Command, CommandData, ParseContext, StrLVal} from "./command.js"; import {Command, CommandData, ParseContext, StrLVal} from "./command.js";
import {Executable} from "../parse.js"; import {Executable} from "../parse.js";
import {LexInput} from "../lexer.js"; import {LexInput} from "../lexer.js";
import {StrVM} from "../vm.js";
export type OverData = { export type OverData = {
subject: StrLVal, subject: StrLVal,
@@ -22,4 +23,18 @@ export class Over extends Command<OverData> {
isParseCandidate(token: LexInput): boolean { isParseCandidate(token: LexInput): boolean {
return this.isKeyword(token, 'over') return this.isKeyword(token, 'over')
} }
async execute(vm: StrVM, data: OverData): Promise<StrVM> {
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))
})
}
} }

View File

@@ -1,18 +1,34 @@
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";
export class Redo extends Command<{ steps: StrTerm }> { export class Redo extends Command<{ steps?: StrTerm }> {
isParseCandidate(token: LexInput): boolean { isParseCandidate(token: LexInput): boolean {
return this.isKeyword(token, 'redo') return this.isKeyword(token, 'redo')
} }
attemptParse(context: ParseContext): { steps: StrTerm } { attemptParse(context: ParseContext): { steps?: StrTerm } {
return { return {
steps: context.popTerm(), steps: context.popOptionalTerm(),
} }
} }
getDisplayName(): string { getDisplayName(): string {
return 'redo' return 'redo'
} }
async execute(vm: StrVM, data: { steps?: StrTerm }): Promise<StrVM> {
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
})
}
} }

View File

@@ -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 {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 }> { export class Save extends Command<{ path?: StrTerm }> {
isParseCandidate(token: LexInput): boolean { isParseCandidate(token: LexInput): boolean {
@@ -13,4 +28,21 @@ export class Save extends Command<{ path?: StrTerm }> {
getDisplayName(): string { getDisplayName(): string {
return 'save' return 'save'
} }
async execute(vm: StrVM, data: { path?: StrTerm }): Promise<StrVM> {
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',
)
})
}
} }

View File

@@ -1,18 +1,34 @@
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";
export class Undo extends Command<{ steps: StrTerm }> { export class Undo extends Command<{ steps?: StrTerm }> {
isParseCandidate(token: LexInput): boolean { isParseCandidate(token: LexInput): boolean {
return this.isKeyword(token, 'undo') return this.isKeyword(token, 'undo')
} }
attemptParse(context: ParseContext): { steps: StrTerm } { attemptParse(context: ParseContext): { steps?: StrTerm } {
return { return {
steps: context.popTerm(), steps: context.popOptionalTerm(),
} }
} }
getDisplayName(): string { getDisplayName(): string {
return 'undo' return 'undo'
} }
async execute(vm: StrVM, data: { steps?: StrTerm }): Promise<StrVM> {
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
})
}
} }

View File

@@ -18,16 +18,22 @@ export const getSubjectDisplay = (sub: StrRVal): string => {
export type Display = { export type Display = {
showSubject(sub: StrRVal): Awaitable<unknown> showSubject(sub: StrRVal): Awaitable<unknown>
showRaw(str: string): Awaitable<unknown>
} }
export class ConsoleDisplay implements Display { export class ConsoleDisplay implements Display {
showSubject(sub: StrRVal) { showSubject(sub: StrRVal) {
console.log(`\n---------------\n${getSubjectDisplay(sub)}\n---------------\n`) console.log(`\n---------------\n${getSubjectDisplay(sub)}\n---------------\n`)
} }
showRaw(str: string) {
console.log(str)
}
} }
export class NullDisplay implements Display { export class NullDisplay implements Display {
showSubject() {} showSubject() {}
showRaw() {}
} }
export type Clipboard = { export type Clipboard = {

View File

@@ -1,4 +1,4 @@
import {Awaitable} from "../util/types.js"; import {Awaitable, JSONData} from "../util/types.js";
import { import {
CommandData, CommandData,
isStrRVal, StrDestructured, isStrRVal, StrDestructured,
@@ -88,14 +88,34 @@ export type TermOperator = {
export class ExecutionContext { export class ExecutionContext {
private history: [StrRVal, Scope][] = [] private history: [StrRVal, Scope][] = []
private forwardHistory: [StrRVal, Scope][] = []
constructor( constructor(
private subject: StrRVal, private subject: StrRVal,
private scope: Scope, private scope: Scope,
) {} ) {}
public async asTransaction<TReturn>(operator: (priorSubject: StrRVal, priorScope: Scope) => Awaitable<TReturn>): Promise<TReturn> {
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() { 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() { public restoreHistory() {
@@ -109,9 +129,25 @@ export class ExecutionContext {
if ( !state ) { if ( !state ) {
throw new Error('No history to undo') throw new Error('No history to undo')
} }
this.forwardHistory.push(state)
return 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<StrRVal>) { async replaceSubject(operator: (sub: StrRVal) => Awaitable<StrRVal>) {
this.subject = await operator(this.subject) 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 { export class StrVM {
public static make(output: OutputManager): StrVM { public static make(output: OutputManager): StrVM {
return new StrVM( return new StrVM(
@@ -221,20 +260,42 @@ export class StrVM {
) )
} }
private noShowNext: boolean = false
private preserveHistoryNext: boolean = false
public readonly control$: BehaviorSubject<Control> = new BehaviorSubject()
constructor( constructor(
private context: ExecutionContext, private context: ExecutionContext,
private output: OutputManager, 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<TReturn>(operator: (ctx: ExecutionContext) => Awaitable<TReturn>): Promise<TReturn> { public async runInPlace<TReturn>(operator: (ctx: ExecutionContext) => Awaitable<TReturn>): Promise<TReturn> {
this.context.pushHistory() return this.context.asTransaction(async (priorSubject, priorScope) => {
try { const result = await operator(this.context)
return await operator(this.context)
} catch (e: unknown) { if ( !this.preserveHistoryNext ) {
// If we got an error, return to the previous state: this.context.pushAsHistory(priorSubject, priorScope)
this.context.restoreHistory() } else {
throw e this.preserveHistoryNext = false
} }
return result
})
} }
public async tapInPlace(operator: (ctx: ExecutionContext) => Awaitable<unknown>): Promise<this> { public async tapInPlace(operator: (ctx: ExecutionContext) => Awaitable<unknown>): Promise<this> {
@@ -275,6 +336,11 @@ export class StrVM {
} }
async outputSubject(): Promise<void> { async outputSubject(): Promise<void> {
if ( this.noShowNext ) {
this.noShowNext = false
return
}
await this.output.display.showSubject(this.context.getSubject()) await this.output.display.showSubject(this.context.getSubject())
} }
} }