[WIP] Implement save, load, over, infile, outfile, on, lipsum, help + start implementing undo, redo, exit, edit
This commit is contained in:
@@ -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_]+$/) ) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
type: 'index',
|
||||||
|
specific: context.popTerm(),
|
||||||
|
exec: await context.popExecutable(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, assume we got the "on <type> <index> <exec>" form:
|
||||||
return {
|
return {
|
||||||
type: context.popKeywordInSet(['line', 'word']).value,
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
88
src/vm/vm.ts
88
src/vm/vm.ts
@@ -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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user