feat(dashboard): hand-rolled Rhai parser + symbol table + Vitest
Foundation for upcoming editor features (scope-aware autocomplete, goto-def / find-usages, source formatter). Hand-rolled recursive descent in TypeScript with Pratt precedence climbing for expressions, error-tolerant so partial trees stay usable while the user is typing. Symbol table walks the AST to produce per-scope declarations, usage sites, and object-literal field maps. Vitest added as a dev-only runner; no editor wiring in this commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
442
dashboard/package-lock.json
generated
442
dashboard/package-lock.json
generated
@@ -34,7 +34,8 @@
|
||||
"svelte-check": "^4.1.4",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"vite": "^6.0.7"
|
||||
"vite": "^6.0.7",
|
||||
"vitest": "^3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
@@ -1352,6 +1353,17 @@
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||
@@ -1359,6 +1371,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
@@ -1675,6 +1694,121 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz",
|
||||
"integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@vitest/utils": "3.2.4",
|
||||
"chai": "^5.2.0",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz",
|
||||
"integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "3.2.4",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz",
|
||||
"integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz",
|
||||
"integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "3.2.4",
|
||||
"pathe": "^2.0.3",
|
||||
"strip-literal": "^3.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz",
|
||||
"integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.4",
|
||||
"magic-string": "^0.30.17",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz",
|
||||
"integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyspy": "^4.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz",
|
||||
"integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "3.2.4",
|
||||
"loupe": "^3.1.4",
|
||||
"tinyrainbow": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||
@@ -1749,6 +1883,16 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
@@ -1777,6 +1921,16 @@
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/cac": {
|
||||
"version": "6.7.14",
|
||||
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
|
||||
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/callsites": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
|
||||
@@ -1787,6 +1941,23 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
||||
"integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"assertion-error": "^2.0.1",
|
||||
"check-error": "^2.1.1",
|
||||
"deep-eql": "^5.0.1",
|
||||
"loupe": "^3.1.0",
|
||||
"pathval": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
@@ -1804,6 +1975,16 @@
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/check-error": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
|
||||
"integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
|
||||
@@ -1934,6 +2115,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/deep-eql": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
|
||||
"integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -1958,6 +2149,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.25.12",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
|
||||
@@ -2246,6 +2444,16 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esutils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||
@@ -2256,6 +2464,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -2474,6 +2692,13 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
|
||||
"integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
@@ -2589,6 +2814,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loupe": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
|
||||
"integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -2748,6 +2980,23 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathval": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz",
|
||||
"integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -3087,6 +3336,13 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sirv": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
|
||||
@@ -3112,6 +3368,20 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
|
||||
@@ -3125,6 +3395,19 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-literal": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz",
|
||||
"integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^9.0.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/antfu"
|
||||
}
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.3",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
|
||||
@@ -3228,6 +3511,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "0.3.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
|
||||
"integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
@@ -3245,6 +3542,36 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinypool": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz",
|
||||
"integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
|
||||
"integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyspy": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz",
|
||||
"integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/totalist": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||
@@ -3420,6 +3747,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite-node": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz",
|
||||
"integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cac": "^6.7.14",
|
||||
"debug": "^4.4.1",
|
||||
"es-module-lexer": "^1.7.0",
|
||||
"pathe": "^2.0.3",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0"
|
||||
},
|
||||
"bin": {
|
||||
"vite-node": "vite-node.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/vitefu": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
|
||||
@@ -3440,6 +3790,79 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
"@vitest/mocker": "3.2.4",
|
||||
"@vitest/pretty-format": "^3.2.4",
|
||||
"@vitest/runner": "3.2.4",
|
||||
"@vitest/snapshot": "3.2.4",
|
||||
"@vitest/spy": "3.2.4",
|
||||
"@vitest/utils": "3.2.4",
|
||||
"chai": "^5.2.0",
|
||||
"debug": "^4.4.1",
|
||||
"expect-type": "^1.2.1",
|
||||
"magic-string": "^0.30.17",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.2",
|
||||
"std-env": "^3.9.0",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^0.3.2",
|
||||
"tinyglobby": "^0.2.14",
|
||||
"tinypool": "^1.1.1",
|
||||
"tinyrainbow": "^2.0.0",
|
||||
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0",
|
||||
"vite-node": "3.2.4",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || ^20.0.0 || >=22.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
|
||||
"@vitest/browser": "3.2.4",
|
||||
"@vitest/ui": "3.2.4",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/debug": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
@@ -3462,6 +3885,23 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"format": "prettier --write .",
|
||||
"lint": "prettier --check . && eslint ."
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.18.0",
|
||||
@@ -28,7 +29,8 @@
|
||||
"svelte-check": "^4.1.4",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"vite": "^6.0.7"
|
||||
"vite": "^6.0.7",
|
||||
"vitest": "^3.0.5"
|
||||
},
|
||||
"overrides": {
|
||||
"cookie": "^0.7.2"
|
||||
|
||||
275
dashboard/src/lib/rhai/ast.ts
Normal file
275
dashboard/src/lib/rhai/ast.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
// AST node definitions for the dashboard's hand-rolled Rhai parser.
|
||||
//
|
||||
// Every node carries `start` / `end` byte offsets into the source so the
|
||||
// editor features (autocomplete, goto-def, find-usages, format) can map
|
||||
// between positions in the document and nodes in the tree.
|
||||
//
|
||||
// The shape mirrors the Rhai book grammar (https://rhai.rs/book/language/)
|
||||
// but simplified: type annotations are absent (Rhai is dynamic), and
|
||||
// statement-vs-expression duality is collapsed by letting `if` / `switch` /
|
||||
// block expressions appear in both positions (an `ExprStmt` wrapper turns
|
||||
// any expression into a statement).
|
||||
|
||||
export interface Range {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Comments — captured by the lexer with their positions and re-emitted by
|
||||
// the formatter. Kept off the AST tree so they don't clutter walkers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Comment extends Range {
|
||||
kind: 'LineComment' | 'BlockComment';
|
||||
text: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Statements
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Stmt =
|
||||
| LetStmt
|
||||
| ConstStmt
|
||||
| FnDecl
|
||||
| ExprStmt
|
||||
| ReturnStmt
|
||||
| WhileStmt
|
||||
| LoopStmt
|
||||
| ForStmt
|
||||
| BreakStmt
|
||||
| ContinueStmt
|
||||
| TryStmt;
|
||||
|
||||
export interface LetStmt extends Range {
|
||||
kind: 'Let';
|
||||
name: string;
|
||||
nameRange: Range;
|
||||
init: Expr | null;
|
||||
}
|
||||
|
||||
export interface ConstStmt extends Range {
|
||||
kind: 'Const';
|
||||
name: string;
|
||||
nameRange: Range;
|
||||
init: Expr | null;
|
||||
}
|
||||
|
||||
export interface Param extends Range {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface FnDecl extends Range {
|
||||
kind: 'FnDecl';
|
||||
name: string;
|
||||
nameRange: Range;
|
||||
params: Param[];
|
||||
body: BlockExpr;
|
||||
}
|
||||
|
||||
export interface ExprStmt extends Range {
|
||||
kind: 'ExprStmt';
|
||||
expr: Expr;
|
||||
// Whether the statement is terminated with `;`. Block-form expressions
|
||||
// (`if`/`switch`/`{...}`) don't require it; everything else does.
|
||||
semi: boolean;
|
||||
}
|
||||
|
||||
export interface ReturnStmt extends Range {
|
||||
kind: 'Return';
|
||||
value: Expr | null;
|
||||
}
|
||||
|
||||
export interface WhileStmt extends Range {
|
||||
kind: 'While';
|
||||
cond: Expr;
|
||||
body: BlockExpr;
|
||||
}
|
||||
|
||||
export interface LoopStmt extends Range {
|
||||
kind: 'Loop';
|
||||
body: BlockExpr;
|
||||
}
|
||||
|
||||
export interface ForStmt extends Range {
|
||||
kind: 'For';
|
||||
varName: string;
|
||||
varRange: Range;
|
||||
iter: Expr;
|
||||
body: BlockExpr;
|
||||
}
|
||||
|
||||
export interface BreakStmt extends Range {
|
||||
kind: 'Break';
|
||||
}
|
||||
|
||||
export interface ContinueStmt extends Range {
|
||||
kind: 'Continue';
|
||||
}
|
||||
|
||||
export interface TryStmt extends Range {
|
||||
kind: 'Try';
|
||||
body: BlockExpr;
|
||||
catchVar: string | null;
|
||||
catchVarRange: Range | null;
|
||||
handler: BlockExpr;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Expressions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Expr =
|
||||
| IdentExpr
|
||||
| NumberExpr
|
||||
| StringExpr
|
||||
| BoolExpr
|
||||
| NullExpr
|
||||
| CallExpr
|
||||
| MemberExpr
|
||||
| IndexExpr
|
||||
| UnaryExpr
|
||||
| BinaryExpr
|
||||
| AssignExpr
|
||||
| ParenExpr
|
||||
| ObjectMapExpr
|
||||
| ArrayExpr
|
||||
| FnExpr
|
||||
| IfExpr
|
||||
| SwitchExpr
|
||||
| BlockExpr;
|
||||
|
||||
export interface IdentExpr extends Range {
|
||||
kind: 'Ident';
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface NumberExpr extends Range {
|
||||
kind: 'Number';
|
||||
raw: string;
|
||||
}
|
||||
|
||||
export interface StringExpr extends Range {
|
||||
kind: 'String';
|
||||
// The surrounding quote — `"` is escape-processed, backtick is raw and
|
||||
// may span multiple lines. We don't decode escapes; the formatter just
|
||||
// preserves the raw text between the quotes.
|
||||
quote: '"' | '`';
|
||||
raw: string;
|
||||
}
|
||||
|
||||
export interface BoolExpr extends Range {
|
||||
kind: 'Bool';
|
||||
value: boolean;
|
||||
}
|
||||
|
||||
export interface NullExpr extends Range {
|
||||
kind: 'Null';
|
||||
}
|
||||
|
||||
export interface CallExpr extends Range {
|
||||
kind: 'Call';
|
||||
callee: Expr;
|
||||
args: Expr[];
|
||||
}
|
||||
|
||||
export interface MemberExpr extends Range {
|
||||
kind: 'Member';
|
||||
object: Expr;
|
||||
property: string;
|
||||
propertyRange: Range;
|
||||
}
|
||||
|
||||
export interface IndexExpr extends Range {
|
||||
kind: 'Index';
|
||||
object: Expr;
|
||||
index: Expr;
|
||||
}
|
||||
|
||||
export interface UnaryExpr extends Range {
|
||||
kind: 'Unary';
|
||||
op: string;
|
||||
operand: Expr;
|
||||
}
|
||||
|
||||
export interface BinaryExpr extends Range {
|
||||
kind: 'Binary';
|
||||
op: string;
|
||||
left: Expr;
|
||||
right: Expr;
|
||||
}
|
||||
|
||||
export interface AssignExpr extends Range {
|
||||
kind: 'Assign';
|
||||
op: string; // = += -= *= /= %= ??=
|
||||
target: Expr;
|
||||
value: Expr;
|
||||
}
|
||||
|
||||
export interface ParenExpr extends Range {
|
||||
kind: 'Paren';
|
||||
expr: Expr;
|
||||
}
|
||||
|
||||
export interface ObjectMapEntry extends Range {
|
||||
key: string;
|
||||
keyRange: Range;
|
||||
value: Expr;
|
||||
}
|
||||
|
||||
export interface ObjectMapExpr extends Range {
|
||||
kind: 'ObjectMap';
|
||||
entries: ObjectMapEntry[];
|
||||
}
|
||||
|
||||
export interface ArrayExpr extends Range {
|
||||
kind: 'Array';
|
||||
elements: Expr[];
|
||||
}
|
||||
|
||||
export interface FnExpr extends Range {
|
||||
kind: 'FnExpr';
|
||||
params: Param[];
|
||||
body: BlockExpr;
|
||||
}
|
||||
|
||||
export interface IfExpr extends Range {
|
||||
kind: 'IfExpr';
|
||||
cond: Expr;
|
||||
then: BlockExpr;
|
||||
// else branch: either a block or another `if` for `else if` chains.
|
||||
else_: BlockExpr | IfExpr | null;
|
||||
}
|
||||
|
||||
export interface SwitchArm extends Range {
|
||||
pattern: Expr | null; // null = `_` default case
|
||||
guard: Expr | null;
|
||||
value: Expr;
|
||||
}
|
||||
|
||||
export interface SwitchExpr extends Range {
|
||||
kind: 'SwitchExpr';
|
||||
subject: Expr;
|
||||
arms: SwitchArm[];
|
||||
}
|
||||
|
||||
export interface BlockExpr extends Range {
|
||||
kind: 'BlockExpr';
|
||||
stmts: Stmt[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Top-level parse output
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ParseError extends Range {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ParseResult {
|
||||
source: string;
|
||||
program: BlockExpr;
|
||||
errors: ParseError[];
|
||||
comments: Comment[];
|
||||
}
|
||||
16
dashboard/src/lib/rhai/index.ts
Normal file
16
dashboard/src/lib/rhai/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// Public entry points for the Rhai parser package. Editor features
|
||||
// import from here.
|
||||
|
||||
export { parse } from './parser';
|
||||
export { tokenize, KEYWORDS } from './lexer';
|
||||
export { buildSymbolTable, renderFnSignature } from './symbols';
|
||||
export type { Decl, DeclKind, Scope, SymbolTable, Usage } from './symbols';
|
||||
export type {
|
||||
BlockExpr,
|
||||
Comment,
|
||||
Expr,
|
||||
ParseError,
|
||||
ParseResult,
|
||||
Range,
|
||||
Stmt
|
||||
} from './ast';
|
||||
73
dashboard/src/lib/rhai/lexer.test.ts
Normal file
73
dashboard/src/lib/rhai/lexer.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { tokenize } from './lexer';
|
||||
|
||||
function kinds(src: string): string[] {
|
||||
return tokenize(src).tokens.filter((t) => t.kind !== 'EOF').map((t) => t.kind);
|
||||
}
|
||||
|
||||
function texts(src: string): string[] {
|
||||
return tokenize(src).tokens.filter((t) => t.kind !== 'EOF').map((t) => t.text);
|
||||
}
|
||||
|
||||
describe('lexer', () => {
|
||||
it('emits an EOF for empty input', () => {
|
||||
const { tokens } = tokenize('');
|
||||
expect(tokens).toHaveLength(1);
|
||||
expect(tokens[0].kind).toBe('EOF');
|
||||
});
|
||||
|
||||
it('distinguishes keywords from identifiers', () => {
|
||||
const { tokens } = tokenize('let foo = bar;');
|
||||
expect(tokens[0]).toMatchObject({ kind: 'Keyword', text: 'let' });
|
||||
expect(tokens[1]).toMatchObject({ kind: 'Ident', text: 'foo' });
|
||||
expect(tokens[2]).toMatchObject({ kind: 'Operator', text: '=' });
|
||||
expect(tokens[3]).toMatchObject({ kind: 'Ident', text: 'bar' });
|
||||
expect(tokens[4]).toMatchObject({ kind: 'Punct', text: ';' });
|
||||
});
|
||||
|
||||
it('lexes integer, float, hex, and binary numbers', () => {
|
||||
expect(texts('1 1.5 0xff 0b1010 1e10 1_000')).toEqual(['1', '1.5', '0xff', '0b1010', '1e10', '1_000']);
|
||||
expect(kinds('1 1.5 0xff')).toEqual(['Number', 'Number', 'Number']);
|
||||
});
|
||||
|
||||
it('lexes double-quote and backtick strings', () => {
|
||||
const { tokens } = tokenize('"hi" `world`');
|
||||
expect(tokens[0]).toMatchObject({ kind: 'String', text: '"hi"' });
|
||||
expect(tokens[1]).toMatchObject({ kind: 'String', text: '`world`' });
|
||||
});
|
||||
|
||||
it('preserves backslash escapes inside double-quoted strings', () => {
|
||||
const { tokens } = tokenize('"a\\"b"');
|
||||
expect(tokens[0].text).toBe('"a\\"b"');
|
||||
});
|
||||
|
||||
it('captures line and block comments as comments, not tokens', () => {
|
||||
const { tokens, comments } = tokenize('let x = 1; // tail\n/* block */ y');
|
||||
expect(comments.map((c) => c.kind)).toEqual(['LineComment', 'BlockComment']);
|
||||
expect(tokens.find((t) => t.text === '//' || t.text === '/*')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles nested block comments', () => {
|
||||
const { comments } = tokenize('/* outer /* inner */ still outer */');
|
||||
expect(comments).toHaveLength(1);
|
||||
expect(comments[0].text).toBe('/* outer /* inner */ still outer */');
|
||||
});
|
||||
|
||||
it('lexes multi-character operators greedily', () => {
|
||||
expect(texts('a == b && c != d')).toEqual(['a', '==', 'b', '&&', 'c', '!=', 'd']);
|
||||
expect(texts('a ?? b ??= c')).toEqual(['a', '??', 'b', '??=', 'c']);
|
||||
expect(texts('1..=10')).toEqual(['1', '..=', '10']);
|
||||
});
|
||||
|
||||
it('recognizes #{ as separate punctuation tokens', () => {
|
||||
const { tokens } = tokenize('#{}');
|
||||
expect(tokens.slice(0, 3).map((t) => t.text)).toEqual(['#', '{', '}']);
|
||||
});
|
||||
|
||||
it('records accurate byte ranges', () => {
|
||||
const src = 'let abc = 42;';
|
||||
const { tokens } = tokenize(src);
|
||||
const abc = tokens.find((t) => t.text === 'abc')!;
|
||||
expect(src.slice(abc.start, abc.end)).toBe('abc');
|
||||
});
|
||||
});
|
||||
248
dashboard/src/lib/rhai/lexer.ts
Normal file
248
dashboard/src/lib/rhai/lexer.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
// Tokenizer for the dashboard's Rhai parser.
|
||||
//
|
||||
// Produces a flat array of tokens (eager — Rhai scripts in the dashboard
|
||||
// are small, 20–200 lines typical) plus a separate list of comments. The
|
||||
// parser only sees tokens; comments are handed to the formatter so it
|
||||
// can re-emit them at the right positions.
|
||||
//
|
||||
// Keyword and operator lists trace back to the upstream TextMate grammar
|
||||
// (rhaiscript/vscode-rhai). We don't copy any grammar bytes.
|
||||
|
||||
import type { Comment, Range } from './ast';
|
||||
|
||||
export type TokenKind =
|
||||
| 'Ident'
|
||||
| 'Keyword'
|
||||
| 'Number'
|
||||
| 'String'
|
||||
| 'Punct'
|
||||
| 'Operator'
|
||||
| 'EOF';
|
||||
|
||||
export interface Token extends Range {
|
||||
kind: TokenKind;
|
||||
// For Ident/Keyword/Punct/Operator: the literal source text. For
|
||||
// Number/String: the full literal including quotes.
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const KEYWORDS = new Set([
|
||||
'let',
|
||||
'const',
|
||||
'fn',
|
||||
'if',
|
||||
'else',
|
||||
'while',
|
||||
'loop',
|
||||
'do',
|
||||
'for',
|
||||
'in',
|
||||
'return',
|
||||
'break',
|
||||
'continue',
|
||||
'switch',
|
||||
'case',
|
||||
'default',
|
||||
'true',
|
||||
'false',
|
||||
'null',
|
||||
'try',
|
||||
'catch',
|
||||
'throw',
|
||||
'as',
|
||||
'is',
|
||||
'private'
|
||||
]);
|
||||
|
||||
// Multi-char operators, longest first so the lexer picks them up greedily.
|
||||
const MULTI_CHAR_OPS = [
|
||||
'??=',
|
||||
'..=',
|
||||
'??',
|
||||
'..',
|
||||
'::',
|
||||
'==',
|
||||
'!=',
|
||||
'<=',
|
||||
'>=',
|
||||
'&&',
|
||||
'||',
|
||||
'<<',
|
||||
'>>',
|
||||
'+=',
|
||||
'-=',
|
||||
'*=',
|
||||
'/=',
|
||||
'%=',
|
||||
'=>',
|
||||
'->'
|
||||
];
|
||||
|
||||
const SINGLE_CHAR_OPS = new Set(['+', '-', '*', '/', '%', '<', '>', '!', '&', '|', '^', '~', '=', '?']);
|
||||
|
||||
// `#` is included so we can recognize the start of `#{` object-map literals;
|
||||
// the lexer emits it as a separate `Punct` and the parser combines it with
|
||||
// the following `{`.
|
||||
const PUNCTS = new Set(['(', ')', '{', '}', '[', ']', ';', ',', '.', ':', '#']);
|
||||
|
||||
export interface LexResult {
|
||||
tokens: Token[];
|
||||
comments: Comment[];
|
||||
}
|
||||
|
||||
export function tokenize(source: string): LexResult {
|
||||
const tokens: Token[] = [];
|
||||
const comments: Comment[] = [];
|
||||
let i = 0;
|
||||
const n = source.length;
|
||||
|
||||
while (i < n) {
|
||||
const ch = source[i];
|
||||
|
||||
// Whitespace
|
||||
if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Line comment
|
||||
if (ch === '/' && source[i + 1] === '/') {
|
||||
const start = i;
|
||||
while (i < n && source[i] !== '\n') i++;
|
||||
comments.push({ kind: 'LineComment', start, end: i, text: source.slice(start, i) });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Block comment (supports nesting per the Rhai book)
|
||||
if (ch === '/' && source[i + 1] === '*') {
|
||||
const start = i;
|
||||
i += 2;
|
||||
let depth = 1;
|
||||
while (i < n && depth > 0) {
|
||||
if (source[i] === '/' && source[i + 1] === '*') {
|
||||
depth++;
|
||||
i += 2;
|
||||
} else if (source[i] === '*' && source[i + 1] === '/') {
|
||||
depth--;
|
||||
i += 2;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
comments.push({ kind: 'BlockComment', start, end: i, text: source.slice(start, i) });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Strings: " ... " (escape-aware, single-line by convention) and
|
||||
// ` ... ` (raw, multi-line). We tokenize the entire literal including
|
||||
// quotes; the parser only cares about its position and text.
|
||||
if (ch === '"' || ch === '`') {
|
||||
const quote = ch;
|
||||
const start = i;
|
||||
i++;
|
||||
while (i < n) {
|
||||
const c = source[i];
|
||||
if (c === '\\' && quote === '"') {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === quote) {
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
tokens.push({ kind: 'String', start, end: i, text: source.slice(start, i) });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Numbers: hex, binary, decimal, optional `.frac`, optional exponent.
|
||||
// Underscores are allowed as digit separators per Rhai.
|
||||
if (isDigit(ch)) {
|
||||
const start = i;
|
||||
if (ch === '0' && (source[i + 1] === 'x' || source[i + 1] === 'X')) {
|
||||
i += 2;
|
||||
while (i < n && (isHexDigit(source[i]) || source[i] === '_')) i++;
|
||||
} else if (ch === '0' && (source[i + 1] === 'b' || source[i + 1] === 'B')) {
|
||||
i += 2;
|
||||
while (i < n && (source[i] === '0' || source[i] === '1' || source[i] === '_')) i++;
|
||||
} else {
|
||||
while (i < n && (isDigit(source[i]) || source[i] === '_')) i++;
|
||||
if (source[i] === '.' && isDigit(source[i + 1])) {
|
||||
i++;
|
||||
while (i < n && (isDigit(source[i]) || source[i] === '_')) i++;
|
||||
}
|
||||
if (source[i] === 'e' || source[i] === 'E') {
|
||||
i++;
|
||||
if (source[i] === '+' || source[i] === '-') i++;
|
||||
while (i < n && isDigit(source[i])) i++;
|
||||
}
|
||||
}
|
||||
tokens.push({ kind: 'Number', start, end: i, text: source.slice(start, i) });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Identifier or keyword
|
||||
if (isIdentStart(ch)) {
|
||||
const start = i;
|
||||
i++;
|
||||
while (i < n && isIdentCont(source[i])) i++;
|
||||
const text = source.slice(start, i);
|
||||
tokens.push({
|
||||
kind: KEYWORDS.has(text) ? 'Keyword' : 'Ident',
|
||||
start,
|
||||
end: i,
|
||||
text
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Multi-char operators
|
||||
let matched = false;
|
||||
for (const op of MULTI_CHAR_OPS) {
|
||||
if (source.startsWith(op, i)) {
|
||||
tokens.push({ kind: 'Operator', start: i, end: i + op.length, text: op });
|
||||
i += op.length;
|
||||
matched = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (matched) continue;
|
||||
|
||||
// Single-char operator
|
||||
if (SINGLE_CHAR_OPS.has(ch)) {
|
||||
tokens.push({ kind: 'Operator', start: i, end: i + 1, text: ch });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Punctuation
|
||||
if (PUNCTS.has(ch)) {
|
||||
tokens.push({ kind: 'Punct', start: i, end: i + 1, text: ch });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unrecognized: skip and let the parser report the gap if needed.
|
||||
i++;
|
||||
}
|
||||
|
||||
tokens.push({ kind: 'EOF', start: n, end: n, text: '' });
|
||||
return { tokens, comments };
|
||||
}
|
||||
|
||||
function isDigit(c: string): boolean {
|
||||
return c >= '0' && c <= '9';
|
||||
}
|
||||
|
||||
function isHexDigit(c: string): boolean {
|
||||
return isDigit(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
|
||||
}
|
||||
|
||||
function isIdentStart(c: string): boolean {
|
||||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c === '_';
|
||||
}
|
||||
|
||||
function isIdentCont(c: string): boolean {
|
||||
return isIdentStart(c) || isDigit(c);
|
||||
}
|
||||
126
dashboard/src/lib/rhai/parser.test.ts
Normal file
126
dashboard/src/lib/rhai/parser.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parse } from './parser';
|
||||
import type { BinaryExpr, ExprStmt, FnDecl, LetStmt } from './ast';
|
||||
|
||||
describe('parser — declarations', () => {
|
||||
it('parses a let binding with initializer', () => {
|
||||
const { program, errors } = parse('let x = 1 + 2;');
|
||||
expect(errors).toEqual([]);
|
||||
expect(program.stmts).toHaveLength(1);
|
||||
const let_ = program.stmts[0] as LetStmt;
|
||||
expect(let_.kind).toBe('Let');
|
||||
expect(let_.name).toBe('x');
|
||||
expect(let_.init?.kind).toBe('Binary');
|
||||
});
|
||||
|
||||
it('parses a const binding', () => {
|
||||
const { program, errors } = parse('const PI = 3.14;');
|
||||
expect(errors).toEqual([]);
|
||||
expect(program.stmts[0]).toMatchObject({ kind: 'Const', name: 'PI' });
|
||||
});
|
||||
|
||||
it('parses fn declarations with parameters', () => {
|
||||
const { program, errors } = parse('fn process(order, user) { order.total }');
|
||||
expect(errors).toEqual([]);
|
||||
const fn = program.stmts[0] as FnDecl;
|
||||
expect(fn.kind).toBe('FnDecl');
|
||||
expect(fn.name).toBe('process');
|
||||
expect(fn.params.map((p) => p.name)).toEqual(['order', 'user']);
|
||||
expect(fn.body.stmts).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parser — expressions', () => {
|
||||
it('respects binary precedence (* before +)', () => {
|
||||
const { program } = parse('let a = 1 + 2 * 3;');
|
||||
const e = (program.stmts[0] as LetStmt).init as BinaryExpr;
|
||||
expect(e.kind).toBe('Binary');
|
||||
expect(e.op).toBe('+');
|
||||
const right = e.right as BinaryExpr;
|
||||
expect(right.op).toBe('*');
|
||||
});
|
||||
|
||||
it('parses method chains (member + call + index)', () => {
|
||||
const { program, errors } = parse('let x = ctx.request.body["k"];');
|
||||
expect(errors).toEqual([]);
|
||||
const init = (program.stmts[0] as LetStmt).init!;
|
||||
expect(init.kind).toBe('Index');
|
||||
});
|
||||
|
||||
it('parses log::info("hi") as Call(Member(Ident(log), "info"), ["hi"])', () => {
|
||||
const { program, errors } = parse('log::info("hi");');
|
||||
expect(errors).toEqual([]);
|
||||
const stmt = program.stmts[0] as ExprStmt;
|
||||
expect(stmt.expr.kind).toBe('Call');
|
||||
});
|
||||
|
||||
it('parses object-map literal #{} with keys', () => {
|
||||
const { program, errors } = parse('let o = #{ a: 1, b: 2 };');
|
||||
expect(errors).toEqual([]);
|
||||
const init = (program.stmts[0] as LetStmt).init!;
|
||||
expect(init.kind).toBe('ObjectMap');
|
||||
if (init.kind === 'ObjectMap') {
|
||||
expect(init.entries.map((e) => e.key)).toEqual(['a', 'b']);
|
||||
}
|
||||
});
|
||||
|
||||
it('parses array literals', () => {
|
||||
const { program, errors } = parse('let xs = [1, 2, 3];');
|
||||
expect(errors).toEqual([]);
|
||||
expect((program.stmts[0] as LetStmt).init!.kind).toBe('Array');
|
||||
});
|
||||
|
||||
it('parses if-as-expression for let RHS', () => {
|
||||
const { program, errors } = parse('let x = if true { 1 } else { 2 };');
|
||||
expect(errors).toEqual([]);
|
||||
expect((program.stmts[0] as LetStmt).init!.kind).toBe('IfExpr');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parser — control flow', () => {
|
||||
it('parses while, for, loop', () => {
|
||||
const { errors: e1 } = parse('while true { break; }');
|
||||
const { errors: e2 } = parse('for x in [1, 2] { x }');
|
||||
const { errors: e3 } = parse('loop { break; }');
|
||||
expect(e1).toEqual([]);
|
||||
expect(e2).toEqual([]);
|
||||
expect(e3).toEqual([]);
|
||||
});
|
||||
|
||||
it('parses if / else if / else chains', () => {
|
||||
const { program, errors } = parse(`
|
||||
if a { 1 } else if b { 2 } else { 3 }
|
||||
`);
|
||||
expect(errors).toEqual([]);
|
||||
const stmt = program.stmts[0] as ExprStmt;
|
||||
expect(stmt.expr.kind).toBe('IfExpr');
|
||||
});
|
||||
|
||||
it('parses try / catch with binding', () => {
|
||||
const { errors } = parse('try { foo(); } catch (e) { log::error(e); }');
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parser — error tolerance', () => {
|
||||
it('is lenient about missing semicolons between statements', () => {
|
||||
// The parser accepts implicit statement separation so completions
|
||||
// remain useful while the user is still typing. Both bindings
|
||||
// should land in the program regardless of the missing `;`.
|
||||
const { program } = parse('let x = 1 let y = 2;');
|
||||
const names = program.stmts.flatMap((s) => (s.kind === 'Let' ? [s.name] : []));
|
||||
expect(names).toContain('x');
|
||||
expect(names).toContain('y');
|
||||
});
|
||||
|
||||
it('does not loop forever on garbage', () => {
|
||||
const { errors } = parse('@@@ ### }}}');
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('recovers after a bad statement and parses the next one', () => {
|
||||
const { program } = parse('let = ; let y = 2;');
|
||||
const y = program.stmts.find((s) => s.kind === 'Let' && s.name === 'y');
|
||||
expect(y).toBeDefined();
|
||||
});
|
||||
});
|
||||
597
dashboard/src/lib/rhai/parser.ts
Normal file
597
dashboard/src/lib/rhai/parser.ts
Normal file
@@ -0,0 +1,597 @@
|
||||
// Parser for the dashboard's Rhai mode.
|
||||
//
|
||||
// Recursive descent for statements, Pratt precedence climbing for
|
||||
// expressions. Error-tolerant: on unexpected input the parser records an
|
||||
// error, resyncs to the next `;` or matching `}`, and keeps going. The
|
||||
// AST it returns is best-effort — partial trees are fine; callers
|
||||
// (autocomplete, goto-def) tolerate gaps.
|
||||
|
||||
import type {
|
||||
BlockExpr,
|
||||
Expr,
|
||||
FnDecl,
|
||||
IfExpr,
|
||||
ObjectMapEntry,
|
||||
Param,
|
||||
ParseError,
|
||||
ParseResult,
|
||||
Stmt,
|
||||
SwitchArm
|
||||
} from './ast';
|
||||
import { tokenize, type Token, type TokenKind } from './lexer';
|
||||
|
||||
export function parse(source: string): ParseResult {
|
||||
const { tokens, comments } = tokenize(source);
|
||||
const p = new Parser(source, tokens);
|
||||
const program = p.parseProgram();
|
||||
return { source, program, errors: p.errors, comments };
|
||||
}
|
||||
|
||||
// Precedence levels for binary operators. Higher binds tighter. Assignment
|
||||
// is special-cased outside the binary chain because it's right-associative
|
||||
// and only legal at the top of an expression.
|
||||
const BINARY_PRECEDENCE: Record<string, number> = {
|
||||
'??': 1,
|
||||
'||': 2,
|
||||
'&&': 3,
|
||||
'==': 4,
|
||||
'!=': 4,
|
||||
'<': 5,
|
||||
'<=': 5,
|
||||
'>': 5,
|
||||
'>=': 5,
|
||||
'|': 6,
|
||||
'^': 7,
|
||||
'&': 8,
|
||||
'<<': 9,
|
||||
'>>': 9,
|
||||
'+': 10,
|
||||
'-': 10,
|
||||
'*': 11,
|
||||
'/': 11,
|
||||
'%': 11,
|
||||
'..': 12,
|
||||
'..=': 12
|
||||
};
|
||||
|
||||
const ASSIGN_OPS = new Set(['=', '+=', '-=', '*=', '/=', '%=', '??=']);
|
||||
const UNARY_OPS = new Set(['!', '-', '+', '~']);
|
||||
|
||||
class Parser {
|
||||
pos = 0;
|
||||
errors: ParseError[] = [];
|
||||
constructor(
|
||||
private source: string,
|
||||
private tokens: Token[]
|
||||
) {}
|
||||
|
||||
// -------------------------------------------------------------------- nav
|
||||
|
||||
private peek(offset = 0): Token {
|
||||
return this.tokens[Math.min(this.pos + offset, this.tokens.length - 1)];
|
||||
}
|
||||
|
||||
private advance(): Token {
|
||||
const t = this.tokens[this.pos];
|
||||
if (this.pos < this.tokens.length - 1) this.pos++;
|
||||
return t;
|
||||
}
|
||||
|
||||
private match(kind: TokenKind, text?: string): boolean {
|
||||
const t = this.peek();
|
||||
if (t.kind !== kind) return false;
|
||||
if (text !== undefined && t.text !== text) return false;
|
||||
this.advance();
|
||||
return true;
|
||||
}
|
||||
|
||||
private check(kind: TokenKind, text?: string): boolean {
|
||||
const t = this.peek();
|
||||
if (t.kind !== kind) return false;
|
||||
if (text !== undefined && t.text !== text) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private expect(kind: TokenKind, text?: string): Token {
|
||||
const t = this.peek();
|
||||
if (t.kind === kind && (text === undefined || t.text === text)) {
|
||||
return this.advance();
|
||||
}
|
||||
const desc = text !== undefined ? `'${text}'` : kind.toLowerCase();
|
||||
this.error(t, `expected ${desc}, got '${t.text || 'end of input'}'`);
|
||||
// Return the token without consuming so the caller's parent can
|
||||
// still resync at its own boundary.
|
||||
return t;
|
||||
}
|
||||
|
||||
private error(at: Token, message: string): void {
|
||||
this.errors.push({ start: at.start, end: at.end, message });
|
||||
}
|
||||
|
||||
// Resync to the next statement boundary inside the current block. Used
|
||||
// when a statement fails to parse — we drop tokens until we either land
|
||||
// on `;` (consumed) or `}` / EOF (left for the caller).
|
||||
private resyncStmt(): void {
|
||||
let depth = 0;
|
||||
while (true) {
|
||||
const t = this.peek();
|
||||
if (t.kind === 'EOF') return;
|
||||
if (t.kind === 'Punct') {
|
||||
if (t.text === '{' || t.text === '(' || t.text === '[') depth++;
|
||||
else if (t.text === '}' || t.text === ')' || t.text === ']') {
|
||||
if (depth === 0) return;
|
||||
depth--;
|
||||
} else if (depth === 0 && t.text === ';') {
|
||||
this.advance();
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.advance();
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ top level
|
||||
|
||||
parseProgram(): BlockExpr {
|
||||
const start = this.peek().start;
|
||||
const stmts: Stmt[] = [];
|
||||
while (this.peek().kind !== 'EOF') {
|
||||
const before = this.pos;
|
||||
const stmt = this.parseStmt();
|
||||
if (stmt) stmts.push(stmt);
|
||||
else if (this.pos === before) {
|
||||
// No forward progress — drop a token to avoid an infinite loop.
|
||||
this.advance();
|
||||
}
|
||||
}
|
||||
const last = this.tokens[this.tokens.length - 1];
|
||||
return { kind: 'BlockExpr', start, end: last.end, stmts };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- statements
|
||||
|
||||
private parseStmt(): Stmt | null {
|
||||
const t = this.peek();
|
||||
|
||||
if (t.kind === 'Keyword') {
|
||||
switch (t.text) {
|
||||
case 'let':
|
||||
return this.parseLetOrConst('Let');
|
||||
case 'const':
|
||||
return this.parseLetOrConst('Const');
|
||||
case 'fn':
|
||||
return this.parseFnDecl();
|
||||
case 'return':
|
||||
return this.parseReturn();
|
||||
case 'while':
|
||||
return this.parseWhile();
|
||||
case 'loop':
|
||||
return this.parseLoop();
|
||||
case 'for':
|
||||
return this.parseFor();
|
||||
case 'break': {
|
||||
this.advance();
|
||||
const semi = this.match('Punct', ';');
|
||||
return { kind: 'Break', start: t.start, end: semi ? t.end + 1 : t.end };
|
||||
}
|
||||
case 'continue': {
|
||||
this.advance();
|
||||
const semi = this.match('Punct', ';');
|
||||
return { kind: 'Continue', start: t.start, end: semi ? t.end + 1 : t.end };
|
||||
}
|
||||
case 'try':
|
||||
return this.parseTry();
|
||||
}
|
||||
}
|
||||
|
||||
// Stray semicolons are no-ops; consume and try again.
|
||||
if (this.match('Punct', ';')) return null;
|
||||
|
||||
// Expression statement (also covers if/switch/block-as-stmt because
|
||||
// those parse as expressions).
|
||||
const expr = this.tryParseExpr();
|
||||
if (!expr) {
|
||||
const bad = this.peek();
|
||||
this.error(bad, `unexpected '${bad.text || 'end of input'}'`);
|
||||
this.resyncStmt();
|
||||
return null;
|
||||
}
|
||||
const semi = this.match('Punct', ';');
|
||||
return {
|
||||
kind: 'ExprStmt',
|
||||
start: expr.start,
|
||||
end: semi ? this.tokens[this.pos - 1].end : expr.end,
|
||||
expr,
|
||||
semi
|
||||
};
|
||||
}
|
||||
|
||||
private parseLetOrConst(kind: 'Let' | 'Const'): Stmt {
|
||||
const start = this.advance().start; // let|const
|
||||
const nameTok = this.expect('Ident');
|
||||
const name = nameTok.text;
|
||||
const nameRange = { start: nameTok.start, end: nameTok.end };
|
||||
let init: Expr | null = null;
|
||||
if (this.match('Operator', '=')) {
|
||||
init = this.tryParseExpr() ?? null;
|
||||
}
|
||||
const semi = this.match('Punct', ';');
|
||||
const end = semi ? this.tokens[this.pos - 1].end : init ? init.end : nameTok.end;
|
||||
return { kind, start, end, name, nameRange, init } as Stmt;
|
||||
}
|
||||
|
||||
private parseFnDecl(): FnDecl {
|
||||
const start = this.advance().start; // fn
|
||||
const nameTok = this.expect('Ident');
|
||||
this.expect('Punct', '(');
|
||||
const params: Param[] = [];
|
||||
while (!this.check('Punct', ')') && this.peek().kind !== 'EOF') {
|
||||
const pTok = this.expect('Ident');
|
||||
params.push({ name: pTok.text, start: pTok.start, end: pTok.end });
|
||||
if (!this.match('Punct', ',')) break;
|
||||
}
|
||||
this.expect('Punct', ')');
|
||||
const body = this.parseBlockExpr();
|
||||
return {
|
||||
kind: 'FnDecl',
|
||||
start,
|
||||
end: body.end,
|
||||
name: nameTok.text,
|
||||
nameRange: { start: nameTok.start, end: nameTok.end },
|
||||
params,
|
||||
body
|
||||
};
|
||||
}
|
||||
|
||||
private parseReturn(): Stmt {
|
||||
const start = this.advance().start; // return
|
||||
let value: Expr | null = null;
|
||||
if (!this.check('Punct', ';') && !this.check('Punct', '}') && this.peek().kind !== 'EOF') {
|
||||
value = this.tryParseExpr() ?? null;
|
||||
}
|
||||
const semi = this.match('Punct', ';');
|
||||
const end = semi ? this.tokens[this.pos - 1].end : value ? value.end : start + 'return'.length;
|
||||
return { kind: 'Return', start, end, value };
|
||||
}
|
||||
|
||||
private parseWhile(): Stmt {
|
||||
const start = this.advance().start; // while
|
||||
const cond = this.tryParseExpr() ?? this.placeholderExpr();
|
||||
const body = this.parseBlockExpr();
|
||||
return { kind: 'While', start, end: body.end, cond, body };
|
||||
}
|
||||
|
||||
private parseLoop(): Stmt {
|
||||
const start = this.advance().start; // loop
|
||||
const body = this.parseBlockExpr();
|
||||
return { kind: 'Loop', start, end: body.end, body };
|
||||
}
|
||||
|
||||
private parseFor(): Stmt {
|
||||
const start = this.advance().start; // for
|
||||
const nameTok = this.expect('Ident');
|
||||
this.expect('Keyword', 'in');
|
||||
const iter = this.tryParseExpr() ?? this.placeholderExpr();
|
||||
const body = this.parseBlockExpr();
|
||||
return {
|
||||
kind: 'For',
|
||||
start,
|
||||
end: body.end,
|
||||
varName: nameTok.text,
|
||||
varRange: { start: nameTok.start, end: nameTok.end },
|
||||
iter,
|
||||
body
|
||||
};
|
||||
}
|
||||
|
||||
private parseTry(): Stmt {
|
||||
const start = this.advance().start; // try
|
||||
const body = this.parseBlockExpr();
|
||||
this.expect('Keyword', 'catch');
|
||||
let catchVar: string | null = null;
|
||||
let catchVarRange: { start: number; end: number } | null = null;
|
||||
if (this.match('Punct', '(')) {
|
||||
if (this.check('Ident')) {
|
||||
const id = this.advance();
|
||||
catchVar = id.text;
|
||||
catchVarRange = { start: id.start, end: id.end };
|
||||
}
|
||||
this.expect('Punct', ')');
|
||||
}
|
||||
const handler = this.parseBlockExpr();
|
||||
return { kind: 'Try', start, end: handler.end, body, catchVar, catchVarRange, handler };
|
||||
}
|
||||
|
||||
private parseBlockExpr(): BlockExpr {
|
||||
const openTok = this.peek();
|
||||
if (!this.match('Punct', '{')) {
|
||||
this.error(openTok, "expected '{'");
|
||||
return { kind: 'BlockExpr', start: openTok.start, end: openTok.start, stmts: [] };
|
||||
}
|
||||
const start = openTok.start;
|
||||
const stmts: Stmt[] = [];
|
||||
while (!this.check('Punct', '}') && this.peek().kind !== 'EOF') {
|
||||
const before = this.pos;
|
||||
const s = this.parseStmt();
|
||||
if (s) stmts.push(s);
|
||||
else if (this.pos === before) this.advance();
|
||||
}
|
||||
const closeTok = this.peek();
|
||||
this.match('Punct', '}');
|
||||
return { kind: 'BlockExpr', start, end: closeTok.end, stmts };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- expressions
|
||||
|
||||
private tryParseExpr(): Expr | null {
|
||||
const t = this.peek();
|
||||
if (t.kind === 'EOF' || (t.kind === 'Punct' && (t.text === ';' || t.text === '}' || t.text === ')' || t.text === ']' || t.text === ','))) {
|
||||
return null;
|
||||
}
|
||||
return this.parseAssign();
|
||||
}
|
||||
|
||||
private parseAssign(): Expr {
|
||||
const left = this.parseBinary(0);
|
||||
const t = this.peek();
|
||||
if (t.kind === 'Operator' && ASSIGN_OPS.has(t.text)) {
|
||||
this.advance();
|
||||
const right = this.parseAssign();
|
||||
return { kind: 'Assign', start: left.start, end: right.end, op: t.text, target: left, value: right };
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private parseBinary(minPrec: number): Expr {
|
||||
let left = this.parseUnary();
|
||||
while (true) {
|
||||
const t = this.peek();
|
||||
if (t.kind !== 'Operator') break;
|
||||
const prec = BINARY_PRECEDENCE[t.text];
|
||||
if (prec === undefined || prec < minPrec) break;
|
||||
this.advance();
|
||||
const right = this.parseBinary(prec + 1);
|
||||
left = { kind: 'Binary', start: left.start, end: right.end, op: t.text, left, right };
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
private parseUnary(): Expr {
|
||||
const t = this.peek();
|
||||
if (t.kind === 'Operator' && UNARY_OPS.has(t.text)) {
|
||||
this.advance();
|
||||
const operand = this.parseUnary();
|
||||
return { kind: 'Unary', start: t.start, end: operand.end, op: t.text, operand };
|
||||
}
|
||||
return this.parsePostfix(this.parsePrimary());
|
||||
}
|
||||
|
||||
private parsePostfix(initial: Expr): Expr {
|
||||
let expr = initial;
|
||||
while (true) {
|
||||
const t = this.peek();
|
||||
if (t.kind === 'Punct' && t.text === '.') {
|
||||
this.advance();
|
||||
const prop = this.expect('Ident');
|
||||
expr = {
|
||||
kind: 'Member',
|
||||
start: expr.start,
|
||||
end: prop.end,
|
||||
object: expr,
|
||||
property: prop.text,
|
||||
propertyRange: { start: prop.start, end: prop.end }
|
||||
};
|
||||
} else if (t.kind === 'Punct' && t.text === '(') {
|
||||
this.advance();
|
||||
const args: Expr[] = [];
|
||||
while (!this.check('Punct', ')') && this.peek().kind !== 'EOF') {
|
||||
const a = this.tryParseExpr();
|
||||
if (!a) break;
|
||||
args.push(a);
|
||||
if (!this.match('Punct', ',')) break;
|
||||
}
|
||||
const close = this.peek();
|
||||
this.expect('Punct', ')');
|
||||
expr = { kind: 'Call', start: expr.start, end: close.end, callee: expr, args };
|
||||
} else if (t.kind === 'Punct' && t.text === '[') {
|
||||
this.advance();
|
||||
const idx = this.tryParseExpr() ?? this.placeholderExpr();
|
||||
const close = this.peek();
|
||||
this.expect('Punct', ']');
|
||||
expr = { kind: 'Index', start: expr.start, end: close.end, object: expr, index: idx };
|
||||
} else if (t.kind === 'Operator' && t.text === '::') {
|
||||
// Namespace path: treat `log::info` as a Member chain on an
|
||||
// Ident so completion and lookup can walk the same shape.
|
||||
this.advance();
|
||||
const next = this.expect('Ident');
|
||||
expr = {
|
||||
kind: 'Member',
|
||||
start: expr.start,
|
||||
end: next.end,
|
||||
object: expr,
|
||||
property: next.text,
|
||||
propertyRange: { start: next.start, end: next.end }
|
||||
};
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return expr;
|
||||
}
|
||||
|
||||
private parsePrimary(): Expr {
|
||||
const t = this.peek();
|
||||
|
||||
// Literals
|
||||
if (t.kind === 'Number') {
|
||||
this.advance();
|
||||
return { kind: 'Number', start: t.start, end: t.end, raw: t.text };
|
||||
}
|
||||
if (t.kind === 'String') {
|
||||
this.advance();
|
||||
const quote = t.text.charAt(0) === '`' ? '`' : '"';
|
||||
return { kind: 'String', start: t.start, end: t.end, quote, raw: t.text };
|
||||
}
|
||||
if (t.kind === 'Keyword') {
|
||||
if (t.text === 'true' || t.text === 'false') {
|
||||
this.advance();
|
||||
return { kind: 'Bool', start: t.start, end: t.end, value: t.text === 'true' };
|
||||
}
|
||||
if (t.text === 'null') {
|
||||
this.advance();
|
||||
return { kind: 'Null', start: t.start, end: t.end };
|
||||
}
|
||||
if (t.text === 'if') return this.parseIfExpr();
|
||||
if (t.text === 'switch') return this.parseSwitchExpr();
|
||||
if (t.text === 'fn') return this.parseFnExpr();
|
||||
}
|
||||
|
||||
// Identifier
|
||||
if (t.kind === 'Ident') {
|
||||
this.advance();
|
||||
return { kind: 'Ident', start: t.start, end: t.end, name: t.text };
|
||||
}
|
||||
|
||||
// Paren expression
|
||||
if (t.kind === 'Punct' && t.text === '(') {
|
||||
this.advance();
|
||||
const inner = this.tryParseExpr() ?? this.placeholderExpr();
|
||||
const close = this.peek();
|
||||
this.expect('Punct', ')');
|
||||
return { kind: 'Paren', start: t.start, end: close.end, expr: inner };
|
||||
}
|
||||
|
||||
// Array literal
|
||||
if (t.kind === 'Punct' && t.text === '[') {
|
||||
return this.parseArray();
|
||||
}
|
||||
|
||||
// Object-map literal: `#{`
|
||||
if (t.kind === 'Punct' && t.text === '#' && this.peek(1).kind === 'Punct' && this.peek(1).text === '{') {
|
||||
return this.parseObjectMap();
|
||||
}
|
||||
|
||||
// Block expression `{ ... }`
|
||||
if (t.kind === 'Punct' && t.text === '{') {
|
||||
return this.parseBlockExpr();
|
||||
}
|
||||
|
||||
this.error(t, `unexpected '${t.text || 'end of input'}'`);
|
||||
// Consume one token so we make forward progress, then return a
|
||||
// placeholder so the surrounding parser keeps its shape.
|
||||
this.advance();
|
||||
return this.placeholderExpr(t);
|
||||
}
|
||||
|
||||
private parseIfExpr(): IfExpr {
|
||||
const start = this.advance().start; // if
|
||||
const cond = this.tryParseExpr() ?? this.placeholderExpr();
|
||||
const thenB = this.parseBlockExpr();
|
||||
let else_: BlockExpr | IfExpr | null = null;
|
||||
if (this.match('Keyword', 'else')) {
|
||||
if (this.check('Keyword', 'if')) {
|
||||
else_ = this.parseIfExpr();
|
||||
} else {
|
||||
else_ = this.parseBlockExpr();
|
||||
}
|
||||
}
|
||||
const end = else_ ? else_.end : thenB.end;
|
||||
return { kind: 'IfExpr', start, end, cond, then: thenB, else_ };
|
||||
}
|
||||
|
||||
private parseSwitchExpr(): Expr {
|
||||
const start = this.advance().start; // switch
|
||||
const subject = this.tryParseExpr() ?? this.placeholderExpr();
|
||||
this.expect('Punct', '{');
|
||||
const arms: SwitchArm[] = [];
|
||||
while (!this.check('Punct', '}') && this.peek().kind !== 'EOF') {
|
||||
const armStart = this.peek().start;
|
||||
let pattern: Expr | null;
|
||||
if (this.check('Operator', '_') || (this.peek().kind === 'Ident' && this.peek().text === '_')) {
|
||||
this.advance();
|
||||
pattern = null;
|
||||
} else {
|
||||
pattern = this.tryParseExpr() ?? this.placeholderExpr();
|
||||
}
|
||||
let guard: Expr | null = null;
|
||||
if (this.match('Keyword', 'if')) {
|
||||
guard = this.tryParseExpr() ?? this.placeholderExpr();
|
||||
}
|
||||
this.expect('Operator', '=>');
|
||||
const value = this.tryParseExpr() ?? this.placeholderExpr();
|
||||
arms.push({ start: armStart, end: value.end, pattern, guard, value });
|
||||
if (!this.match('Punct', ',')) break;
|
||||
}
|
||||
const close = this.peek();
|
||||
this.expect('Punct', '}');
|
||||
return { kind: 'SwitchExpr', start, end: close.end, subject, arms };
|
||||
}
|
||||
|
||||
private parseFnExpr(): Expr {
|
||||
// `fn (params) { ... }` — anonymous function expression. Rare in
|
||||
// Rhai but legal; some scripts use it for callbacks.
|
||||
const start = this.advance().start; // fn
|
||||
this.expect('Punct', '(');
|
||||
const params: Param[] = [];
|
||||
while (!this.check('Punct', ')') && this.peek().kind !== 'EOF') {
|
||||
const pTok = this.expect('Ident');
|
||||
params.push({ name: pTok.text, start: pTok.start, end: pTok.end });
|
||||
if (!this.match('Punct', ',')) break;
|
||||
}
|
||||
this.expect('Punct', ')');
|
||||
const body = this.parseBlockExpr();
|
||||
return { kind: 'FnExpr', start, end: body.end, params, body };
|
||||
}
|
||||
|
||||
private parseArray(): Expr {
|
||||
const start = this.advance().start; // [
|
||||
const elements: Expr[] = [];
|
||||
while (!this.check('Punct', ']') && this.peek().kind !== 'EOF') {
|
||||
const e = this.tryParseExpr();
|
||||
if (!e) break;
|
||||
elements.push(e);
|
||||
if (!this.match('Punct', ',')) break;
|
||||
}
|
||||
const close = this.peek();
|
||||
this.expect('Punct', ']');
|
||||
return { kind: 'Array', start, end: close.end, elements };
|
||||
}
|
||||
|
||||
private parseObjectMap(): Expr {
|
||||
const start = this.advance().start; // #
|
||||
this.advance(); // {
|
||||
const entries: ObjectMapEntry[] = [];
|
||||
while (!this.check('Punct', '}') && this.peek().kind !== 'EOF') {
|
||||
const k = this.peek();
|
||||
let key: string;
|
||||
let keyRange: { start: number; end: number };
|
||||
if (k.kind === 'Ident' || k.kind === 'Keyword') {
|
||||
this.advance();
|
||||
key = k.text;
|
||||
keyRange = { start: k.start, end: k.end };
|
||||
} else if (k.kind === 'String') {
|
||||
this.advance();
|
||||
// Strip surrounding quotes for the key name (best-effort —
|
||||
// we don't decode escape sequences; this is only used for
|
||||
// completion labels).
|
||||
key = k.text.length >= 2 ? k.text.slice(1, -1) : k.text;
|
||||
keyRange = { start: k.start, end: k.end };
|
||||
} else {
|
||||
this.error(k, 'expected map key');
|
||||
break;
|
||||
}
|
||||
this.expect('Punct', ':');
|
||||
const value = this.tryParseExpr() ?? this.placeholderExpr();
|
||||
entries.push({ start: keyRange.start, end: value.end, key, keyRange, value });
|
||||
if (!this.match('Punct', ',')) break;
|
||||
}
|
||||
const close = this.peek();
|
||||
this.expect('Punct', '}');
|
||||
return { kind: 'ObjectMap', start, end: close.end, entries };
|
||||
}
|
||||
|
||||
private placeholderExpr(at?: Token): Expr {
|
||||
const t = at ?? this.peek();
|
||||
return { kind: 'Ident', start: t.start, end: t.start, name: '' };
|
||||
}
|
||||
}
|
||||
119
dashboard/src/lib/rhai/symbols.test.ts
Normal file
119
dashboard/src/lib/rhai/symbols.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parse } from './parser';
|
||||
import { buildSymbolTable } from './symbols';
|
||||
|
||||
function build(src: string) {
|
||||
const r = parse(src);
|
||||
return { ...r, table: buildSymbolTable(r) };
|
||||
}
|
||||
|
||||
describe('symbols — declarations and usages', () => {
|
||||
it('captures let declarations', () => {
|
||||
const { table } = build('let x = 1; x + 1;');
|
||||
const x = table.allDecls.find((d) => d.name === 'x')!;
|
||||
expect(x.kind).toBe('let');
|
||||
expect(table.usages.find((u) => u.name === 'x')!.resolved).toBe(x);
|
||||
});
|
||||
|
||||
it('records fn signatures for completion detail', () => {
|
||||
const { table } = build('fn process(order, user) { order }');
|
||||
const fn = table.allDecls.find((d) => d.name === 'process')!;
|
||||
expect(fn.kind).toBe('fn');
|
||||
expect(fn.signature).toBe('process(order, user)');
|
||||
});
|
||||
|
||||
it('hoists fn declarations: calls above the decl resolve', () => {
|
||||
const { table } = build('greet("world"); fn greet(s) { s }');
|
||||
const u = table.usages.find((u) => u.name === 'greet')!;
|
||||
expect(u.resolved?.kind).toBe('fn');
|
||||
});
|
||||
|
||||
it('function bodies do not see outer locals', () => {
|
||||
const { table } = build(`
|
||||
let outer = 1;
|
||||
fn f() { outer }
|
||||
`);
|
||||
const outerUse = table.usages.find((u) => u.name === 'outer')!;
|
||||
expect(outerUse.resolved).toBeNull();
|
||||
});
|
||||
|
||||
it('function bodies do see outer fn declarations', () => {
|
||||
const { table } = build(`
|
||||
fn helper() { 1 }
|
||||
fn caller() { helper() }
|
||||
`);
|
||||
const helperUse = table.usages.find((u) => u.name === 'helper' && u.range.start > 30)!;
|
||||
expect(helperUse.resolved?.kind).toBe('fn');
|
||||
});
|
||||
|
||||
it('captures function parameters in their body scope', () => {
|
||||
const { table } = build('fn f(a, b) { a + b }');
|
||||
const a = table.allDecls.find((d) => d.name === 'a')!;
|
||||
expect(a.kind).toBe('param');
|
||||
const useOfA = table.usages.find((u) => u.name === 'a')!;
|
||||
expect(useOfA.resolved).toBe(a);
|
||||
});
|
||||
|
||||
it('captures for-loop binders', () => {
|
||||
const { table } = build('for item in [1, 2, 3] { item }');
|
||||
const item = table.allDecls.find((d) => d.name === 'item')!;
|
||||
expect(item.kind).toBe('for');
|
||||
});
|
||||
|
||||
it('respects forward-declaration: cannot use a let before its decl', () => {
|
||||
const { table } = build('x; let x = 1;');
|
||||
const earlyUse = table.usages.find((u) => u.name === 'x' && u.range.start < 5)!;
|
||||
expect(earlyUse.resolved).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('symbols — object-literal field maps', () => {
|
||||
it('records fields of an object-map literal initializer', () => {
|
||||
const { table } = build('let order = #{ id: 1, total: 5 };');
|
||||
const order = table.allDecls.find((d) => d.name === 'order')!;
|
||||
expect(order.objectFields).toEqual(['id', 'total']);
|
||||
});
|
||||
|
||||
it('objectFieldsOf returns the set after the declaration', () => {
|
||||
const src = 'let order = #{ id: 1 }; order.id';
|
||||
const { table } = build(src);
|
||||
const afterDecl = src.indexOf('order.id') + 'order.'.length;
|
||||
expect(table.objectFieldsOf('order', afterDecl)).toEqual(['id']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('symbols — completion + navigation helpers', () => {
|
||||
it('scopeCompletions surfaces in-scope locals and hoisted fns', () => {
|
||||
const src = `
|
||||
let outer = 1;
|
||||
fn process(order) {
|
||||
order
|
||||
}
|
||||
`;
|
||||
const { table } = build(src);
|
||||
const insideFn = src.indexOf('order\n');
|
||||
const names = table.scopeCompletions(insideFn).map((d) => d.name);
|
||||
expect(names).toContain('order');
|
||||
expect(names).toContain('process');
|
||||
// outer is not visible from inside `fn process`.
|
||||
expect(names).not.toContain('outer');
|
||||
});
|
||||
|
||||
it('declOfUsageAt resolves a usage to its declaration', () => {
|
||||
const src = 'fn process(o) { o } process(1)';
|
||||
const { table } = build(src);
|
||||
const callPos = src.lastIndexOf('process');
|
||||
const d = table.declOfUsageAt(callPos)!;
|
||||
expect(d.name).toBe('process');
|
||||
expect(d.kind).toBe('fn');
|
||||
});
|
||||
|
||||
it('usagesOf collects declaration + every reference', () => {
|
||||
const src = 'fn process(o) { o } process(1); process(2);';
|
||||
const { table } = build(src);
|
||||
const fn = table.allDecls.find((d) => d.name === 'process')!;
|
||||
const all = table.usagesOf(fn);
|
||||
// 1 decl name + 2 call sites = 3 ranges
|
||||
expect(all).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
447
dashboard/src/lib/rhai/symbols.ts
Normal file
447
dashboard/src/lib/rhai/symbols.ts
Normal file
@@ -0,0 +1,447 @@
|
||||
// Symbol table built from the parsed AST.
|
||||
//
|
||||
// One walk produces everything the editor features need:
|
||||
// * declarations (let, const, fn, params, for-loop binders, catch binder)
|
||||
// * usages (every Ident reference, resolved by walking the scope chain)
|
||||
// * object-literal field maps (so `obj.` can suggest known keys)
|
||||
//
|
||||
// Resolution rules (matching Rhai):
|
||||
// * `fn` declarations live in the script-root scope regardless of where
|
||||
// they appear textually. They form a flat namespace; nested functions
|
||||
// are not allowed in standard Rhai.
|
||||
// * A function body is a fresh scope that does NOT inherit the enclosing
|
||||
// locals — Rhai's `fn` is a pure function, not a closure. It can
|
||||
// still see top-level `fn`s (call them) but not top-level `let`s.
|
||||
// * Blocks (if/while/loop/for/try) nest within their containing scope.
|
||||
// * `let`/`const` are visible only after their declaration site within
|
||||
// their scope.
|
||||
//
|
||||
// Known limit: object-literal field tracking is best-effort. We only
|
||||
// record fields at the literal-initialization site — `let o = #{ a: 1 };`.
|
||||
// Reassignments and member writes (`o.b = 2;`) don't update the field set.
|
||||
|
||||
import type {
|
||||
BlockExpr,
|
||||
Comment,
|
||||
Expr,
|
||||
ForStmt,
|
||||
FnDecl,
|
||||
IfExpr,
|
||||
ObjectMapExpr,
|
||||
ParseResult,
|
||||
Range,
|
||||
Stmt,
|
||||
SwitchExpr,
|
||||
TryStmt
|
||||
} from './ast';
|
||||
|
||||
export type DeclKind = 'let' | 'const' | 'fn' | 'param' | 'for' | 'catch';
|
||||
|
||||
export interface Decl {
|
||||
kind: DeclKind;
|
||||
name: string;
|
||||
nameRange: Range;
|
||||
// For `fn`: rendered signature like `process(order, user)`.
|
||||
signature?: string;
|
||||
// For `let`/`const` initialized to an object-map literal — the field
|
||||
// names at the literal site. Empty otherwise.
|
||||
objectFields?: string[];
|
||||
// Lexical visibility: the offset at which references can resolve to
|
||||
// this declaration. For `let`/`const` it's just past the declaration;
|
||||
// for `fn`, parameters, `for`, and `catch` binders it's the start of
|
||||
// the scope they belong to.
|
||||
visibleFrom: number;
|
||||
scope: Scope;
|
||||
}
|
||||
|
||||
export interface Usage {
|
||||
name: string;
|
||||
range: Range;
|
||||
scope: Scope;
|
||||
resolved: Decl | null;
|
||||
}
|
||||
|
||||
export interface Scope {
|
||||
id: number;
|
||||
kind: 'root' | 'fn' | 'block';
|
||||
// A scope's range covers the source span where its locals are
|
||||
// reachable. For the root scope this is the whole document.
|
||||
range: Range;
|
||||
parent: Scope | null;
|
||||
children: Scope[];
|
||||
decls: Decl[];
|
||||
}
|
||||
|
||||
export interface SymbolTable {
|
||||
root: Scope;
|
||||
allDecls: Decl[];
|
||||
usages: Usage[];
|
||||
// Public API used by the editor features.
|
||||
declAt(pos: number): Decl | null;
|
||||
declOfUsageAt(pos: number): Decl | null;
|
||||
usagesOf(decl: Decl): Range[];
|
||||
objectFieldsOf(name: string, atPos: number): string[];
|
||||
scopeCompletions(atPos: number): Decl[];
|
||||
}
|
||||
|
||||
export function buildSymbolTable(result: ParseResult): SymbolTable {
|
||||
const builder = new Builder(result);
|
||||
builder.walkProgram();
|
||||
builder.resolveUsages();
|
||||
return builder.finish();
|
||||
}
|
||||
|
||||
// Cosmetic helper: format a function's signature for the completion
|
||||
// `detail` field. Kept here so the symbol table is the single source of
|
||||
// truth for "how a `fn` shows up in the UI".
|
||||
export function renderFnSignature(fn: FnDecl): string {
|
||||
return `${fn.name}(${fn.params.map((p) => p.name).join(', ')})`;
|
||||
}
|
||||
|
||||
class Builder {
|
||||
allDecls: Decl[] = [];
|
||||
usages: Usage[] = [];
|
||||
root: Scope;
|
||||
private currentScope: Scope;
|
||||
private nextScopeId = 0;
|
||||
|
||||
constructor(private result: ParseResult) {
|
||||
const span = { start: 0, end: result.source.length };
|
||||
this.root = this.makeScope('root', span, null);
|
||||
this.currentScope = this.root;
|
||||
}
|
||||
|
||||
private makeScope(kind: Scope['kind'], range: Range, parent: Scope | null): Scope {
|
||||
const s: Scope = { id: this.nextScopeId++, kind, range, parent, children: [], decls: [] };
|
||||
if (parent) parent.children.push(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
private declare(d: Decl): void {
|
||||
this.currentScope.decls.push(d);
|
||||
this.allDecls.push(d);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- walk
|
||||
|
||||
walkProgram(): void {
|
||||
// First pass: hoist `fn` decls into the root scope so calls anywhere
|
||||
// in the file can resolve to them regardless of source order.
|
||||
for (const stmt of this.result.program.stmts) {
|
||||
if (stmt.kind === 'FnDecl') {
|
||||
this.declare({
|
||||
kind: 'fn',
|
||||
name: stmt.name,
|
||||
nameRange: stmt.nameRange,
|
||||
signature: renderFnSignature(stmt),
|
||||
visibleFrom: 0,
|
||||
scope: this.root
|
||||
});
|
||||
}
|
||||
}
|
||||
// Second pass: walk statements normally. Skip re-declaring the
|
||||
// already-hoisted fn names; just descend into their bodies.
|
||||
for (const stmt of this.result.program.stmts) this.walkStmt(stmt);
|
||||
}
|
||||
|
||||
private walkStmt(stmt: Stmt): void {
|
||||
switch (stmt.kind) {
|
||||
case 'Let':
|
||||
case 'Const': {
|
||||
if (stmt.init) this.walkExpr(stmt.init);
|
||||
const objectFields =
|
||||
stmt.init && stmt.init.kind === 'ObjectMap'
|
||||
? (stmt.init as ObjectMapExpr).entries.map((e) => e.key)
|
||||
: undefined;
|
||||
this.declare({
|
||||
kind: stmt.kind === 'Let' ? 'let' : 'const',
|
||||
name: stmt.name,
|
||||
nameRange: stmt.nameRange,
|
||||
objectFields,
|
||||
visibleFrom: stmt.nameRange.end,
|
||||
scope: this.currentScope
|
||||
});
|
||||
return;
|
||||
}
|
||||
case 'FnDecl': {
|
||||
const prev = this.currentScope;
|
||||
const fnScope = this.makeScope('fn', stmt.body, prev);
|
||||
this.currentScope = fnScope;
|
||||
for (const p of stmt.params) {
|
||||
this.declare({
|
||||
kind: 'param',
|
||||
name: p.name,
|
||||
nameRange: { start: p.start, end: p.end },
|
||||
visibleFrom: stmt.body.start,
|
||||
scope: fnScope
|
||||
});
|
||||
}
|
||||
for (const s of stmt.body.stmts) this.walkStmt(s);
|
||||
this.currentScope = prev;
|
||||
return;
|
||||
}
|
||||
case 'ExprStmt':
|
||||
this.walkExpr(stmt.expr);
|
||||
return;
|
||||
case 'Return':
|
||||
if (stmt.value) this.walkExpr(stmt.value);
|
||||
return;
|
||||
case 'While':
|
||||
this.walkExpr(stmt.cond);
|
||||
this.walkBlock(stmt.body);
|
||||
return;
|
||||
case 'Loop':
|
||||
this.walkBlock(stmt.body);
|
||||
return;
|
||||
case 'For':
|
||||
this.walkFor(stmt);
|
||||
return;
|
||||
case 'Try':
|
||||
this.walkTry(stmt);
|
||||
return;
|
||||
case 'Break':
|
||||
case 'Continue':
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private walkFor(stmt: ForStmt): void {
|
||||
this.walkExpr(stmt.iter);
|
||||
const prev = this.currentScope;
|
||||
const blockScope = this.makeScope('block', stmt.body, prev);
|
||||
this.currentScope = blockScope;
|
||||
this.declare({
|
||||
kind: 'for',
|
||||
name: stmt.varName,
|
||||
nameRange: stmt.varRange,
|
||||
visibleFrom: stmt.body.start,
|
||||
scope: blockScope
|
||||
});
|
||||
for (const s of stmt.body.stmts) this.walkStmt(s);
|
||||
this.currentScope = prev;
|
||||
}
|
||||
|
||||
private walkTry(stmt: TryStmt): void {
|
||||
this.walkBlock(stmt.body);
|
||||
const prev = this.currentScope;
|
||||
const handlerScope = this.makeScope('block', stmt.handler, prev);
|
||||
this.currentScope = handlerScope;
|
||||
if (stmt.catchVar && stmt.catchVarRange) {
|
||||
this.declare({
|
||||
kind: 'catch',
|
||||
name: stmt.catchVar,
|
||||
nameRange: stmt.catchVarRange,
|
||||
visibleFrom: stmt.handler.start,
|
||||
scope: handlerScope
|
||||
});
|
||||
}
|
||||
for (const s of stmt.handler.stmts) this.walkStmt(s);
|
||||
this.currentScope = prev;
|
||||
}
|
||||
|
||||
private walkBlock(block: BlockExpr): void {
|
||||
const prev = this.currentScope;
|
||||
const blockScope = this.makeScope('block', block, prev);
|
||||
this.currentScope = blockScope;
|
||||
for (const s of block.stmts) this.walkStmt(s);
|
||||
this.currentScope = prev;
|
||||
}
|
||||
|
||||
private walkExpr(expr: Expr): void {
|
||||
switch (expr.kind) {
|
||||
case 'Ident':
|
||||
if (expr.name) {
|
||||
this.usages.push({
|
||||
name: expr.name,
|
||||
range: { start: expr.start, end: expr.end },
|
||||
scope: this.currentScope,
|
||||
resolved: null
|
||||
});
|
||||
}
|
||||
return;
|
||||
case 'Number':
|
||||
case 'String':
|
||||
case 'Bool':
|
||||
case 'Null':
|
||||
return;
|
||||
case 'Call':
|
||||
this.walkExpr(expr.callee);
|
||||
for (const a of expr.args) this.walkExpr(a);
|
||||
return;
|
||||
case 'Member':
|
||||
this.walkExpr(expr.object);
|
||||
// We don't record the property as a usage — it's resolved
|
||||
// against the object's shape, not the lexical scope.
|
||||
return;
|
||||
case 'Index':
|
||||
this.walkExpr(expr.object);
|
||||
this.walkExpr(expr.index);
|
||||
return;
|
||||
case 'Unary':
|
||||
this.walkExpr(expr.operand);
|
||||
return;
|
||||
case 'Binary':
|
||||
this.walkExpr(expr.left);
|
||||
this.walkExpr(expr.right);
|
||||
return;
|
||||
case 'Assign':
|
||||
this.walkExpr(expr.target);
|
||||
this.walkExpr(expr.value);
|
||||
return;
|
||||
case 'Paren':
|
||||
this.walkExpr(expr.expr);
|
||||
return;
|
||||
case 'ObjectMap':
|
||||
for (const e of expr.entries) this.walkExpr(e.value);
|
||||
return;
|
||||
case 'Array':
|
||||
for (const e of expr.elements) this.walkExpr(e);
|
||||
return;
|
||||
case 'FnExpr': {
|
||||
const prev = this.currentScope;
|
||||
const fnScope = this.makeScope('fn', expr.body, prev);
|
||||
this.currentScope = fnScope;
|
||||
for (const p of expr.params) {
|
||||
this.declare({
|
||||
kind: 'param',
|
||||
name: p.name,
|
||||
nameRange: { start: p.start, end: p.end },
|
||||
visibleFrom: expr.body.start,
|
||||
scope: fnScope
|
||||
});
|
||||
}
|
||||
for (const s of expr.body.stmts) this.walkStmt(s);
|
||||
this.currentScope = prev;
|
||||
return;
|
||||
}
|
||||
case 'IfExpr':
|
||||
this.walkIf(expr);
|
||||
return;
|
||||
case 'SwitchExpr':
|
||||
this.walkSwitch(expr);
|
||||
return;
|
||||
case 'BlockExpr':
|
||||
this.walkBlock(expr);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private walkIf(expr: IfExpr): void {
|
||||
this.walkExpr(expr.cond);
|
||||
this.walkBlock(expr.then);
|
||||
if (expr.else_) {
|
||||
if (expr.else_.kind === 'IfExpr') this.walkIf(expr.else_);
|
||||
else this.walkBlock(expr.else_);
|
||||
}
|
||||
}
|
||||
|
||||
private walkSwitch(expr: SwitchExpr): void {
|
||||
this.walkExpr(expr.subject);
|
||||
for (const arm of expr.arms) {
|
||||
if (arm.pattern) this.walkExpr(arm.pattern);
|
||||
if (arm.guard) this.walkExpr(arm.guard);
|
||||
this.walkExpr(arm.value);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------- resolution
|
||||
|
||||
resolveUsages(): void {
|
||||
for (const u of this.usages) {
|
||||
u.resolved = this.resolveName(u.name, u.range.start, u.scope);
|
||||
}
|
||||
}
|
||||
|
||||
private resolveName(name: string, atPos: number, fromScope: Scope): Decl | null {
|
||||
let scope: Scope | null = fromScope;
|
||||
let crossedFn = false;
|
||||
while (scope) {
|
||||
for (const d of scope.decls) {
|
||||
if (d.name !== name) continue;
|
||||
// Function scopes don't see outer locals — only the root's
|
||||
// `fn` decls. So once we've crossed a fn boundary, accept
|
||||
// only `fn` declarations.
|
||||
if (crossedFn && d.kind !== 'fn') continue;
|
||||
if (d.visibleFrom <= atPos) return d;
|
||||
}
|
||||
if (scope.kind === 'fn') crossedFn = true;
|
||||
scope = scope.parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- finish
|
||||
|
||||
finish(): SymbolTable {
|
||||
const { allDecls, usages, root } = this;
|
||||
|
||||
const declAt = (pos: number): Decl | null => {
|
||||
let best: Decl | null = null;
|
||||
for (const d of allDecls) {
|
||||
if (pos >= d.nameRange.start && pos <= d.nameRange.end) {
|
||||
best = d;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
const findScopeAt = (pos: number, scope: Scope = root): Scope => {
|
||||
for (const c of scope.children) {
|
||||
if (pos >= c.range.start && pos <= c.range.end) {
|
||||
return findScopeAt(pos, c);
|
||||
}
|
||||
}
|
||||
return scope;
|
||||
};
|
||||
|
||||
const declOfUsageAt = (pos: number): Decl | null => {
|
||||
const direct = declAt(pos);
|
||||
if (direct) return direct;
|
||||
for (const u of usages) {
|
||||
if (pos >= u.range.start && pos <= u.range.end) return u.resolved;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const usagesOf = (decl: Decl): Range[] => {
|
||||
const out: Range[] = [{ start: decl.nameRange.start, end: decl.nameRange.end }];
|
||||
for (const u of usages) {
|
||||
if (u.resolved === decl) out.push({ start: u.range.start, end: u.range.end });
|
||||
}
|
||||
out.sort((a, b) => a.start - b.start);
|
||||
return out;
|
||||
};
|
||||
|
||||
const objectFieldsOf = (name: string, atPos: number): string[] => {
|
||||
const fromScope = findScopeAt(atPos);
|
||||
const d = (this as Builder).resolveName(name, atPos, fromScope);
|
||||
return d?.objectFields ?? [];
|
||||
};
|
||||
|
||||
const scopeCompletions = (atPos: number): Decl[] => {
|
||||
const seen = new Set<string>();
|
||||
const out: Decl[] = [];
|
||||
let scope: Scope | null = findScopeAt(atPos);
|
||||
let crossedFn = false;
|
||||
while (scope) {
|
||||
for (const d of scope.decls) {
|
||||
if (seen.has(d.name)) continue;
|
||||
if (crossedFn && d.kind !== 'fn') continue;
|
||||
if (d.visibleFrom > atPos) continue;
|
||||
seen.add(d.name);
|
||||
out.push(d);
|
||||
}
|
||||
if (scope.kind === 'fn') crossedFn = true;
|
||||
scope = scope.parent;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
return { root, allDecls, usages, declAt, declOfUsageAt, usagesOf, objectFieldsOf, scopeCompletions };
|
||||
}
|
||||
}
|
||||
|
||||
// Used by symbols.test.ts; harmless to export.
|
||||
export function commentsAt(comments: Comment[], pos: number): Comment | undefined {
|
||||
return comments.find((c) => pos >= c.start && pos < c.end);
|
||||
}
|
||||
15
dashboard/vitest.config.ts
Normal file
15
dashboard/vitest.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
// Vitest config for unit-testing the Rhai parser / symbol table /
|
||||
// formatter. Kept separate from vite.config.ts because the dev/build
|
||||
// pipeline doesn't depend on a test runner.
|
||||
//
|
||||
// Tests use explicit `import { describe, it, expect } from 'vitest'`
|
||||
// to keep globals out of the type environment.
|
||||
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/lib/rhai/**/*.test.ts'],
|
||||
environment: 'node'
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user