diff --git a/.gitignore b/.gitignore index 7a1537b..fc903d2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .idea +lib node_modules diff --git a/package.json b/package.json new file mode 100644 index 0000000..7cfcd8d --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "@glmdev/str", + "version": "0.1.0", + "description": "An interactive string manipulation environment", + "homepage": "https://code.garrettmills.dev/glmdev/str", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "type": "module", + "directories": { + "lib": "lib" + }, + "files": [ + "lib/**/*" + ], + "repository": { + "type": "git", + "url": "https://code.garrettmills.dev/garrettmills/str" + }, + "scripts": { + "build": "rimraf lib && tsc" + }, + "author": "Garrett Mills ", + "license": "MIT", + "packageManager": "pnpm@10.6.5", + "devDependencies": { + "@types/node": "^22", + "rimraf": "^6.1.0", + "typescript": "^5.9.3" + } +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..7d8e834 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,310 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@types/node': + specifier: ^22 + version: 22.19.0 + rimraf: + specifier: ^6.1.0 + version: 6.1.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + +packages: + + '@isaacs/balanced-match@4.0.1': + resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} + engines: {node: 20 || >=22} + + '@isaacs/brace-expansion@5.0.0': + resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} + engines: {node: 20 || >=22} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@types/node@22.19.0': + resolution: {integrity: sha512-xpr/lmLPQEj+TUnHmR+Ab91/glhJvsqcjB+yY0Ix9GO70H6Lb4FHH5GeqdOE5btAx7eIMwuHkp4H2MSkLcqWbA==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + glob@11.0.3: + resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==} + engines: {node: 20 || >=22} + hasBin: true + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jackspeak@4.1.1: + resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} + engines: {node: 20 || >=22} + + lru-cache@11.2.2: + resolution: {integrity: sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==} + engines: {node: 20 || >=22} + + minimatch@10.1.1: + resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} + engines: {node: 20 || >=22} + + minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@2.0.1: + resolution: {integrity: sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==} + engines: {node: 20 || >=22} + + rimraf@6.1.0: + resolution: {integrity: sha512-DxdlA1bdNzkZK7JiNWH+BAx1x4tEJWoTofIopFo6qWUU94jYrFZ0ubY05TqH3nWPJ1nKa1JWVFDINZ3fnrle/A==} + engines: {node: 20 || >=22} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + +snapshots: + + '@isaacs/balanced-match@4.0.1': {} + + '@isaacs/brace-expansion@5.0.0': + dependencies: + '@isaacs/balanced-match': 4.0.1 + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@types/node@22.19.0': + dependencies: + undici-types: 6.21.0 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + eastasianwidth@0.2.0: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + glob@11.0.3: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.1.1 + minimatch: 10.1.1 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.1 + + is-fullwidth-code-point@3.0.0: {} + + isexe@2.0.0: {} + + jackspeak@4.1.1: + dependencies: + '@isaacs/cliui': 8.0.2 + + lru-cache@11.2.2: {} + + minimatch@10.1.1: + dependencies: + '@isaacs/brace-expansion': 5.0.0 + + minipass@7.1.2: {} + + package-json-from-dist@1.0.1: {} + + path-key@3.1.1: {} + + path-scurry@2.0.1: + dependencies: + lru-cache: 11.2.2 + minipass: 7.1.2 + + rimraf@6.1.0: + dependencies: + glob: 11.0.3 + package-json-from-dist: 1.0.1 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + signal-exit@4.1.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.2 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.1.2 diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..beaca0d --- /dev/null +++ b/src/index.ts @@ -0,0 +1,21 @@ +import {log} from './log.js' +import {Lifecycle} from './util/lifecycle.js' +import {Input} from './vm/input.js' +import {Lexer} from "./vm/lexer.js"; +import {Parser} from "./vm/parser.js"; +import {commands} from "./vm/commands/index.js"; + +const lifecycle = new Lifecycle() +const input = new Input() +input.adoptLifecycle(lifecycle) +input.subscribe(line => log.verbose('input', { line })) + +const lexer = new Lexer(input) +lexer.subscribe(token => log.verbose('token', token)) + +const parser = new Parser(lexer, commands) +parser.subscribe(exec => log.verbose('exec', exec)) + +input.setupPrompt() + +process.on('SIGINT', () => lifecycle.close()) diff --git a/src/log.ts b/src/log.ts new file mode 100644 index 0000000..b87b9db --- /dev/null +++ b/src/log.ts @@ -0,0 +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 diff --git a/src/util/lifecycle.ts b/src/util/lifecycle.ts new file mode 100644 index 0000000..6622b75 --- /dev/null +++ b/src/util/lifecycle.ts @@ -0,0 +1,19 @@ +import {Awaitable} from './types.js' + +export type LifecycleCallback = () => Awaitable + +export type LifecycleAware = { + adoptLifecycle(lifecycle: Lifecycle): void; +} + +export class Lifecycle { + private onCloses: LifecycleCallback[] = [] + + onClose(closure: LifecycleCallback): void { + this.onCloses.push(closure) + } + + close() { + this.onCloses.map(x => x()) + } +} diff --git a/src/util/log.ts b/src/util/log.ts new file mode 100644 index 0000000..77ec423 --- /dev/null +++ b/src/util/log.ts @@ -0,0 +1,122 @@ +import {Awaitable} from './types.js' + +export enum LogLevel { + VERBOSE = 0, + DEBUG = 1, + INFO = 2, + WARN = 3, + ERROR = 4, +} + +export const logLevelDisplay: Record = { + [LogLevel.VERBOSE]: 'verb', + [LogLevel.DEBUG]: 'debug', + [LogLevel.INFO]: 'info', + [LogLevel.WARN]: 'warn', + [LogLevel.ERROR]: 'error', +} + +export type LogLevels = { + default: LogLevel, + streams: Record, +} + +export type LogMessage = { + level: LogLevel, + timestamp: Date, + stream: string, + data: unknown, +} + +export type StreamLogger = { + verbose(data: unknown): void, + debug(data: unknown): void, + info(data: unknown): void, + warn(data: unknown): void, + error(data: unknown): void, +} + +export abstract class Logger { + protected logLevels: LogLevels + + constructor( + defaultLevel: LogLevel, + ) { + this.logLevels = { + default: defaultLevel, + streams: {}, + } + } + + setDefaultLevel(level: LogLevel) { + this.logLevels.default = level + } + + setStreamLevel(stream: string, level: LogLevel) { + this.logLevels.streams[stream] = level + } + + getStreamLogger(stream: string): StreamLogger { + return { + verbose: (data: unknown) => this.verbose(stream, data), + debug: (data: unknown) => this.debug(stream, data), + info: (data: unknown) => this.info(stream, data), + warn: (data: unknown) => this.warn(stream, data), + error: (data: unknown) => this.error(stream, data), + } + } + + verbose(stream: string, data: unknown): Awaitable { + return this.logAtLevel(LogLevel.VERBOSE, stream, data) + } + + debug(stream: string, data: unknown): Awaitable { + return this.logAtLevel(LogLevel.DEBUG, stream, data) + } + + info(stream: string, data: unknown): Awaitable { + return this.logAtLevel(LogLevel.INFO, stream, data) + } + + warn(stream: string, data: unknown): Awaitable { + return this.logAtLevel(LogLevel.WARN, stream, data) + } + + error(stream: string, data: unknown): Awaitable { + return this.logAtLevel(LogLevel.ERROR, stream, data) + } + + logAtLevel(level: LogLevel, stream: string, data: unknown): Awaitable { + return this.log({ + timestamp: new Date, + level, + stream, + data, + }) + } + + shouldLog(message: LogMessage): boolean { + return message.level >= this.getLevelForStream(message.stream) + } + + log(message: LogMessage): Awaitable { + if ( this.shouldLog(message) ) { + return this.write(message) + } + } + + getLevelForStream(stream: string): LogLevel { + if ( stream in this.logLevels.streams ) { + return this.logLevels.streams[stream] + } + return this.logLevels.default + } + + protected abstract write(message: LogMessage): Awaitable +} + +export class ConsoleLogger extends Logger { + protected write(message: LogMessage): Awaitable { + console.log(`[${message.stream}] [${logLevelDisplay[message.level]}] [${message.timestamp.toISOString()}]`, message.data) + } +} diff --git a/src/util/subject.ts b/src/util/subject.ts new file mode 100644 index 0000000..4e46efb --- /dev/null +++ b/src/util/subject.ts @@ -0,0 +1,225 @@ +/** + * Base error used to trigger an unsubscribe action from a subscriber. + * @extends Error + */ +export class UnsubscribeError extends Error {} + +/** + * Thrown when a closed observable is pushed to. + * @extends Error + */ +export class CompletedObservableError extends Error { + constructor() { + super('This observable can no longer be pushed to, as it has been completed.') + } +} + +/** + * Type of a basic subscriber function. + */ +export type SubscriberFunction = (val: T) => any + +/** + * Type of a basic subscriber function that handles errors. + */ +export type SubscriberErrorFunction = (error: Error) => any + +/** + * Type of a basic subscriber function that handles completed events. + */ +export type SubscriberCompleteFunction = (val?: T) => any + +/** + * Subscribers that define multiple handler methods. + */ +export type ComplexSubscriber = { + next?: SubscriberFunction, + error?: SubscriberErrorFunction, + complete?: SubscriberCompleteFunction, +} + +/** + * Subscription to a behavior subject. + */ +export type Subscription = SubscriberFunction | ComplexSubscriber + +/** + * Object providing helpers for unsubscribing from a subscription. + */ +export type Unsubscribe = { unsubscribe: () => void } + +/** + * A stream-based state class. + */ +export class BehaviorSubject { + /** + * Subscribers to this subject. + * @type Array + */ + protected subscribers: ComplexSubscriber[] = [] + + /** + * True if this subject has been marked complete. + * @type boolean + */ + protected subjectIsComplete = false + + /** + * The current value of this subject. + */ + protected currentValue?: T + + /** + * True if any value has been pushed to this subject. + * @type boolean + */ + protected hasPush = false + + /** + * Register a new subscription to this subject. + * @param {Subscription} subscriber + * @return Unsubscribe + */ + public subscribe(subscriber: Subscription): Unsubscribe { + if ( typeof subscriber === 'function' ) { + this.subscribers.push({ next: subscriber }) + } else { + this.subscribers.push(subscriber) + } + + return { + unsubscribe: () => { + this.subscribers = this.subscribers.filter(x => x !== subscriber) + }, + } + } + + public pipe(mapper: (val: T) => T2|Promise): BehaviorSubject { + const sub = new BehaviorSubject() + this.subscribe(async val => sub.next(await mapper(val))) + return sub + } + + public pipeFlat(mapper: (val: T) => T2[]|Promise): BehaviorSubject { + const sub = new BehaviorSubject() + this.subscribe(async val => { + const vals = await mapper(val) + return Promise.all(vals.map(val => sub.next(val))) + }) + return sub + } + + /** + * Cast this subject to a promise, which resolves on the output of the next value. + * @return Promise + */ + public toPromise(): Promise { + return new Promise((resolve, reject) => { + const { unsubscribe } = this.subscribe({ + next: (val: T) => { + resolve(val) + unsubscribe() + }, + error: (error: Error) => { + reject(error) + unsubscribe() + }, + complete: (val?: T) => { + if ( typeof val !== 'undefined' ) { + resolve(val) + } + unsubscribe() + }, + }) + }) + } + + /** + * Push a new value to this subject. The promise resolves when all subscribers have been pushed to. + * @param val + * @return Promise + */ + public async next(val: T): Promise { + if ( this.subjectIsComplete ) { + throw new CompletedObservableError() + } + this.currentValue = val + this.hasPush = true + for ( const subscriber of this.subscribers ) { + if ( subscriber.next ) { + try { + await subscriber.next(val) + } catch (e) { + if ( e instanceof UnsubscribeError ) { + this.subscribers = this.subscribers.filter(x => x !== subscriber) + } else if (subscriber.error && e instanceof Error) { + await subscriber.error(e) + } else { + throw e + } + } + } + } + } + + /** + * Push the given array of values to this subject in order. + * The promise resolves when all subscribers have been pushed to for all values. + * @param {Array} vals + * @return Promise + */ + public async push(vals: T[]): Promise { + if ( this.subjectIsComplete ) { + throw new CompletedObservableError() + } + await Promise.all(vals.map(val => this.next(val))) + } + + /** + * Mark this subject as complete. + * The promise resolves when all subscribers have been pushed to. + * @param [finalValue] - optionally, a final value to set + * @return Promise + */ + public async complete(finalValue?: T): Promise { + if ( this.subjectIsComplete ) { + throw new CompletedObservableError() + } + if ( typeof finalValue === 'undefined' ) { + finalValue = this.value() + } else { + this.currentValue = finalValue + } + + for ( const subscriber of this.subscribers ) { + if ( subscriber.complete ) { + try { + await subscriber.complete(finalValue) + } catch (e) { + if ( subscriber.error && e instanceof Error ) { + await subscriber.error(e) + } else { + throw e + } + } + } + } + + this.subjectIsComplete = true + } + + /** + * Get the current value of this subject. + */ + public value(): T | undefined { + return this.currentValue + } + + /** + * True if this subject is marked as complete. + * @return boolean + */ + public isComplete(): boolean { + return this.subjectIsComplete + } +} diff --git a/src/util/types.ts b/src/util/types.ts new file mode 100644 index 0000000..cccbce0 --- /dev/null +++ b/src/util/types.ts @@ -0,0 +1,9 @@ +export type Awaitable = T | Promise + +export type JSONScalar = string | boolean | number | undefined +export type JSONData = JSONScalar | Array | { [key: string]: JSONScalar | JSONData } + +/** A typescript-compatible version of Object.hasOwnProperty. */ +export function hasOwnProperty(obj: X, prop: Y): obj is X & Record { // eslint-disable-line @typescript-eslint/ban-types + return Object.hasOwnProperty.call(obj, prop) +} diff --git a/src/vm/commands/command.ts b/src/vm/commands/command.ts new file mode 100644 index 0000000..3eeab78 --- /dev/null +++ b/src/vm/commands/command.ts @@ -0,0 +1,73 @@ +import {LexInput} from '../lexer.js' +import {ExpectedEndOfInputError, InvalidVariableNameError, UnexpectedEndOfInputError} from "../parse.js"; + +export type StrLVal = { term: 'variable', name: string } + +export type StrTerm = + { term: 'string', value: string } + | StrLVal + +export class ParseContext { + constructor( + private inputs: LexInput[], + ) {} + + assertEmpty() { + if ( this.inputs.length ) { + throw new ExpectedEndOfInputError(`Expected end of input. Found: ${this.inputs[0].value}`) + } + } + + popOptionalTerm(): StrTerm|undefined { + if ( this.inputs.length ) return this.popTerm() + return undefined + } + + popTerm(): StrTerm { + if ( !this.inputs.length ) { + throw new UnexpectedEndOfInputError('Unexpected end of input. Expected term.'); + } + + const input = this.inputs.shift()! + + // Check if the token is a literal variable name: + if ( !input.literal && input.value.startsWith('$') ) { + if ( !input.value.match(/^\$[a-zA-Z0-9_]+$/) ) { + throw new InvalidVariableNameError(`Invalid variable name: ${input.value}`) + } + + return { term: 'variable', name: input.value } + } + + // Otherwise, parse it as a string literal: + return { term: 'string', value: input.value } + } + + popLVal(): StrLVal { + if ( !this.inputs.length ) { + throw new UnexpectedEndOfInputError('Unexpected end of input. Expected lval.'); + } + + const input = this.inputs.shift()! + + if ( input.literal || !input.value.match(/^\$[a-zA-Z0-9_]+$/) ) { + throw new InvalidVariableNameError(`Expected variable name. Found: ${input.value}`) + } + + return { term: 'variable', name: input.value } + } +} + +export type CommandData = Record + +export abstract class Command { + abstract isParseCandidate(token: LexInput): boolean + + abstract attemptParse(context: ParseContext): TData + + abstract getDisplayName(): string + + protected isKeyword(token: LexInput, keyword: string): boolean { + return !token.literal && token.value === keyword + } +} diff --git a/src/vm/commands/copy.ts b/src/vm/commands/copy.ts new file mode 100644 index 0000000..7a766f7 --- /dev/null +++ b/src/vm/commands/copy.ts @@ -0,0 +1,16 @@ +import {Command, ParseContext} from "./command.js"; +import {LexInput} from "../lexer.js"; + +export class Copy extends Command<{}> { + isParseCandidate(token: LexInput): boolean { + return this.isKeyword(token, 'copy') + } + + attemptParse(context: ParseContext): {} { + return {} + } + + getDisplayName(): string { + return 'copy' + } +} diff --git a/src/vm/commands/edit.ts b/src/vm/commands/edit.ts new file mode 100644 index 0000000..f371245 --- /dev/null +++ b/src/vm/commands/edit.ts @@ -0,0 +1,16 @@ +import {Command, ParseContext} from "./command.js"; +import {LexInput} from "../lexer.js"; + +export class Edit extends Command<{}> { + isParseCandidate(token: LexInput): boolean { + return this.isKeyword(token, 'edit') + } + + attemptParse(context: ParseContext): {} { + return {} + } + + getDisplayName(): string { + return 'edit' + } +} diff --git a/src/vm/commands/exit.ts b/src/vm/commands/exit.ts new file mode 100644 index 0000000..aeb2863 --- /dev/null +++ b/src/vm/commands/exit.ts @@ -0,0 +1,16 @@ +import {Command, ParseContext} from "./command.js"; +import {LexInput} from "../lexer.js"; + +export class Exit extends Command<{}> { + isParseCandidate(token: LexInput): boolean { + return this.isKeyword(token, 'exit') + } + + attemptParse(context: ParseContext): {} { + return {} + } + + getDisplayName(): string { + return 'exit' + } +} diff --git a/src/vm/commands/from.ts b/src/vm/commands/from.ts new file mode 100644 index 0000000..7c08ebb --- /dev/null +++ b/src/vm/commands/from.ts @@ -0,0 +1,16 @@ +import {Command, ParseContext, StrLVal} from "./command.js"; +import {LexInput} from "../lexer.js"; + +export class From extends Command<{ var: StrLVal }> { + isParseCandidate(token: LexInput): boolean { + return this.isKeyword(token, 'from') + } + + attemptParse(context: ParseContext): { var: StrLVal } { + return { var: context.popLVal() } + } + + getDisplayName(): string { + return 'from' + } +} diff --git a/src/vm/commands/history.ts b/src/vm/commands/history.ts new file mode 100644 index 0000000..e106284 --- /dev/null +++ b/src/vm/commands/history.ts @@ -0,0 +1,16 @@ +import {Command, ParseContext} from "./command.js"; +import {LexInput} from "../lexer.js"; + +export class History extends Command<{}> { + isParseCandidate(token: LexInput): boolean { + return this.isKeyword(token, 'history') + } + + attemptParse(context: ParseContext): {} { + return {} + } + + getDisplayName(): string { + return 'history' + } +} diff --git a/src/vm/commands/index.ts b/src/vm/commands/index.ts new file mode 100644 index 0000000..a93d0b5 --- /dev/null +++ b/src/vm/commands/index.ts @@ -0,0 +1,29 @@ +import {Command, CommandData} from './command.js' +import {Exit} from "./exit.js"; +import {InFile} from "./infile.js"; +import {Copy} from "./copy.js"; +import {Edit} from "./edit.js"; +import {From} from "./from.js"; +import {History} from "./history.js"; +import {Load} from "./load.js"; +import {OutFile} from "./outfile.js"; +import {Paste} from "./paste.js"; +import {RunFile} from "./runfile.js"; +import {Save} from "./save.js"; +import {To} from "./to.js"; + +export type Commands = Command[] +export const commands: Commands = [ + new Copy, + new Edit, + new Exit, + new From, + new History, + new InFile, + new Load, + new OutFile, + new Paste, + new RunFile, + new Save, + new To, +] diff --git a/src/vm/commands/infile.ts b/src/vm/commands/infile.ts new file mode 100644 index 0000000..a3503b3 --- /dev/null +++ b/src/vm/commands/infile.ts @@ -0,0 +1,16 @@ +import {Command, ParseContext, StrTerm} from "./command.js"; +import {LexInput} from "../lexer.js"; + +export class InFile extends Command<{ path: StrTerm }> { + isParseCandidate(token: LexInput): boolean { + return this.isKeyword(token, 'infile') + } + + attemptParse(context: ParseContext): { path: StrTerm } { + return { path: context.popTerm() } + } + + getDisplayName(): string { + return 'infile' + } +} diff --git a/src/vm/commands/load.ts b/src/vm/commands/load.ts new file mode 100644 index 0000000..6b49fbc --- /dev/null +++ b/src/vm/commands/load.ts @@ -0,0 +1,16 @@ +import {Command, ParseContext, StrTerm} from "./command.js"; +import {LexInput} from "../lexer.js"; + +export class Load extends Command<{ path?: StrTerm }> { + isParseCandidate(token: LexInput): boolean { + return this.isKeyword(token, 'load') + } + + attemptParse(context: ParseContext): { path?: StrTerm } { + return { path: context.popOptionalTerm() } + } + + getDisplayName(): string { + return 'load' + } +} diff --git a/src/vm/commands/outfile.ts b/src/vm/commands/outfile.ts new file mode 100644 index 0000000..c333bdc --- /dev/null +++ b/src/vm/commands/outfile.ts @@ -0,0 +1,16 @@ +import {Command, ParseContext, StrTerm} from "./command.js"; +import {LexInput} from "../lexer.js"; + +export class OutFile extends Command<{ path: StrTerm }> { + isParseCandidate(token: LexInput): boolean { + return this.isKeyword(token, 'outfile') + } + + attemptParse(context: ParseContext): { path: StrTerm } { + return { path: context.popTerm() } + } + + getDisplayName(): string { + return 'outfile' + } +} diff --git a/src/vm/commands/paste.ts b/src/vm/commands/paste.ts new file mode 100644 index 0000000..2de9824 --- /dev/null +++ b/src/vm/commands/paste.ts @@ -0,0 +1,16 @@ +import {Command, ParseContext} from "./command.js"; +import {LexInput} from "../lexer.js"; + +export class Paste extends Command<{}> { + isParseCandidate(token: LexInput): boolean { + return this.isKeyword(token, 'paste') + } + + attemptParse(context: ParseContext): {} { + return {} + } + + getDisplayName(): string { + return 'paste' + } +} diff --git a/src/vm/commands/runfile.ts b/src/vm/commands/runfile.ts new file mode 100644 index 0000000..d228d81 --- /dev/null +++ b/src/vm/commands/runfile.ts @@ -0,0 +1,16 @@ +import {Command, ParseContext, StrTerm} from "./command.js"; +import {LexInput} from "../lexer.js"; + +export class RunFile extends Command<{ path: StrTerm }> { + isParseCandidate(token: LexInput): boolean { + return this.isKeyword(token, 'runfile') + } + + attemptParse(context: ParseContext): { path: StrTerm } { + return { path: context.popTerm() } + } + + getDisplayName(): string { + return 'runfile' + } +} diff --git a/src/vm/commands/save.ts b/src/vm/commands/save.ts new file mode 100644 index 0000000..02b3deb --- /dev/null +++ b/src/vm/commands/save.ts @@ -0,0 +1,16 @@ +import {Command, ParseContext, StrTerm} from "./command.js"; +import {LexInput} from "../lexer.js"; + +export class Save extends Command<{ path?: StrTerm }> { + isParseCandidate(token: LexInput): boolean { + return this.isKeyword(token, 'save') + } + + attemptParse(context: ParseContext): { path?: StrTerm } { + return { path: context.popOptionalTerm() } + } + + getDisplayName(): string { + return 'save' + } +} diff --git a/src/vm/commands/to.ts b/src/vm/commands/to.ts new file mode 100644 index 0000000..9490732 --- /dev/null +++ b/src/vm/commands/to.ts @@ -0,0 +1,16 @@ +import {Command, ParseContext, StrLVal} from "./command.js"; +import {LexInput} from "../lexer.js"; + +export class To extends Command<{ var: StrLVal }> { + isParseCandidate(token: LexInput): boolean { + return this.isKeyword(token, 'to') + } + + attemptParse(context: ParseContext): { var: StrLVal } { + return { var: context.popLVal() } + } + + getDisplayName(): string { + return 'to' + } +} diff --git a/src/vm/index.ts b/src/vm/index.ts new file mode 100644 index 0000000..b8b0e2e --- /dev/null +++ b/src/vm/index.ts @@ -0,0 +1,7 @@ +import {Input} from './input.js' + +export class StrVM { + constructor( + private input: Input, + ) {} +} diff --git a/src/vm/input.ts b/src/vm/input.ts new file mode 100644 index 0000000..b082cc2 --- /dev/null +++ b/src/vm/input.ts @@ -0,0 +1,35 @@ +import * as readline from 'node:readline' +import {BehaviorSubject} from "../util/subject.js"; +import {Lifecycle, LifecycleAware} from "../util/lifecycle.js"; + +export class Input extends BehaviorSubject implements LifecycleAware { + private rl?: readline.Interface + + public setupPrompt(): void { + if ( this.rl ) { + this.closePrompt() + } + + this.rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + prompt: 'str %> ', + }) + + this.rl.prompt() + + this.rl.on('line', async (line) => { + await this.next(line + '\n') + this.rl?.prompt(true) + }) + } + + public closePrompt(): void { + this.rl?.close() + this.rl = undefined + } + + adoptLifecycle(lifecycle: Lifecycle): void { + lifecycle.onClose(() => this.closePrompt()) + } +} diff --git a/src/vm/lexer.ts b/src/vm/lexer.ts new file mode 100644 index 0000000..fd4a5c8 --- /dev/null +++ b/src/vm/lexer.ts @@ -0,0 +1,95 @@ +import {BehaviorSubject} from '../util/subject.js' +import {Input} from './input.js' +import {log} from '../log.js' +import {StreamLogger} from '../util/log.js' + +export type LexTerminator = { type: 'terminator' } +export type LexInput = { type: 'input', value: string, literal?: true } + +export type LexToken = LexTerminator | LexInput + +const logger = log.getStreamLogger('lexer') + +export class Lexer extends BehaviorSubject { + private isEscape: boolean = false + private inQuote?: '"'|"'" + private tokenAccumulator: string = '' + + private logger: StreamLogger + + constructor(input: Input) { + super() + this.logger = log.getStreamLogger('lexer') + input.subscribe(input => this.lexInput(input)) + } + + private logState(c: string): void { + this.logger.verbose({ + c, + isEscape: this.isEscape, + inQuote: this.inQuote, + tokenAccumulator: this.tokenAccumulator, + }) + } + + private async emitToken(reason: string, literal?: true): Promise { + logger.verbose({ emitToken: reason }) + await this.next({ type: 'input', value: this.tokenAccumulator, literal }) + this.tokenAccumulator = '' + } + + private async lexInput(input: string): Promise { + logger.debug({ input }) + + let inputChars = input.split('') + + while ( inputChars.length ) { + const c = inputChars.shift()! + this.logState(c) + + // We got the 2nd character after an escape + if ( this.isEscape ) { + this.tokenAccumulator += c + this.isEscape = false + continue + } + + // We are about to get an escape character + if ( c === '\\' ) { + this.isEscape = true + continue + } + + // We got a statement terminator + if ( (c === ';' || c === '\n') && !this.inQuote ) { + if ( this.tokenAccumulator ) { + await this.emitToken('terminator') + } + await this.next({ type: 'terminator' }) + continue + } + + // Whitespace separates tokens + if ( (c === ' ' || c === '\t' || c === '\r') && !this.inQuote ) { + if ( this.tokenAccumulator ) { + await this.emitToken('whitespace') + } + continue + } + + // We are either starting or ending an unescaped matching quote + if ( c === `'` || c === `"` ) { + if ( c === this.inQuote ) { + this.inQuote = undefined + await this.emitToken('quote', true) + continue + } else if ( !this.inQuote ) { + this.inQuote = c + continue + } + } + + this.tokenAccumulator += c + } + } +} diff --git a/src/vm/parse.ts b/src/vm/parse.ts new file mode 100644 index 0000000..5046705 --- /dev/null +++ b/src/vm/parse.ts @@ -0,0 +1,14 @@ +import {Command, CommandData} from './commands/command.js' + +export type Executable = { + command: Command, + data: TData, +} + +export class ParseError extends Error {} +export class InternalParseError extends ParseError {} +export class IsNotKeywordError extends ParseError {} +export class InvalidCommandError extends ParseError {} +export class UnexpectedEndOfInputError extends ParseError {} +export class ExpectedEndOfInputError extends InvalidCommandError {} +export class InvalidVariableNameError extends ParseError {} \ No newline at end of file diff --git a/src/vm/parser.ts b/src/vm/parser.ts new file mode 100644 index 0000000..12fb647 --- /dev/null +++ b/src/vm/parser.ts @@ -0,0 +1,94 @@ +import {BehaviorSubject} from '../util/subject.js' +import {Lexer, LexInput, LexToken} from './lexer.js' +import {StreamLogger} from '../util/log.js' +import {log} from '../log.js' +import {Commands} from './commands/index.js' +import {Command, CommandData, ParseContext} from './commands/command.js' +import {Executable, InternalParseError, InvalidCommandError, IsNotKeywordError} from './parse.js' + +export class Parser extends BehaviorSubject> { + private logger: StreamLogger + + private parseCandidate?: Command + private inputForCandidate: LexInput[] = [] + + constructor(lexer: Lexer, private commands: Commands) { + super() + this.logger = log.getStreamLogger('parser') + lexer.subscribe(token => this.handleToken(token)) + } + + async handleToken(token: LexToken) { + // We are in between full commands, so try to identify a new parse candidate: + if ( !this.parseCandidate ) { + // Ignore duplicated terminators between commands + if ( token.type === 'terminator' ) { + return + } + + this.logger.verbose({ identifyParseCandidate: token }) + if ( !this.isKeyword(token) ) { + throw new IsNotKeywordError('Expected keyword, found: ' + this.displayToken(token)) + } + + this.parseCandidate = this.getParseCandidate(token) + return + } + + // We have already identified a parse candidate: + // 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) + return + } + + // If we got a terminator, then ask the candidate to actually perform its parse: + if ( token.type === 'terminator' ) { + try { + // Have the candidate attempt to parse itself from the collecte data: + const context = new ParseContext(this.inputForCandidate) + this.logger.verbose({ parsing: this.parseCandidate.getDisplayName(), context }) + const data = this.parseCandidate.attemptParse(context) + + // The candidate must consume every token in the context: + context.assertEmpty() + + // Emit the parsed command: + this.logger.debug({ parsed: this.parseCandidate.getDisplayName() }) + await this.next({ + command: this.parseCandidate, + data, + }) + return + } finally { + this.parseCandidate = undefined + this.inputForCandidate = [] + } + } + + throw new InternalParseError('Encountered invalid token.') + } + + private isKeyword(token: LexToken): token is (LexInput & {literal: undefined}) { + return token.type === 'input' && !token.literal + } + + private getParseCandidate(token: LexInput): Command { + for ( const command of this.commands ) { + if ( command.isParseCandidate(token) ) { + this.logger.debug({ foundParseCandidate: command.getDisplayName(), token }) + return command + } + } + + throw new InvalidCommandError('Could not find parser for: ' + this.displayToken(token)) + } + + private displayToken(token: LexToken) { + if ( token.type === 'terminator' ) { + return '(TERMINATOR)' + } + + return `(${token.literal ? 'LITERAL' : 'INPUT'}) ${token.value}` + } +} diff --git a/src/vm/string.ts b/src/vm/string.ts new file mode 100644 index 0000000..1b58345 --- /dev/null +++ b/src/vm/string.ts @@ -0,0 +1,51 @@ +export type Word = { type: 'word', value: string } +export type Whitespace = { type: 'space', value: string } + +export type Component = Word | Whitespace + +export const isWord = (cmp: Component): cmp is Word => + cmp.type === 'word' + +export const isWhitespace = (cmp: Component): cmp is Whitespace => + cmp.type === 'space' + +export type Line = { + components: Component[], +} + +export type SString = { + lines: Line[], +} + +export const toNativeString = (value: SString): string => + value.lines + .map(line => + line.components + .map(cmp => cmp.value) + .join('')) + .join('\n') + +export const fromNativeString = (value: string): SString => ({ + lines: value.split('\n') + .map(rawLine => { + const whitespace = [...rawLine.matchAll(/\s+/g)] + const words = rawLine.split(/\s+/g) + const line: Line = { components: [] } + + for ( let i = 0; i < words.length; i += 1 ) { + line.components.push({ + type: 'word', + value: words[i], + }) + + if ( i < whitespace.length ) { + line.components.push({ + type: 'space', + value: whitespace[i][0], + }) + } + } + + return line + }), +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1214fca --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2016", + "module": "nodenext", + "declaration": true, + "outDir": "./lib", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "lib": ["ESNext"] + }, + "include": ["src"], + "exclude": ["node_modules"] +} \ No newline at end of file