diff --git a/package.json b/package.json index 7cfcd8d..9bb3ded 100644 --- a/package.json +++ b/package.json @@ -26,5 +26,9 @@ "@types/node": "^22", "rimraf": "^6.1.0", "typescript": "^5.9.3" + }, + "dependencies": { + "ansis": "^4.2.0", + "table": "^6.9.0" } } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7d8e834..b911f01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,13 @@ settings: importers: .: + dependencies: + ansis: + specifier: ^4.2.0 + version: 4.2.0 + table: + specifier: ^6.9.0 + version: 6.9.0 devDependencies: '@types/node': specifier: ^22 @@ -35,6 +42,9 @@ packages: '@types/node@22.19.0': resolution: {integrity: sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA==} + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -51,6 +61,14 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + engines: {node: '>=14'} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -71,6 +89,12 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -91,6 +115,12 @@ packages: resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} engines: {node: 20 || >=22} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + lodash.truncate@4.4.2: + resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + lru-cache@11.2.2: resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==} engines: {node: 20 || >=22} @@ -114,6 +144,10 @@ packages: resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} engines: {node: 20 || >=22} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + rimraf@6.1.0: resolution: {integrity: sha512-DxdlA1bdNzkZK7JiNWH+BAx1x4tEJWoTofIopFo6qWUU94jYrFZ0ubY05TqH3nWPJ1nKa1JWVFDINZ3fnrle/A==} engines: {node: 20 || >=22} @@ -131,6 +165,10 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -147,6 +185,10 @@ packages: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} + table@6.9.0: + resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} + engines: {node: '>=10.0.0'} + typescript@5.9.3: resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} engines: {node: '>=14.17'} @@ -189,6 +231,13 @@ snapshots: dependencies: undici-types: 6.21.0 + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-regex@5.0.1: {} ansi-regex@6.2.2: {} @@ -199,6 +248,10 @@ snapshots: ansi-styles@6.2.3: {} + ansis@4.2.0: {} + + astral-regex@2.0.0: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -217,6 +270,10 @@ snapshots: emoji-regex@9.2.2: {} + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.0: {} + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -239,6 +296,10 @@ snapshots: dependencies: '@isaacs/cliui': 8.0.2 + json-schema-traverse@1.0.0: {} + + lodash.truncate@4.4.2: {} + lru-cache@11.2.2: {} minimatch@10.1.1: @@ -256,6 +317,8 @@ snapshots: lru-cache: 11.2.2 minipass: 7.1.2 + require-from-string@2.0.2: {} + rimraf@6.1.0: dependencies: glob: 11.0.3 @@ -269,6 +332,12 @@ snapshots: signal-exit@4.1.0: {} + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -289,6 +358,14 @@ snapshots: dependencies: ansi-regex: 6.2.2 + table@6.9.0: + dependencies: + ajv: 8.18.0 + lodash.truncate: 4.4.2 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + typescript@5.9.3: {} undici-types@6.21.0: {} diff --git a/src/log.ts b/src/log.ts index b87b9db..7cfd659 100644 --- a/src/log.ts +++ b/src/log.ts @@ -1,5 +1,5 @@ import {ConsoleLogger, Logger, LogLevel} from './util/log.js' -export const log: Logger = new ConsoleLogger(LogLevel.VERBOSE) -log.setStreamLevel('lexer', LogLevel.INFO) -log.setStreamLevel('token', LogLevel.INFO) \ No newline at end of file +export const log: Logger = new ConsoleLogger(LogLevel.ERROR) +// log.setStreamLevel('lexer', LogLevel.INFO) +// log.setStreamLevel('token', LogLevel.INFO) diff --git a/src/vm/commands/command.ts b/src/vm/commands/command.ts index c73a8d2..f17fb4f 100644 --- a/src/vm/commands/command.ts +++ b/src/vm/commands/command.ts @@ -1,16 +1,18 @@ import {createHash} from 'node:crypto'; -import {LexInput, tokenIsLVal} from '../lexer.js' +import {LexInput, LexToken, tokenIsLVal} from '../lexer.js' import { Executable, ExpectedEndOfInputError, InvalidVariableNameError, IsNotKeywordError, - UnexpectedEndOfInputError + UnexpectedEndOfInputError, UnexpectedEndofStatementError } from "../parse.js"; import {Awaitable, ElementType, hasOwnProperty} from "../../util/types.js"; import {StrVM} from "../vm.js"; import os from "node:os"; +export class TypeError extends Error {} + export type StrLVal = { term: 'variable', name: string } export const isStrLVal = (val: unknown): val is StrLVal => @@ -19,26 +21,64 @@ export const isStrLVal = (val: unknown): val is StrLVal => && 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: StrRVal }[] } export const joinDestructured = (val: StrDestructured['value']): string => val - .map(part => `${part.prefix || ''}${part.value}`) + .map(part => `${part.prefix || ''}${part.value.value}`) .join('') export const destructureToLines = (val: string): StrDestructured['value'] => val .split('\n') .map((line, idx) => { if ( idx ) { - return { prefix: '\n', value: line } + return { prefix: '\n', value: wrapString(line) } } - return { value: line } + return { value: wrapString(line) } }) -export type StrRVal = - { term: 'string', value: string, literal?: true } - | { term: 'int', value: number } - | StrDestructured +export type StrString = { term: 'string', value: string, literal?: true } + +export type StrInt = { term: 'int', value: number } + +export type StrLamba = { + term: 'lambda', + value: { + args: StrLVal[], + exec: Executable[] + }, +} + +export type StrRVal = StrString | StrInt | StrDestructured | StrLamba + +export type StrDestructuredTable = { + term: 'destructured', + value: { + prefix?: string, + value: { + term: 'destructured', + value: { + prefix?: string, + value: StrString, + }[], + }, + }[], +} + +export const isStrDestructuredTable = (what: StrRVal): what is StrDestructuredTable => { + return what.term === 'destructured' + && what.value.every(item => + item.value.term === 'destructured' + && item.value.value.every(subitem => + subitem.value.term === 'string' || subitem.value.term === 'int')) +} + +export const unwrapStrDestructuredTable = (table: StrDestructuredTable): string[][] => { + return table.value + .map(row => + row.value.value + .map(cell => unwrapString(cell.value))) +} const toHex = (v: string) => createHash('sha256').update(v).digest('hex') @@ -51,6 +91,10 @@ export const hashStrRVal = (val: StrRVal): string => { return toHex(`s:int:${val.value}`) } + if ( val.term === 'lambda' ) { + throw new Error('Cannot hash lambda') // todo + } + return toHex(`s:dstr:${joinDestructured(val.value)}`) } @@ -73,8 +117,8 @@ export const unwrapString = (term: StrRVal): string => { return String(term.value) } - if ( term.term === 'destructured' ) { - throw new Error('ope!') // fixme + if ( term.term === 'destructured' || term.term === 'lambda' ) { + throw new TypeError(`Found unexpected ${term.term} (expected: string|int)`) } return term.value @@ -95,7 +139,7 @@ export const wrapInt = (val: number): StrRVal => ({ export const unwrapInt = (term: StrRVal): number => { if ( term.term !== 'int' ) { - throw new Error('Unexpected error: cannot unwrap term: is not an int') + throw new TypeError(`Found unexpected ${term.term} (expected: int)`) } return term.value @@ -108,7 +152,7 @@ export const wrapDestructured = (val: StrDestructured['value']): StrDestructured export const unwrapDestructured = (term: StrRVal): StrDestructured['value'] => { if ( term.term !== 'destructured' ) { - throw new Error('Unexpected error: cannot unwrap term: is not a destructured') + throw new TypeError(`Found unexpected ${term.term} (expected: destructured)`) } return term.value @@ -127,15 +171,20 @@ export const processPath = (path: string): string => { return path } +export interface ParseSubContext { + inputs: LexToken[], +} + export class ParseContext { constructor( - private inputs: LexInput[], - private childParser: (tokens: LexInput[]) => Awaitable<[Executable, LexInput[]]>, + private inputs: LexToken[], + private childParser: (tokens: LexToken[]) => Awaitable<[Executable, LexToken[]]>, ) {} assertEmpty() { if ( this.inputs.length ) { - throw new ExpectedEndOfInputError(`Expected end of input. Found: ${this.inputs[0].value}`) + const showTerm = this.inputs[0].type === 'terminator' ? 'EOS' : this.inputs[0].value + throw new ExpectedEndOfInputError(`Expected end of input. Found: ${showTerm}`) } } @@ -156,6 +205,11 @@ export class ParseContext { } const input = this.inputs.shift()! + + if ( input.type === 'terminator' ) { + throw new UnexpectedEndofStatementError('Unexpected end of statement terminator. Expected term.') + } + return this.parseInputToTerm(input) } @@ -165,6 +219,10 @@ export class ParseContext { } const input = this.inputs[0] + if ( input.type === 'terminator' ) { + return undefined + } + return this.parseInputToTerm(input) } @@ -198,6 +256,9 @@ export class ParseContext { } const input = this.inputs.shift()! + if ( input.type === 'terminator' ) { + throw new UnexpectedEndofStatementError('Unexpected end of statement terminator. Expected one of: ' + options.join(', ')) + } if ( input.literal || !options.includes(input.value) ) { throw new IsNotKeywordError('Unexpected term: ' + input.value + ' (expected one of: ' + options.join(', ') + ')') @@ -208,10 +269,14 @@ export class ParseContext { popLVal(): StrLVal { if ( !this.inputs.length ) { - throw new UnexpectedEndOfInputError('Unexpected end of input. Expected lval.'); + throw new UnexpectedEndOfInputError('Unexpected end of input. Expected lval.') } const input = this.inputs.shift()! + if ( input.type === 'terminator' ) { + throw new UnexpectedEndofStatementError('Unexpected end of statement terminator. Expected lval.') + } + if ( !tokenIsLVal(input) ) { throw new InvalidVariableNameError(`Expected variable name. Found: ${input.value}`) } diff --git a/src/vm/commands/concat.ts b/src/vm/commands/concat.ts new file mode 100644 index 0000000..82b567a --- /dev/null +++ b/src/vm/commands/concat.ts @@ -0,0 +1,43 @@ +import {Command, ParseContext, StrTerm, wrapString} from "./command.js"; +import {LexInput} from "../lexer.js"; +import {StrVM} from "../vm.js"; +import {Awaitable} from "../../util/types.js"; + +export type ConcatData = { + terms: StrTerm[], +} + +export class Concat extends Command { + isParseCandidate(token: LexInput): boolean { + return this.isKeyword(token, 'concat') || this.isKeyword(token, 'cat') + } + + attemptParse(context: ParseContext): ConcatData { + const data: ConcatData = { + terms: [], + } + + let term: StrTerm|undefined + while ( term = context.popOptionalTerm() ) { + data.terms.push(term) + } + + return data + } + + getDisplayName(): string { + return 'concat' + } + + execute(vm: StrVM, data: ConcatData): Awaitable { + return vm.replaceContextMatchingTerm(ctx => ({ + override: () => { + const result = data.terms + .map(term => ctx.resolveString(term)) + .join('') + + return wrapString(result) + }, + })) + } +} diff --git a/src/vm/commands/contains.ts b/src/vm/commands/contains.ts index bb08a98..9525a9a 100644 --- a/src/vm/commands/contains.ts +++ b/src/vm/commands/contains.ts @@ -21,8 +21,8 @@ export class Contains extends Command<{ find: StrTerm }> { execute(vm: StrVM, data: { find: StrTerm }): Awaitable { return vm.replaceContextMatchingTerm(ctx => ({ string: sub => sub.includes(ctx.resolveString(data.find)) ? sub : '', - destructured: parts => parts.filter(part => - part.value.includes(ctx.resolveString(data.find))), + destructuredOfStrings: parts => parts.filter(part => + part.includes(ctx.resolveString(data.find))), })) } } diff --git a/src/vm/commands/each.ts b/src/vm/commands/each.ts index 3c85757..2f29fb8 100644 --- a/src/vm/commands/each.ts +++ b/src/vm/commands/each.ts @@ -33,7 +33,7 @@ export class Each extends Command { await child.replaceContextMatchingTerm({ override: part.value }) return child.runInPlace(async ctx => { await data.exec.command.execute(child, data.exec.data) - return unwrapString(ctx.getSubject()) + return ctx.getSubject() }) }) })) diff --git a/src/vm/commands/index.ts b/src/vm/commands/index.ts index 70ce909..c9f3ce4 100644 --- a/src/vm/commands/index.ts +++ b/src/vm/commands/index.ts @@ -26,6 +26,7 @@ import {Prefix} from "./prefix.js"; import {Quote} from "./quote.js"; import {Redo} from "./redo.js"; import {Replace} from "./replace.js"; +import {Reverse} from "./rev.js"; import {RSub} from "./rsub.js"; import {Show} from "./show.js"; import {Split} from "./split.js"; @@ -46,11 +47,13 @@ import {Sort} from "./sort.js"; import {Set} from "./set.js"; import {Assign} from "./assign.js"; import {Zip} from "./zip.js"; +import {Concat} from "./concat.js"; export type Commands = Command[] export const commands: Commands = [ new Assign, new Clear, + new Concat, new Contains, new Copy, new Drop, @@ -79,6 +82,7 @@ export const commands: Commands = [ new Quote, new Redo, new Replace, + new Reverse, new RSub, new RunFile, new Save, diff --git a/src/vm/commands/join.ts b/src/vm/commands/join.ts index c3ced29..45d3ef3 100644 --- a/src/vm/commands/join.ts +++ b/src/vm/commands/join.ts @@ -23,7 +23,7 @@ export class Join extends Command<{ with?: StrTerm }> { restructureOrLines: parts => { if ( data.with ) { return parts - .map(part => part.value) + .map(part => part.value.value) .join(ctx.resolveString(data.with)) } diff --git a/src/vm/commands/lines.ts b/src/vm/commands/lines.ts index c988011..417f9bb 100644 --- a/src/vm/commands/lines.ts +++ b/src/vm/commands/lines.ts @@ -1,4 +1,4 @@ -import {Command, ParseContext, unwrapString} from "./command.js"; +import {Command, ParseContext, wrapString} from "./command.js"; import {LexInput} from "../lexer.js"; import {StrVM} from "../vm.js"; import {Awaitable} from "../../util/types.js"; @@ -22,7 +22,7 @@ export class Lines extends Command<{}> { return sub.split('\n') .map((line, idx) => ({ prefix: idx ? '\n' : undefined, - value: line, + value: wrapString(line), })) }, }) diff --git a/src/vm/commands/missing.ts b/src/vm/commands/missing.ts index 629e347..7b1a531 100644 --- a/src/vm/commands/missing.ts +++ b/src/vm/commands/missing.ts @@ -21,8 +21,8 @@ export class Missing extends Command<{ find: StrTerm }> { execute(vm: StrVM, data: { find: StrTerm }): Awaitable { return vm.replaceContextMatchingTerm(ctx => ({ string: sub => sub.includes(ctx.resolveString(data.find)) ? '' : sub, - destructured: parts => parts.filter(part => - !part.value.includes(ctx.resolveString(data.find))), + destructuredOfStrings: parts => parts.filter(part => + !part.includes(ctx.resolveString(data.find))), })) } } diff --git a/src/vm/commands/on.ts b/src/vm/commands/on.ts index b0a4de7..8a0521a 100644 --- a/src/vm/commands/on.ts +++ b/src/vm/commands/on.ts @@ -77,9 +77,9 @@ export class On extends Command { // 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 childCtx.replaceSubject(() => operand.value) await data.exec.command.execute(child, data.exec.data) - return unwrapString(childCtx.getSubject()) + return childCtx.getSubject() }) // Replace the specific index back into the destructured: diff --git a/src/vm/commands/rev.ts b/src/vm/commands/rev.ts new file mode 100644 index 0000000..dd0583b --- /dev/null +++ b/src/vm/commands/rev.ts @@ -0,0 +1,25 @@ +import {Command} from "./command.js"; +import {LexInput} from "../lexer.js"; +import {StrVM} from "../vm.js"; +import {Awaitable} from "../../util/types.js"; + +export class Reverse extends Command<{}> { + attemptParse(): {} { + return {} + } + + getDisplayName(): string { + return 'rev' + } + + isParseCandidate(token: LexInput): boolean { + return this.isKeyword(token, 'rev') + } + + execute(vm: StrVM): Awaitable { + return vm.replaceContextMatchingTerm({ + string: s => s.split('').reverse().join(''), + destructured: s => [...s].reverse(), + }) + } +} diff --git a/src/vm/commands/split.ts b/src/vm/commands/split.ts index f654327..7021a04 100644 --- a/src/vm/commands/split.ts +++ b/src/vm/commands/split.ts @@ -1,4 +1,4 @@ -import {Command, ParseContext, StrTerm, unwrapString, wrapDestructured} 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"; @@ -30,7 +30,7 @@ export class Split extends Command { return sub.split(prefix) .map((segment, idx) => ({ prefix: idx ? prefix : undefined, - value: segment, + value: wrapString(segment), })) } })) diff --git a/src/vm/commands/unique.ts b/src/vm/commands/unique.ts index 1ae4bef..f20b4a4 100644 --- a/src/vm/commands/unique.ts +++ b/src/vm/commands/unique.ts @@ -21,7 +21,7 @@ export class Unique extends Command<{}> { destructuredOrLines: sub => { const seen: Record = {} return sub.filter(part => { - const hash = hashStrRVal(wrapString(part.value)) + const hash = hashStrRVal(part.value) if ( seen[hash] ) { return false } diff --git a/src/vm/commands/words.ts b/src/vm/commands/words.ts index 1fcd1f2..baf9328 100644 --- a/src/vm/commands/words.ts +++ b/src/vm/commands/words.ts @@ -1,4 +1,4 @@ -import {Command, ParseContext} from "./command.js"; +import {Command, ParseContext, wrapString} from "./command.js"; import {LexInput} from "../lexer.js"; import {StrVM} from "../vm.js"; import {Awaitable} from "../../util/types.js"; @@ -24,7 +24,7 @@ export class Words extends Command<{}> { return parts.map((part, idx) => ({ prefix: idx ? separators[idx - 1][0] : undefined, - value: part, + value: wrapString(part), })) } }) diff --git a/src/vm/input.ts b/src/vm/input.ts index 02827a8..be2c452 100644 --- a/src/vm/input.ts +++ b/src/vm/input.ts @@ -8,6 +8,8 @@ export class Input extends BehaviorSubject implements LifecycleAware { private rl?: readline.Interface private log: StreamLogger = log.getStreamLogger('input') + public readonly errors$: BehaviorSubject = new BehaviorSubject() + public hasPrompt(): boolean { return !!this.rl } diff --git a/src/vm/output.ts b/src/vm/output.ts index b202033..79f54b1 100644 --- a/src/vm/output.ts +++ b/src/vm/output.ts @@ -1,34 +1,86 @@ -import {StrRVal} from "./commands/command.js"; +import {isStrDestructuredTable, StrRVal, unwrapStrDestructuredTable} from "./commands/command.js"; import {Awaitable} from "../util/types.js"; import childProcess from "node:child_process"; import fs from "node:fs"; import {tempFile} from "../util/fs.js"; +import {table} from "table"; +import * as ansi from 'ansis'; -export const getSubjectDisplay = (sub: StrRVal): string => { - let annotated = '\n┌───────────────\n' +export const getSubjectDisplay = (sub: StrRVal, prefix: string = '', firstLinePrefix?: string): string => { + if ( typeof firstLinePrefix === 'undefined' ) { + firstLinePrefix = prefix + } + + if ( isStrDestructuredTable(sub) ) { + const config = { + border: { + topBody: ansi.gray`─`, + topJoin: ansi.gray`┬`, + topLeft: ansi.gray`${firstLinePrefix}┌`, + topRight: ansi.gray`┐`, + + bottomBody: ansi.gray`─`, + bottomJoin: ansi.gray`┴`, + bottomLeft: ansi.gray`${prefix}└`, + bottomRight: ansi.gray`┘`, + + bodyLeft: ansi.gray`${prefix}│`, + bodyRight: ansi.gray`│`, + bodyJoin: ansi.gray`│`, + + joinBody: ansi.gray`─`, + joinLeft: ansi.gray`${prefix}├`, + joinRight: ansi.gray`┤`, + joinJoin: ansi.gray`┼`, + }, + } + + let annotatedTable = unwrapStrDestructuredTable(sub) + .map((row, rowIdx) => [ + ansi.blue`${rowIdx.toString()}`, + ...row, + ]) + + annotatedTable = [ + ['', ...annotatedTable[0] + .map((cell, cellIdx) => ansi.blue`${(cellIdx - 1).toString()}`) + .slice(1)], + ...annotatedTable, + ] + + return table(annotatedTable, config) + + `${prefix}├────────────────────────────────────────────────` + +`\n${prefix}│ :: destructured (:: destructured (:: string))` + + `\n${prefix}└────────────────────────────────────────────────` + } + + let annotated = firstLinePrefix + '┌───────────────' if ( sub.term === 'string' ) { const lines = sub.value.split('\n') const padLength = `${lines.length}`.length // heh - annotated += lines - .map((line, idx) => '│ ' + idx.toString().padStart(padLength, ' ') + ' │' + line) + annotated += '\n' + lines + .map((line, idx) => prefix + '│ ' + idx.toString().padStart(padLength, ' ') + ' │' + line) .join('\n') } if ( sub.term === 'int' ) { - annotated += `│ ${sub.value}` + annotated += prefix + `\n│ ${sub.value}` } if ( sub.term === 'destructured' ) { const padLength = `${sub.value.length}`.length - annotated += sub.value - .map((el, idx) => '│ ' + idx.toString().padStart(padLength, ' ') + ' │' - + el.value.split('\n').map((line, lineIdx) => lineIdx ? (`│ ${''.padStart(padLength, ' ')} │${line}`) : line).join('\n')) - .join('\n│ ' + ''.padStart(padLength, ' ') + ' ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈\n') + annotated += '\n' + sub.value + .map((el, elIdx) => { + const subPrefix = prefix + `│ ${''.padStart(padLength, ' ')} │` + const subFirstPrefix = prefix + `│ ${elIdx.toString().padStart(padLength, ' ')} │` + return getSubjectDisplay(el.value, subPrefix, subFirstPrefix) + }) + .join(`\n${prefix}│ ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈\n`) } - annotated += '\n├───────────────' - annotated += `\n│ :: ${sub.term}` - annotated += '\n└───────────────' + annotated += `\n${prefix}├───────────────` + annotated += `\n${prefix}│ :: ${sub.term}` + annotated += `\n${prefix}└───────────────` return annotated } diff --git a/src/vm/parse.ts b/src/vm/parse.ts index 5046705..3d1dfe4 100644 --- a/src/vm/parse.ts +++ b/src/vm/parse.ts @@ -10,5 +10,6 @@ export class InternalParseError extends ParseError {} export class IsNotKeywordError extends ParseError {} export class InvalidCommandError extends ParseError {} export class UnexpectedEndOfInputError extends ParseError {} +export class UnexpectedEndofStatementError extends ParseError {} export class ExpectedEndOfInputError extends InvalidCommandError {} -export class InvalidVariableNameError extends ParseError {} \ No newline at end of file +export class InvalidVariableNameError extends ParseError {} diff --git a/src/vm/parser.ts b/src/vm/parser.ts index 1edaf23..62045ec 100644 --- a/src/vm/parser.ts +++ b/src/vm/parser.ts @@ -8,7 +8,7 @@ import { Executable, InternalParseError, InvalidCommandError, - IsNotKeywordError, + IsNotKeywordError, ParseError, UnexpectedEndOfInputError } from './parse.js' @@ -16,15 +16,41 @@ export class Parser extends BehaviorSubject> { private logger: StreamLogger private parseCandidate?: Command - private inputForCandidate: LexInput[] = [] + private inputForCandidate: LexToken[] = [] + + private subcontextLevel: number = 0; + + /** Used when no parse candidate is found. Prevents trying to parse the tail of the command. */ + private dropUntilTerminator: boolean = false constructor(private commands: Commands, lexer?: Lexer) { super() this.logger = log.getStreamLogger('parser') - lexer?.subscribe(token => this.handleToken(token)) + lexer?.subscribe({ + next: token => this.handleToken(token), + error: error => this.handleParseError(error), + }) + } + + async handleParseError(error: Error) { + if ( error instanceof ParseError ) { + this.logger.error(`(${error.constructor.name}) ${error.message}`) + return + } + + throw error } async handleToken(token: LexToken) { + // We previously encountered an invalid command, so avoid trying to parse the tail of it: + if ( this.dropUntilTerminator ) { + if ( token.type === 'terminator' ) { + this.dropUntilTerminator = false + } + + return + } + // We are in between full commands, so try to identify a new parse candidate: if ( !this.parseCandidate ) { // Ignore duplicated terminators between commands @@ -37,7 +63,13 @@ export class Parser extends BehaviorSubject> { throw new IsNotKeywordError('Expected keyword, found: ' + this.displayToken(token)) } - this.parseCandidate = this.getParseCandidate(token) + try { + this.parseCandidate = this.getParseCandidate(token) + } catch (e) { + this.dropUntilTerminator = true + throw e + } + if ( this.parseCandidate.shouldIncludeLeaderInParseContext() ) { this.inputForCandidate.push(token) } @@ -48,13 +80,28 @@ export class Parser extends BehaviorSubject> { // If this is normal input token, collect it so we can give it to the candidate to parse: if ( token.type === 'input' ) { this.inputForCandidate.push(token) + + if ( !token.literal && token.value.startsWith('(') ) { + this.subcontextLevel += 1 + } + + if ( !token.literal && token.value.endsWith(')') && this.subcontextLevel ) { + this.subcontextLevel -= 1 + } + return } // If we got a terminator, then ask the candidate to actually perform its parse: if ( token.type === 'terminator' ) { + if ( this.subcontextLevel > 0 ) { + // We're inside a sub-context right now, so just accumulate the input and continue on: + this.inputForCandidate.push(token) + return + } + try { - // Have the candidate attempt to parse itself from the collecte data: + // Have the candidate attempt to parse itself from the collected data: const context = this.getContext() this.logger.verbose({ parsing: this.parseCandidate.getDisplayName(), context }) const data = await this.parseCandidate.attemptParse(context) diff --git a/src/vm/vm.ts b/src/vm/vm.ts index 47e35ce..3bb5ff7 100644 --- a/src/vm/vm.ts +++ b/src/vm/vm.ts @@ -1,10 +1,10 @@ -import {Awaitable, JSONData} from "../util/types.js"; +import {Awaitable, hasOwnProperty, JSONData} from "../util/types.js"; import { - CommandData, destructureToLines, + CommandData, destructureToLines, isStrLVal, isStrRVal, joinDestructured, StrDestructured, StrLVal, StrRVal, - StrTerm, unwrapDestructured, + StrTerm, TypeError, unwrapDestructured, unwrapInt, unwrapString, wrapDestructured, wrapInt, wrapString @@ -82,6 +82,8 @@ export type TermOperator = { * If `destructured`, map directly. */ restructureOrLines?: (sub: StrDestructured['value']) => Awaitable, + /** Map `destructured` of `string` to `destructured` of `string`. */ + destructuredOfStrings?: (sub: string[]) => Awaitable, /** Map `destructured` to `destructured`. */ destructured?: (sub: StrDestructured['value']) => Awaitable, /** @@ -98,6 +100,38 @@ export type TermOperator = { override?: string | StrRVal | ((sub: StrRVal) => Awaitable), } +export const termOperatorInputDisplays: Record = { + destructure: ['string'], + int: ['int'], + string: ['string', 'int'], + restructure: ['destructured'], + restructureOrLines: ['string', 'destructured'], + destructured: ['destructured'], + destructuredOfStrings: ['destructured'], + destructuredOrLines: ['string', 'destructured'], + stringOrDestructuredPart: ['string', 'destructured'], + override: ['string', 'int', 'destructured'], +} + +export const getTermOperatorInputDisplayList = (op: TermOperator): string[] => { + const vals: Partial> = {} + + let key: keyof TermOperator + // @ts-ignore + for ( key of Object.keys(op) ) { + for ( const disp of termOperatorInputDisplays[key] ) { + vals[disp] = true + } + } + + console.log({ vals }) + return Object.keys(vals) +} + +export class ExecutionError extends Error {} +export class TermOperationError extends ExecutionError {} +export class UndefinedTermError extends ExecutionError {} + export class ExecutionContext { private history: [StrRVal, Scope][] = [] private forwardHistory: [StrRVal, Scope][] = [] @@ -214,6 +248,29 @@ export class ExecutionContext { return } + if ( + sub.term === 'destructured' + && (sub.value.length < 1 || sub.value[0].value.term === 'string') + && operator.destructuredOfStrings + ) { + const prefixes = unwrapDestructured(sub) + .map(v => v.prefix) + + const strings = unwrapDestructured(sub) + .map(v => v.value.value as string) + + const mappedStrings = (await operator.destructuredOfStrings(strings)) + .map(string => wrapString(string)) + + this.subject = wrapDestructured( + mappedStrings.map((value, idx) => ({ + prefix: prefixes[idx], + value, + }))) + + return + } + if ( sub.term === 'destructured' && operator.destructured ) { this.subject = wrapDestructured(await operator.destructured(unwrapDestructured(sub))) return @@ -224,12 +281,16 @@ export class ExecutionContext { return } - if ( sub.term === 'destructured' && operator.stringOrDestructuredPart ) { + if ( + sub.term === 'destructured' + && (sub.value.length < 1 || sub.value[0].value.term === 'string') + && operator.stringOrDestructuredPart + ) { this.subject = wrapDestructured(await Promise.all( unwrapDestructured(sub) .map(async part => ({ ...part, - value: await operator.stringOrDestructuredPart!(part.value), + value: wrapString(await operator.stringOrDestructuredPart!(unwrapString(part.value))), })))) return } @@ -245,7 +306,7 @@ export class ExecutionContext { return } - throw new Error('(todo: better error) Cannot replace subject: could not find an appropriate operation for the term type of the current subject') + throw new TermOperationError(`This operation does not apply to the subject. The subject must be: ${getTermOperatorInputDisplayList(operator).join('|')}`) } resolve(term: StrTerm): StrRVal|undefined { @@ -263,7 +324,8 @@ export class ExecutionContext { resolveRequired(term: StrTerm): StrRVal { const rval = this.resolve(term) if ( !rval ) { - throw new Error('FIXME: undefined term') + const display = isStrLVal(term) ? term.name : '(unknown)' + throw new UndefinedTermError(`Could not find undefined term: ${display}`) } return rval } @@ -420,7 +482,19 @@ export class Executor extends BehaviorSubject implements LifecycleAware{ constructor(private output: OutputManager, parser?: Parser, private input?: Input) { super() this.logger = log.getStreamLogger('executor') - parser?.subscribe(exec => this.handleExecutable(exec)) + parser?.subscribe({ + next: exec => this.handleExecutable(exec), + error: error => this.handleExecutionError(error), + }) + } + + async handleExecutionError(error: Error) { + if ( error instanceof ExecutionError || error instanceof TypeError ) { + this.logger.error(`(${error.constructor.name}) ${error.message}`) + return + } + + throw error } adoptLifecycle(lifecycle: Lifecycle): void {