Start reimplementation in typescript
This commit is contained in:
parent
569bff2d3e
commit
144d90e871
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
||||
.idea
|
||||
lib
|
||||
node_modules
|
||||
|
||||
30
package.json
Normal file
30
package.json
Normal file
@ -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 <shout@garrettmills.dev>",
|
||||
"license": "MIT",
|
||||
"packageManager": "pnpm@10.6.5",
|
||||
"devDependencies": {
|
||||
"@types/node": "^22",
|
||||
"rimraf": "^6.1.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
310
pnpm-lock.yaml
Normal file
310
pnpm-lock.yaml
Normal file
@ -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
|
||||
21
src/index.ts
Normal file
21
src/index.ts
Normal file
@ -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())
|
||||
5
src/log.ts
Normal file
5
src/log.ts
Normal file
@ -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)
|
||||
19
src/util/lifecycle.ts
Normal file
19
src/util/lifecycle.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import {Awaitable} from './types.js'
|
||||
|
||||
export type LifecycleCallback = () => Awaitable<unknown>
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
122
src/util/log.ts
Normal file
122
src/util/log.ts
Normal file
@ -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, string> = {
|
||||
[LogLevel.VERBOSE]: 'verb',
|
||||
[LogLevel.DEBUG]: 'debug',
|
||||
[LogLevel.INFO]: 'info',
|
||||
[LogLevel.WARN]: 'warn',
|
||||
[LogLevel.ERROR]: 'error',
|
||||
}
|
||||
|
||||
export type LogLevels = {
|
||||
default: LogLevel,
|
||||
streams: Record<string, LogLevel>,
|
||||
}
|
||||
|
||||
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<void> {
|
||||
return this.logAtLevel(LogLevel.VERBOSE, stream, data)
|
||||
}
|
||||
|
||||
debug(stream: string, data: unknown): Awaitable<void> {
|
||||
return this.logAtLevel(LogLevel.DEBUG, stream, data)
|
||||
}
|
||||
|
||||
info(stream: string, data: unknown): Awaitable<void> {
|
||||
return this.logAtLevel(LogLevel.INFO, stream, data)
|
||||
}
|
||||
|
||||
warn(stream: string, data: unknown): Awaitable<void> {
|
||||
return this.logAtLevel(LogLevel.WARN, stream, data)
|
||||
}
|
||||
|
||||
error(stream: string, data: unknown): Awaitable<void> {
|
||||
return this.logAtLevel(LogLevel.ERROR, stream, data)
|
||||
}
|
||||
|
||||
logAtLevel(level: LogLevel, stream: string, data: unknown): Awaitable<void> {
|
||||
return this.log({
|
||||
timestamp: new Date,
|
||||
level,
|
||||
stream,
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
shouldLog(message: LogMessage): boolean {
|
||||
return message.level >= this.getLevelForStream(message.stream)
|
||||
}
|
||||
|
||||
log(message: LogMessage): Awaitable<void> {
|
||||
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<void>
|
||||
}
|
||||
|
||||
export class ConsoleLogger extends Logger {
|
||||
protected write(message: LogMessage): Awaitable<void> {
|
||||
console.log(`[${message.stream}] [${logLevelDisplay[message.level]}] [${message.timestamp.toISOString()}]`, message.data)
|
||||
}
|
||||
}
|
||||
225
src/util/subject.ts
Normal file
225
src/util/subject.ts
Normal file
@ -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<T> = (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<T> = (val?: T) => any
|
||||
|
||||
/**
|
||||
* Subscribers that define multiple handler methods.
|
||||
*/
|
||||
export type ComplexSubscriber<T> = {
|
||||
next?: SubscriberFunction<T>,
|
||||
error?: SubscriberErrorFunction,
|
||||
complete?: SubscriberCompleteFunction<T>,
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscription to a behavior subject.
|
||||
*/
|
||||
export type Subscription<T> = SubscriberFunction<T> | ComplexSubscriber<T>
|
||||
|
||||
/**
|
||||
* Object providing helpers for unsubscribing from a subscription.
|
||||
*/
|
||||
export type Unsubscribe = { unsubscribe: () => void }
|
||||
|
||||
/**
|
||||
* A stream-based state class.
|
||||
*/
|
||||
export class BehaviorSubject<T> {
|
||||
/**
|
||||
* Subscribers to this subject.
|
||||
* @type Array<ComplexSubscriber>
|
||||
*/
|
||||
protected subscribers: ComplexSubscriber<T>[] = []
|
||||
|
||||
/**
|
||||
* 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<T>): 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<T2>(mapper: (val: T) => T2|Promise<T2>): BehaviorSubject<T2> {
|
||||
const sub = new BehaviorSubject<T2>()
|
||||
this.subscribe(async val => sub.next(await mapper(val)))
|
||||
return sub
|
||||
}
|
||||
|
||||
public pipeFlat<T2>(mapper: (val: T) => T2[]|Promise<T2[]>): BehaviorSubject<T2> {
|
||||
const sub = new BehaviorSubject<T2>()
|
||||
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<T> {
|
||||
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<void>
|
||||
*/
|
||||
public async next(val: T): Promise<void> {
|
||||
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<void>
|
||||
*/
|
||||
public async push(vals: T[]): Promise<void> {
|
||||
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<void>
|
||||
*/
|
||||
public async complete(finalValue?: T): Promise<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
9
src/util/types.ts
Normal file
9
src/util/types.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export type Awaitable<T> = T | Promise<T>
|
||||
|
||||
export type JSONScalar = string | boolean | number | undefined
|
||||
export type JSONData = JSONScalar | Array<JSONScalar | JSONData> | { [key: string]: JSONScalar | JSONData }
|
||||
|
||||
/** A typescript-compatible version of Object.hasOwnProperty. */
|
||||
export function hasOwnProperty<X extends {}, Y extends PropertyKey>(obj: X, prop: Y): obj is X & Record<Y, unknown> { // eslint-disable-line @typescript-eslint/ban-types
|
||||
return Object.hasOwnProperty.call(obj, prop)
|
||||
}
|
||||
73
src/vm/commands/command.ts
Normal file
73
src/vm/commands/command.ts
Normal file
@ -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<string, unknown>
|
||||
|
||||
export abstract class Command<TData extends CommandData> {
|
||||
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
|
||||
}
|
||||
}
|
||||
16
src/vm/commands/copy.ts
Normal file
16
src/vm/commands/copy.ts
Normal file
@ -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'
|
||||
}
|
||||
}
|
||||
16
src/vm/commands/edit.ts
Normal file
16
src/vm/commands/edit.ts
Normal file
@ -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'
|
||||
}
|
||||
}
|
||||
16
src/vm/commands/exit.ts
Normal file
16
src/vm/commands/exit.ts
Normal file
@ -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'
|
||||
}
|
||||
}
|
||||
16
src/vm/commands/from.ts
Normal file
16
src/vm/commands/from.ts
Normal file
@ -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'
|
||||
}
|
||||
}
|
||||
16
src/vm/commands/history.ts
Normal file
16
src/vm/commands/history.ts
Normal file
@ -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'
|
||||
}
|
||||
}
|
||||
29
src/vm/commands/index.ts
Normal file
29
src/vm/commands/index.ts
Normal file
@ -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<CommandData>[]
|
||||
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,
|
||||
]
|
||||
16
src/vm/commands/infile.ts
Normal file
16
src/vm/commands/infile.ts
Normal file
@ -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'
|
||||
}
|
||||
}
|
||||
16
src/vm/commands/load.ts
Normal file
16
src/vm/commands/load.ts
Normal file
@ -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'
|
||||
}
|
||||
}
|
||||
16
src/vm/commands/outfile.ts
Normal file
16
src/vm/commands/outfile.ts
Normal file
@ -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'
|
||||
}
|
||||
}
|
||||
16
src/vm/commands/paste.ts
Normal file
16
src/vm/commands/paste.ts
Normal file
@ -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'
|
||||
}
|
||||
}
|
||||
16
src/vm/commands/runfile.ts
Normal file
16
src/vm/commands/runfile.ts
Normal file
@ -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'
|
||||
}
|
||||
}
|
||||
16
src/vm/commands/save.ts
Normal file
16
src/vm/commands/save.ts
Normal file
@ -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'
|
||||
}
|
||||
}
|
||||
16
src/vm/commands/to.ts
Normal file
16
src/vm/commands/to.ts
Normal file
@ -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'
|
||||
}
|
||||
}
|
||||
7
src/vm/index.ts
Normal file
7
src/vm/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import {Input} from './input.js'
|
||||
|
||||
export class StrVM {
|
||||
constructor(
|
||||
private input: Input,
|
||||
) {}
|
||||
}
|
||||
35
src/vm/input.ts
Normal file
35
src/vm/input.ts
Normal file
@ -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<string> 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())
|
||||
}
|
||||
}
|
||||
95
src/vm/lexer.ts
Normal file
95
src/vm/lexer.ts
Normal file
@ -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<LexToken> {
|
||||
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<void> {
|
||||
logger.verbose({ emitToken: reason })
|
||||
await this.next({ type: 'input', value: this.tokenAccumulator, literal })
|
||||
this.tokenAccumulator = ''
|
||||
}
|
||||
|
||||
private async lexInput(input: string): Promise<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/vm/parse.ts
Normal file
14
src/vm/parse.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import {Command, CommandData} from './commands/command.js'
|
||||
|
||||
export type Executable<TData extends CommandData> = {
|
||||
command: Command<TData>,
|
||||
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 {}
|
||||
94
src/vm/parser.ts
Normal file
94
src/vm/parser.ts
Normal file
@ -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<Executable<CommandData>> {
|
||||
private logger: StreamLogger
|
||||
|
||||
private parseCandidate?: Command<CommandData>
|
||||
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<CommandData> {
|
||||
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}`
|
||||
}
|
||||
}
|
||||
51
src/vm/string.ts
Normal file
51
src/vm/string.ts
Normal file
@ -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
|
||||
}),
|
||||
})
|
||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
@ -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"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user