diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index a874c69..bfa7e22 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -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", diff --git a/dashboard/package.json b/dashboard/package.json index 6573f50..abb7fc2 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -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" diff --git a/dashboard/src/lib/rhai/ast.ts b/dashboard/src/lib/rhai/ast.ts new file mode 100644 index 0000000..bba0526 --- /dev/null +++ b/dashboard/src/lib/rhai/ast.ts @@ -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[]; +} diff --git a/dashboard/src/lib/rhai/index.ts b/dashboard/src/lib/rhai/index.ts new file mode 100644 index 0000000..6e825e8 --- /dev/null +++ b/dashboard/src/lib/rhai/index.ts @@ -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'; diff --git a/dashboard/src/lib/rhai/lexer.test.ts b/dashboard/src/lib/rhai/lexer.test.ts new file mode 100644 index 0000000..9d05f6d --- /dev/null +++ b/dashboard/src/lib/rhai/lexer.test.ts @@ -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'); + }); +}); diff --git a/dashboard/src/lib/rhai/lexer.ts b/dashboard/src/lib/rhai/lexer.ts new file mode 100644 index 0000000..79ff8c2 --- /dev/null +++ b/dashboard/src/lib/rhai/lexer.ts @@ -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); +} diff --git a/dashboard/src/lib/rhai/parser.test.ts b/dashboard/src/lib/rhai/parser.test.ts new file mode 100644 index 0000000..d706b15 --- /dev/null +++ b/dashboard/src/lib/rhai/parser.test.ts @@ -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(); + }); +}); diff --git a/dashboard/src/lib/rhai/parser.ts b/dashboard/src/lib/rhai/parser.ts new file mode 100644 index 0000000..77fc068 --- /dev/null +++ b/dashboard/src/lib/rhai/parser.ts @@ -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 = { + '??': 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: '' }; + } +} diff --git a/dashboard/src/lib/rhai/symbols.test.ts b/dashboard/src/lib/rhai/symbols.test.ts new file mode 100644 index 0000000..b653a48 --- /dev/null +++ b/dashboard/src/lib/rhai/symbols.test.ts @@ -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); + }); +}); diff --git a/dashboard/src/lib/rhai/symbols.ts b/dashboard/src/lib/rhai/symbols.ts new file mode 100644 index 0000000..e77dddc --- /dev/null +++ b/dashboard/src/lib/rhai/symbols.ts @@ -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(); + 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); +} diff --git a/dashboard/vitest.config.ts b/dashboard/vitest.config.ts new file mode 100644 index 0000000..af3c81a --- /dev/null +++ b/dashboard/vitest.config.ts @@ -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' + } +});