diff --git a/.gitignore b/.gitignore index 2e2444e..719f852 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ external node_modules syntaxes_to_json.sh.err tmp + +test \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 320698d..ce105ee 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -16,5 +16,8 @@ }, "[python]": { "editor.defaultFormatter": "ms-python.black-formatter" + }, + "[typescript]": { + "editor.defaultFormatter": "vscode.typescript-language-features" } } diff --git a/.vscodeignore b/.vscodeignore index c390487..17a361f 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -11,8 +11,6 @@ !server/out/completion.*.json !server/out/hover.*.json !server/out/signature.*.json -# Rollup native bindings -!server/node_modules/@rollup/rollup*/** !server/node_modules/sslc-emscripten-noderawfs/** diff --git a/client/src/extension.ts b/client/src/extension.ts index 430f9af..c8b6aae 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -70,6 +70,7 @@ export async function activate(context: ExtensionContext) { { scheme: "file", language: "weidu-tra" }, { scheme: "file", pattern: "**/*.tbaf" }, + { scheme: "file", pattern: "**/*.tssl" }, ], synchronize: { // Notify the server about file changes to '.clientrc files contained in the workspace diff --git a/package.json b/package.json index 312fc32..b5ca200 100644 --- a/package.json +++ b/package.json @@ -409,7 +409,7 @@ "scripts": { "vscode:prepublish": "pnpm esbuild-base-client --minify && pnpm esbuild-base-server --minify && pnpm esbuild-base-preview --minify", "esbuild-base-client": "esbuild ./client/src/extension.ts --bundle --outfile=client/out/extension.js --external:vscode --format=cjs --platform=node", - "esbuild-base-server": "esbuild ./server/src/server.ts --bundle --outfile=server/out/server.js --external:vscode --format=cjs --platform=node", + "esbuild-base-server": "esbuild ./server/src/server.ts --bundle --outfile=server/out/server.js --external:vscode --external:esbuild-wasm --format=cjs --platform=node", "esbuild-base-preview": "esbuild ./preview/src/index.ts --bundle --outfile=preview/out/index.js", "esbuild-client": "pnpm esbuild-base-client --sourcemap", "esbuild-server": "pnpm esbuild-base-server --sourcemap", diff --git a/server/package.json b/server/package.json index 51ba837..8b2f4a6 100644 --- a/server/package.json +++ b/server/package.json @@ -14,24 +14,19 @@ "url": "https://github.com/BGforgeNet/VScode-BGforge-MLS/server" }, "dependencies": { - "@rollup/plugin-typescript": "^12.1.2", "@supercharge/promise-pool": "^2.3.2", + "esbuild-wasm": "^0.24.2", "fast-glob": "^3.2.12", - "rollup": "^4.30.1", "sslc-emscripten-noderawfs": "https://github.com/sfall-team/sslc/releases/download/2025-06-18-01-40-04/wasm-emscripten-node-noderawfs.tar.gz", "strip-literal": "^1.0.0", + "ts-macros": "^2.6.2", "ts-morph": "^24.0.0", "tslib": "^2.8.1", "typescript": "^4.9.4", "vscode-languageserver": "8.1.0", "vscode-languageserver-textdocument": "^1.0.8", "vscode-uri": "*", - "yaml": "^2.2.2", - "@rollup/rollup-darwin-arm64": "^4.30.1", - "@rollup/rollup-darwin-x64": "^4.30.1", - "@rollup/rollup-freebsd-x64": "^4.30.1", - "@rollup/rollup-linux-x64-gnu": "^4.30.1", - "@rollup/rollup-win32-x64-msvc": "^4.30.1" + "yaml": "^2.2.2" }, "devDependencies": { "@types/vscode": "^1.69.1", diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml new file mode 100644 index 0000000..7d98c7d --- /dev/null +++ b/server/pnpm-lock.yaml @@ -0,0 +1,663 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@supercharge/promise-pool': + specifier: ^2.3.2 + version: 2.4.0 + esbuild-wasm: + specifier: ^0.24.2 + version: 0.24.2 + fast-glob: + specifier: ^3.2.12 + version: 3.3.3 + sslc-emscripten-noderawfs: + specifier: https://github.com/sfall-team/sslc/releases/download/2025-06-18-01-40-04/wasm-emscripten-node-noderawfs.tar.gz + version: https://github.com/sfall-team/sslc/releases/download/2025-06-18-01-40-04/wasm-emscripten-node-noderawfs.tar.gz + strip-literal: + specifier: ^1.0.0 + version: 1.3.0 + ts-macros: + specifier: ^2.6.2 + version: 2.6.2(typescript@4.9.5) + ts-morph: + specifier: ^24.0.0 + version: 24.0.0 + tslib: + specifier: ^2.8.1 + version: 2.8.1 + typescript: + specifier: ^4.9.4 + version: 4.9.5 + vscode-languageserver: + specifier: 8.1.0 + version: 8.1.0 + vscode-languageserver-textdocument: + specifier: ^1.0.8 + version: 1.0.12 + vscode-uri: + specifier: '*' + version: 3.1.0 + yaml: + specifier: ^2.2.2 + version: 2.8.1 + devDependencies: + '@types/vscode': + specifier: ^1.69.1 + version: 1.105.0 + '@vscode/test-electron': + specifier: ^2.2.1 + version: 2.5.2 + +packages: + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@supercharge/promise-pool@2.4.0': + resolution: {integrity: sha512-O9CMipBlq5OObdt1uKJGIzm9cdjpPWfj+a+Zw9EgWKxaMNHKC7EU7X9taj3H0EGQNLOSq2jAcOa3EzxlfHsD6w==} + engines: {node: '>=8'} + + '@ts-morph/common@0.25.0': + resolution: {integrity: sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg==} + + '@types/vscode@1.105.0': + resolution: {integrity: sha512-Lotk3CTFlGZN8ray4VxJE7axIyLZZETQJVWi/lYoUVQuqfRxlQhVOfoejsD2V3dVXPSbS15ov5ZyowMAzgUqcw==} + + '@vscode/test-electron@2.5.2': + resolution: {integrity: sha512-8ukpxv4wYe0iWMRQU18jhzJOHkeGKbnw7xWRX3Zw1WJA4cEKbHcmmLPdPrPtL6rhDcrlCZN+xKRpv09n4gRHYg==} + engines: {node: '>=16'} + + acorn@8.15.0: + resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + code-block-writer@13.0.3: + resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + esbuild-wasm@0.24.2: + resolution: {integrity: sha512-03/7Z1gD+ohDnScFztvI4XddTAbKVmMEzCvvkBpQdWKEXJ+73dTyeNrmdxP1Q0zpDMFjzUJwtK4rLjqwiHbzkw==} + engines: {node: '>=18'} + hasBin: true + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fastq@1.19.1: + resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + get-east-asian-width@1.4.0: + resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} + engines: {node: '>=18'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + jszip@3.10.1: + resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + + log-symbols@6.0.0: + resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + engines: {node: '>=18'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + ora@8.2.0: + resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} + engines: {node: '>=18'} + + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sslc-emscripten-noderawfs@https://github.com/sfall-team/sslc/releases/download/2025-06-18-01-40-04/wasm-emscripten-node-noderawfs.tar.gz: + resolution: {tarball: https://github.com/sfall-team/sslc/releases/download/2025-06-18-01-40-04/wasm-emscripten-node-noderawfs.tar.gz} + version: 1.0.0 + hasBin: true + + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + + strip-literal@1.3.0: + resolution: {integrity: sha512-PugKzOsyXpArk0yWmUwqOZecSO0GH0bPoctLcqNDH9J04pVW3lflYE0ujElBGTloevcxF5MofAOZ7C5l2b+wLg==} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-macros@2.6.2: + resolution: {integrity: sha512-ZzEn268Td/efdvgFptYS2Hh4k8fEihF9P2QFqwX9OzEwAhdWq0oyhD0nUH6xh+mXklPKQiGQySS2NyW79tG5eA==} + hasBin: true + peerDependencies: + typescript: 5.2.x || 5.3.x || 5.4.x || 5.5.x || 5.6.x + + ts-morph@24.0.0: + resolution: {integrity: sha512-2OAOg/Ob5yx9Et7ZX4CvTCc0UFoZHwLEJ+dpDPSUi5TgwwlTlX47w+iFRrEwzUZwYACjq83cgjS/Da50Ga37uw==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@4.9.5: + resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==} + engines: {node: '>=4.2.0'} + hasBin: true + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vscode-jsonrpc@8.1.0: + resolution: {integrity: sha512-6TDy/abTQk+zDGYazgbIPc+4JoXdwC8NHU9Pbn4UJP1fehUyZmM4RHp5IthX7A6L5KS30PRui+j+tbbMMMafdw==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.3: + resolution: {integrity: sha512-924/h0AqsMtA5yK22GgMtCYiMdCOtWTSGgUOkgEDX+wk2b0x4sAfLiO4NxBxqbiVtz7K7/1/RgVrVI0NClZwqA==} + + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.17.3: + resolution: {integrity: sha512-SYU4z1dL0PyIMd4Vj8YOqFvHu7Hz/enbWtpfnVbJHU4Nd1YNYx8u0ennumc6h48GQNeOLxmwySmnADouT/AuZA==} + + vscode-languageserver@8.1.0: + resolution: {integrity: sha512-eUt8f1z2N2IEUDBsKaNapkz7jl5QpskN2Y0G01T/ItMxBxw1fJwvtySGB9QMecatne8jFIWJGWI61dWjyTLQsw==} + hasBin: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + yaml@2.8.1: + resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + +snapshots: + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.19.1 + + '@supercharge/promise-pool@2.4.0': {} + + '@ts-morph/common@0.25.0': + dependencies: + minimatch: 9.0.5 + path-browserify: 1.0.1 + tinyglobby: 0.2.15 + + '@types/vscode@1.105.0': {} + + '@vscode/test-electron@2.5.2': + dependencies: + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + jszip: 3.10.1 + ora: 8.2.0 + semver: 7.7.3 + transitivePeerDependencies: + - supports-color + + acorn@8.15.0: {} + + agent-base@7.1.4: {} + + ansi-regex@6.2.2: {} + + balanced-match@1.0.2: {} + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + chalk@5.6.2: {} + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + code-block-writer@13.0.3: {} + + core-util-is@1.0.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + emoji-regex@10.6.0: {} + + esbuild-wasm@0.24.2: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fastq@1.19.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + get-east-asian-width@1.4.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + immediate@3.0.6: {} + + inherits@2.0.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-interactive@2.0.0: {} + + is-number@7.0.0: {} + + is-unicode-supported@1.3.0: {} + + is-unicode-supported@2.1.0: {} + + isarray@1.0.0: {} + + jszip@3.10.1: + dependencies: + lie: 3.3.0 + pako: 1.0.11 + readable-stream: 2.3.8 + setimmediate: 1.0.5 + + lie@3.3.0: + dependencies: + immediate: 3.0.6 + + log-symbols@6.0.0: + dependencies: + chalk: 5.6.2 + is-unicode-supported: 1.3.0 + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mimic-function@5.0.1: {} + + minimatch@9.0.5: + dependencies: + brace-expansion: 2.0.2 + + ms@2.1.3: {} + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + ora@8.2.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 7.2.0 + strip-ansi: 7.1.2 + + pako@1.0.11: {} + + path-browserify@1.0.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + process-nextick-args@2.0.1: {} + + queue-microtask@1.2.3: {} + + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + reusify@1.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-buffer@5.1.2: {} + + semver@7.7.3: {} + + setimmediate@1.0.5: {} + + signal-exit@4.1.0: {} + + sslc-emscripten-noderawfs@https://github.com/sfall-team/sslc/releases/download/2025-06-18-01-40-04/wasm-emscripten-node-noderawfs.tar.gz: {} + + stdin-discarder@0.2.2: {} + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.4.0 + strip-ansi: 7.1.2 + + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.2.2 + + strip-literal@1.3.0: + dependencies: + acorn: 8.15.0 + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-macros@2.6.2(typescript@4.9.5): + dependencies: + typescript: 4.9.5 + yargs-parser: 21.1.1 + + ts-morph@24.0.0: + dependencies: + '@ts-morph/common': 0.25.0 + code-block-writer: 13.0.3 + + tslib@2.8.1: {} + + typescript@4.9.5: {} + + util-deprecate@1.0.2: {} + + vscode-jsonrpc@8.1.0: {} + + vscode-languageserver-protocol@3.17.3: + dependencies: + vscode-jsonrpc: 8.1.0 + vscode-languageserver-types: 3.17.3 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.3: {} + + vscode-languageserver@8.1.0: + dependencies: + vscode-languageserver-protocol: 3.17.3 + + vscode-uri@3.1.0: {} + + yaml@2.8.1: {} + + yargs-parser@21.1.1: {} diff --git a/server/src/compile.ts b/server/src/compile.ts index 23d8093..54cef07 100644 --- a/server/src/compile.ts +++ b/server/src/compile.ts @@ -1,9 +1,11 @@ import * as fs from "fs"; import { TextDocument } from "vscode-languageserver-textdocument"; -import { conlog, isDirectory, tmpDir } from "./common"; +import * as path from "path"; +import { conlog, isDirectory, pathToUri, tmpDir } from "./common"; import * as fallout from "./fallout"; import { connection, getDocumentSettings } from "./server"; -import * as tbaf from "./tbaf"; +import * as tbaf from "./tbaf/index"; +import * as tssl from "./tssl"; import * as weidu from "./weidu"; /** Only these languages can be compiled */ @@ -70,7 +72,36 @@ export async function compile(uri: string, langId: string, interactive = false, } if (langId == "typescript") { - tbaf.compile(uri, text); + if (uri.toLowerCase().endsWith(".tbaf")) { + try { + const bafPath = await tbaf.compile(uri, text); + const bafName = path.basename(bafPath); + connection.window.showInformationMessage(`Transpiled to ${bafName}`); + // Chain BAF compilation if weidu and game path are configured + if (settings.weidu.path && settings.weidu.gamePath) { + const bafUri = pathToUri(bafPath); + const bafText = fs.readFileSync(bafPath, 'utf-8'); + weidu.compile(bafUri, settings.weidu, true, bafText); + } + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + connection.window.showErrorMessage(`TBAF: ${msg}`); + } + } + if (uri.toLowerCase().endsWith(".tssl")) { + try { + const sslPath = await tssl.compile(uri, text); + const sslName = path.basename(sslPath); + connection.window.showInformationMessage(`Transpiled to ${sslName}`); + // Chain SSL compilation + const sslUri = pathToUri(sslPath); + const sslText = fs.readFileSync(sslPath, 'utf-8'); + await fallout.compile(sslUri, settings.falloutSSL, true, sslText); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + connection.window.showErrorMessage(`TSSL: ${msg}`); + } + } return; } diff --git a/server/src/esbuild-utils.ts b/server/src/esbuild-utils.ts new file mode 100644 index 0000000..6f51dd4 --- /dev/null +++ b/server/src/esbuild-utils.ts @@ -0,0 +1,197 @@ +/** + * Shared esbuild utilities for TSSL and TBAF transpilers. + */ + +import * as esbuild from "esbuild-wasm"; +import { Project, SyntaxKind } from "ts-morph"; + +let esbuildInitialized = false; + +/** + * Initialize esbuild (singleton, safe to call multiple times). + */ +export async function ensureEsbuild(): Promise { + if (esbuildInitialized) return; + await esbuild.initialize({}); + esbuildInitialized = true; +} + +/** + * Clean up esbuild output by stripping marker prefix and fixing import aliases. + * + * esbuild renames identifiers when there are name collisions (e.g., See → See2). + * This function: + * 1. Strips everything before the marker (runtime helpers like __defProp, __name) + * 2. Builds alias map from import statements + * 3. Detects collision patterns (name2 → name22) + * 4. Uses original constants to restore esbuild-renamed identifiers + * 5. Renames identifiers back to originals + * 6. Removes import declarations + * + * @param code Bundled code from esbuild + * @param marker Marker string to find start of user code + * @param originalConstants Original constant names and values from source file (for restoring renamed vars) + * @returns Cleaned code + */ +export function cleanupEsbuildOutput(code: string, marker: string, originalConstants?: Map): string { + // Strip everything before marker + const markerIndex = code.indexOf(marker); + if (markerIndex !== -1) { + code = code.substring(markerIndex + marker.length).trimStart(); + } + + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile("esbuild-output.ts", code); + + // Build alias map from import statements + // import { See as See2 } → See2 should become See + const aliasMap = new Map(); + for (const importDecl of sourceFile.getImportDeclarations()) { + for (const named of importDecl.getNamedImports()) { + const alias = named.getAliasNode(); + if (alias) { + aliasMap.set(alias.getText(), named.getName()); + } + } + } + + // Detect esbuild's collision avoidance: if we have alias X→Y, and there's + // an identifier X2 (X + digit), it was the original that got renamed. + // e.g., import { See as See2 } causes bundled See2 to become See22 + // In this case: rename See22→See2, and DON'T rename See2→See + const allIdentifiers = new Set(); + sourceFile.getDescendantsOfKind(SyntaxKind.Identifier).forEach(id => { + allIdentifiers.add(id.getText()); + }); + + for (const [alias] of [...aliasMap]) { + for (const id of allIdentifiers) { + if (id.startsWith(alias) && id !== alias && /^\d+$/.test(id.slice(alias.length))) { + if (!aliasMap.has(id)) { + aliasMap.set(id, alias); + aliasMap.delete(alias); + } + } + } + } + + // Detect and fix esbuild's variable renaming due to name collisions. + // + // Problem: When TSSL defines a constant that also exists in folib imports, + // esbuild renames the local constant by appending a single digit to avoid collision. + // Example: `const DIK_F4 = 62` in TSSL + `DIK_F4` exported from folib + // → esbuild outputs `var DIK_F42 = 62` (appended '2') + // + // Solution: Use the original constants extracted from the TSSL source file + // (passed via originalConstants parameter) to identify and reverse these renames. + // + // Algorithm: + // 1. Build map of all var declarations in bundled code: name → value + // 2. For each var ending in a digit, strip the last char to get candidate original name + // 3. If originalConstants has that name with the SAME value, it's a rename → restore it + // + // Why this is robust: + // - Requires BOTH name pattern match AND exact value equality + // - Not just regex pattern matching - uses actual constant values from source + // - False positive scenario: original has `FOO=5` and `FOO2=5` (same value) + // This is rare and would require user to define two constants with same value + // where one name is the other plus a digit - unlikely in practice + if (originalConstants && originalConstants.size > 0) { + // Build map of var declarations in bundled code: name → initializer text + const varDecls = new Map(); + for (const stmt of sourceFile.getStatements()) { + if (stmt.getKind() === SyntaxKind.VariableStatement) { + const varStmt = stmt.asKind(SyntaxKind.VariableStatement); + if (!varStmt) continue; + for (const decl of varStmt.getDeclarationList().getDeclarations()) { + const name = decl.getName(); + const init = decl.getInitializer(); + if (init) { + varDecls.set(name, init.getText()); + } + } + } + } + + // Find renamed vars by matching against original constants + for (const [bundledName, bundledValue] of varDecls) { + // Only check names ending in digit (esbuild's rename pattern) + // Uses simple regex /\d$/ - just checks last char is 0-9 + if (!/\d$/.test(bundledName)) continue; + + // Strip last character (the digit esbuild added) + // Using slice, not regex - simple and predictable + const baseName = bundledName.slice(0, -1); + + // Match requires BOTH conditions: + // 1. baseName exists in original constants + // 2. Value is exactly the same (string comparison of initializer text) + // This prevents false positives from unrelated vars that happen to end in digits + if (originalConstants.get(baseName) === bundledValue && !aliasMap.has(bundledName)) { + aliasMap.set(bundledName, baseName); + } + } + } + + // Rename identifiers using AST (automatically skips strings) + // Sort by length (longest first) to avoid partial replacements + const sortedAliases = [...aliasMap.entries()].sort((a, b) => b[0].length - a[0].length); + for (const [alias, original] of sortedAliases) { + sourceFile.getDescendantsOfKind(SyntaxKind.Identifier) + .filter(id => id.getText() === alias) + .forEach(id => id.replaceWithText(original)); + } + + // Remove import declarations + sourceFile.getImportDeclarations().forEach(decl => decl.remove()); + + return sourceFile.getFullText(); +} + +/** + * Create an esbuild plugin that marks all modules as side-effect-free. + * Enables aggressive tree-shaking for transpilers with no JS runtime. + * https://github.com/evanw/esbuild/issues/1895 + */ +export function noSideEffectsPlugin(): esbuild.Plugin { + return { + name: "no-side-effects", + setup(build) { + build.onResolve({ filter: /.*/ }, async args => { + if (args.kind === 'entry-point') return null; + // Use pluginData to prevent infinite recursion + if (args.pluginData?.fromNoSideEffectsPlugin) return null; + const result = await build.resolve(args.path, { + resolveDir: args.resolveDir, + kind: args.kind, + pluginData: { fromNoSideEffectsPlugin: true }, + }); + if (result.errors.length > 0) return result; + return { ...result, sideEffects: false }; + }); + }, + }; +} + +/** + * Create an esbuild plugin that marks matching imports as external. + * + * @param patterns Array of regex patterns to match import paths + * @param name Plugin name for debugging + * @returns esbuild plugin + */ +export function externalModulesPlugin(patterns: RegExp[], name = "external-modules"): esbuild.Plugin { + return { + name, + setup(build) { + build.onResolve({ filter: /.*/ }, (args) => { + for (const pattern of patterns) { + if (pattern.test(args.path)) { + return { path: args.path, external: true }; + } + } + return null; + }); + }, + }; +} diff --git a/server/src/fallout.ts b/server/src/fallout.ts index c515be3..074afa2 100644 --- a/server/src/fallout.ts +++ b/server/src/fallout.ts @@ -669,7 +669,7 @@ export async function compile( }); if (returnCode === 0) { if (interactive) { - connection.window.showInformationMessage(`Successfully compiled ${baseName}.`); + connection.window.showInformationMessage(`Compiled ${baseName}.`); } } else { if (interactive) { @@ -700,7 +700,7 @@ export async function compile( } } else { if (interactive) { - connection.window.showInformationMessage(`Successfully compiled ${baseName}.`); + connection.window.showInformationMessage(`Compiled ${baseName}.`); } } sendDiagnostics(uri, stdout, tmpUri); diff --git a/server/src/tbaf.ts b/server/src/tbaf.ts deleted file mode 100644 index e407540..0000000 --- a/server/src/tbaf.ts +++ /dev/null @@ -1,957 +0,0 @@ -import * as fs from "fs"; -import * as path from "path"; -import { - ArrayLiteralExpression, - BinaryExpression, - Block, - CallExpression, - Expression, - ForOfStatement, - ForStatement, - FunctionDeclaration, - IfStatement, - Node, - ParenthesizedExpression, - Project, - ReturnStatement, - SourceFile, - SpreadElement, - SyntaxKind, - VariableDeclaration, - VariableDeclarationKind, - VariableDeclarationList -} from 'ts-morph'; -import { conlog, getRelPath2, tmpDir, uriToPath } from "./common"; -import { connection } from "./server"; - -import typescript from "@rollup/plugin-typescript"; -import { cwd } from "process"; -import { rollup } from 'rollup'; - -export const EXT_TBAF = ".tbaf"; - -/** - * Initial Rollup bundle is saved here. - */ -const TMP_BUNDLED = path.join(tmpDir, "tbaf-bundled.ts"); - -/** - * TS compiler refuses to recognize tbaf, so we have to copy. - * Located in the same directory as the file being compiled. - * Cannot be ".tmp.ts", TS/Rollup refuse that too. - */ -const TMP_COPIED = "_tmp.tbaf.ts"; - -/** - * Final TS code ends up here - */ -const TMP_FINAL = path.join(tmpDir, "tbaf-final.ts"); - -/** - * Convert TBAF to BAF. - * @param uri - * @returns - */ -export async function compile(uri: string, text: string) { - const filePath = uriToPath(uri); - let ext = path.parse(filePath).ext; - ext = ext.toLowerCase(); - if (ext != EXT_TBAF) { - conlog(`${uri} is not a .tbaf file, cannot process!`); - connection.window.showInformationMessage(`${uri} is not a .tbaf file, cannot process!`); - return; - } - - // Initialize the TypeScript project - // Read all files anew to avoid ts-morph caching. - const project = new Project(); - - await bundle(filePath, text); - - const sourceFile = project.addSourceFileAtPath(TMP_BUNDLED); - - // Apply transformations - applyTransformations(sourceFile); - // Save the transformed file to the specified output file - const finalTS = project.createSourceFile(TMP_FINAL, sourceFile.getText(), { overwrite: true }); - finalTS.saveSync(); - console.log(`\nTransformed code saved to ${TMP_FINAL}`); - - // Save to BAF file, same directory - const dirName = path.parse(filePath).dir; - const baseName = path.parse(filePath).name; - const bafName = path.join(dirName, `${baseName}.baf`); - const fileName = path.basename(filePath); - exportBAF(finalTS, bafName, fileName); - connection.window.showInformationMessage(`Transpiled to ${bafName}.`); -} - - -/** - * For tracking variable values in context - */ -type varsContext = Map; -/** - * Inline and unroll loops and other constructs. - * @param sourceFile The source file to modify. - */ -function inlineUnroll(sourceFile: SourceFile) { - const functionDeclarations = sourceFile.getFunctions(); - const variablesContext: varsContext = new Map(); // Track const declarations - - // Collect variables - sourceFile.forEachDescendant(node => { - - switch (node.getKind()) { - // Process array literals immediately. - case SyntaxKind.ArrayLiteralExpression: { - flattenSpreadForNode(node as ArrayLiteralExpression, variablesContext); - break; - } - // Collect const variable declarations. - case SyntaxKind.VariableDeclaration: { - const variableDeclaration = node as VariableDeclaration; - const parentDeclarationList = variableDeclaration.getParent() as VariableDeclarationList; - if (parentDeclarationList.getDeclarationKind() === VariableDeclarationKind.Const) { - const name = variableDeclaration.getName(); - let init = variableDeclaration.getInitializer(); - if (init) { - if (init.getKind() === SyntaxKind.ArrayLiteralExpression) { - // Flatten any spread elements, substituting identifiers from variablesContext. - flattenSpreadForNode(init as ArrayLiteralExpression, variablesContext); - } - const initializerText = getCleanInitializerText(init); - if (initializerText.includes("...")) { - console.log(`Skipping collection for ${name} because initializer still contains spread.`); - } else { - variablesContext.set(name, initializerText); - console.log(`Collected const variable: ${name} = ${initializerText}`); - } - } - } - break; - } - // Unroll for...of loops - case SyntaxKind.ForOfStatement: - unrollForOfLoop(node as ForOfStatement, variablesContext); - break; - // Unroll for loops - case SyntaxKind.ForStatement: - unrollForLoop(node as ForStatement, variablesContext); - break; - - // Handle function calls - case SyntaxKind.CallExpression: { - - const callExpr = node as CallExpression; - - // Apply variable substitution first - substituteVariables(callExpr, variablesContext); - - // Check if it's a local function and inline it - const functionName = callExpr.getExpression().getText(); - if (functionDeclarations.some(func => func.getName() === functionName)) { - inlineFunction(callExpr, functionDeclarations, variablesContext); - } - break; - } - - // Evaluate and replace spread expressions in arrays - case SyntaxKind.ArrayLiteralExpression: { - evaluateAndReplaceSpreadExpressions(node as ArrayLiteralExpression, variablesContext); - break; - } - - default: - break; - } - }); -} - -// Rebuild an initializer from an array literal (omitting comments) -function getCleanInitializerText(init: Expression): string { - if (init.getKind() === SyntaxKind.ArrayLiteralExpression) { - const arr = init as ArrayLiteralExpression; - return `[ ${arr.getElements().map(el => el.getText()).join(", ")} ]`; - } - return init.getText(); -} - - -/** - * Evaluates spread expressions in an array literal and replaces the node. - * @param arrayLiteral The array literal node to process. - * @param variablesContext Context to resolve variables. - */ -function evaluateAndReplaceSpreadExpressions(arrayLiteral: ArrayLiteralExpression, variablesContext: varsContext) { - const elements = arrayLiteral.getElements(); - - let evaluatedArray: string[] = []; - - elements.forEach(element => { - if (element.getKind() === SyntaxKind.SpreadElement) { - const spreadExpr = (element as SpreadElement).getExpression(); - - if (spreadExpr.getKind() === SyntaxKind.ArrayLiteralExpression) { - // Flatten inline array spreads - const spreadArray = (spreadExpr as ArrayLiteralExpression).getElements().map(e => e.getText()); - evaluatedArray.push(...spreadArray); - } else { - // Check if spread is a known variable - const spreadVarName = spreadExpr.getText(); - if (variablesContext.has(spreadVarName)) { - const resolvedValue = variablesContext.get(spreadVarName); - if (resolvedValue?.startsWith("[") && resolvedValue?.endsWith("]")) { - const parsedArray = JSON.parse(resolvedValue.replace(/'/g, '"')); - evaluatedArray.push(...parsedArray); - } else { - evaluatedArray.push(spreadVarName); // Keep as-is if not an array - } - } else { - evaluatedArray.push(element.getText()); // Keep unresolved spreads - } - } - } else { - evaluatedArray.push(element.getText()); - } - }); - - // Replace the original array expression with the evaluated array - const newArrayText = `[${evaluatedArray.join(", ")}]`; - arrayLiteral.replaceWithText(newArrayText); - - console.log(`Replaced array literal with: ${newArrayText}`); -} - -/** - * Substitute variables in non-local function calls using the provided variable context. - * @param callExpression The call expression to substitute variables in. - * @param vars The context of variable declarations. - */ -function substituteVariables(callExpression: CallExpression, vars: Map) { - callExpression.getArguments().forEach(arg => { - const argText = arg.getText(); - if (vars.has(argText)) { - const substitution = vars.get(argText)!; - console.log(`Substituting variable in argument: ${argText} -> ${substitution}`); - arg.replaceWithText(substitution); - } - }); -} - -function inlineFunction(callExpression: CallExpression, functionDeclarations: FunctionDeclaration[], vars: varsContext) { - const functionName = callExpression.getExpression().getText(); - console.log(`Processing function: ${functionName}`); - - const parent = callExpression.getParent(); - // Short-circuit if the function call is inverted with '!' - if (Node.isPrefixUnaryExpression(parent) && parent.getOperatorToken() === SyntaxKind.ExclamationToken) { - console.log(`Skipping inlining for inverted call: !${functionName}()`); - return; - } - - // Find the corresponding function declaration - const functionDecl = functionDeclarations.find(func => func.getName() === functionName); - if (!functionDecl) return; - - const parameters = functionDecl.getParameters(); - const args = callExpression.getArguments(); - - // Map parameters to arguments - const paramArgMap = new Map(); - parameters.forEach((param, index) => { - const paramName = param.getName(); - let argText = args[index]?.getText() || param.getInitializer()?.getText() || "undefined"; - - if (vars.has(argText)) argText = vars.get(argText)!; - paramArgMap.set(paramName, argText); - }); - - - // Handle boolean return statements - const functionBody = functionDecl.getBody()?.asKindOrThrow(SyntaxKind.Block); - if (!functionBody) return; - const statements = functionBody.getStatements(); - const returnStmt = statements.find(stmt => stmt.isKind(SyntaxKind.ReturnStatement)) as ReturnStatement | undefined; - if (returnStmt && isConditionContext(parent)) { - if (!parent) return; // Skip if not parent - let returnText = returnStmt.getExpression()?.getText() || "undefined"; - returnText = substituteParams(returnText, paramArgMap, vars); - - console.log(`return text is ${returnText}`); - if (needsParentheses(returnText)) returnText = `(${returnText})`; - parent.replaceWithText(parent.getText().replace(callExpression.getText(), returnText)); - console.log(`Replaced ${functionName}() inside a condition.`); - return; - } - - // Handle void functions - if (!returnStmt && parent?.isKind(SyntaxKind.ExpressionStatement)) { - let inlinedCode = statements.map(stmt => stmt.getText()).join("\n"); - inlinedCode = substituteParams(inlinedCode, paramArgMap, vars); - - parent.replaceWithText(inlinedCode); - console.log(`Replaced ${functionName}() with inlined code.`); - } -} - -/** - * Utility to substitute parameters and variables in the code. - */ -function substituteParams(code: string, paramArgMap: Map, vars: varsContext): string { - paramArgMap.forEach((arg, param) => { - const regex = new RegExp(`\\b${param}\\b`, "g"); - code = code.replace(regex, arg); - }); - vars.forEach((value, variable) => { - const regex = new RegExp(`\\b${variable}\\b`, "g"); - code = code.replace(regex, value); - }); - return code; -} - -/** - * Utility to determine if a return text needs parentheses. - */ -function needsParentheses(text: string): boolean { - return (text.includes("&&") || text.includes("||") && !(text.startsWith('(') && text.endsWith(')'))); -} - -/** - * Checks if the parent node is part of a condition (if statement, binary expression, etc.). - */ -function isConditionContext(parent: Node | undefined): boolean { - return !!( - parent && - (parent.isKind(SyntaxKind.IfStatement) || - parent.isKind(SyntaxKind.BinaryExpression) || - parent.isKind(SyntaxKind.PrefixUnaryExpression)) - ); -} - -/** - * Unroll a single for...of loop. - * @param forOfStatement The for...of statement to unroll. - * @param vars The context of variable declarations. - */ -function unrollForOfLoop(forOfStatement: ForOfStatement, vars: varsContext) { - // Get array expression (e.g., `players` in `for (const player of players)`) - let arrayExpression = forOfStatement.getExpression().getText(); - - // Resolve the array expression if it's a const variable - if (vars.has(arrayExpression)) { - arrayExpression = vars.get(arrayExpression)!; - } - - console.log("Array Expression:", arrayExpression); - - // Check if the resolved array expression is a literal array - const arrayLiteral = forOfStatement.getSourceFile().getDescendantsOfKind(SyntaxKind.ArrayLiteralExpression) - .find(literal => literal.getText() === arrayExpression); - - if (!arrayLiteral) { - console.log("Not a literal array, skipping..."); - return; - } - - // Get loop variable name (e.g., `target` in `for (const target of players)`) - const loopVariable = forOfStatement.getInitializer().getText().replace(/^const\s+/, ''); - console.log("Loop Variable:", loopVariable); - - // Get body statements inside the loop - const statement = forOfStatement.getStatement(); - const bodyStatements = statement.isKind(SyntaxKind.Block) - ? statement.getStatements() - : [statement]; - - console.log("Body Statements:"); - bodyStatements.forEach(stmt => console.log(stmt.getText())); - - // Unroll the loop - const unrolledStatements = arrayLiteral.getElements().map(element => { - return bodyStatements.map(statement => { - let statementText = statement.getText(); - - // Replace the loop variable with the indexed array element - statementText = statementText.replace(new RegExp(`\\b${loopVariable}\\b`, "g"), element.getText()); - - // Resolve any const variables used in the statement - vars.forEach((value, variable) => { - statementText = statementText.replace(new RegExp(`\\b${variable}\\b`, "g"), value); - }); - - return statementText; - }).join("\n"); - }); - - console.log("Unrolled Statements:"); - console.log(unrolledStatements.join("\n")); - - // Replace the original loop with unrolled statements - forOfStatement.replaceWithText(unrolledStatements.join("\n")); -} - -/** - * Bundle functions into temporary file with Rollup - */ -async function bundle(input: string, text: string) { - input = getRelPath2(cwd(), input); // Otherwise Rollup can't find includes - const tmpInput = path.join(path.dirname(input), TMP_COPIED); - fs.writeFileSync(tmpInput, text); - const bundle = await rollup({ - input: tmpInput, - external: id => /node_modules/.test(id), // Exclude node_modules - plugins: [ - typescript({ - declaration: false, - tslib: 'tslib', // Otherwise Rollup won't even start - target: "esnext", // So that Rollup doesn't change syntax - // include: "**/*.(ts|tbaf)", // Doesn't work, TS compiler refuses to recognize tbaf, so we have to copy. - }) - ] - }); - - await bundle.write({ - file: TMP_BUNDLED, - format: 'esm', - }); - - console.log('Bundling complete!'); -} - - -/** - * Convert all ELSE statement to IF statements with inverted conditions, like BAF needs - * @param sourceFile - */ -function convertElseToIf(sourceFile: SourceFile) { - console.log("Starting transformation on source file:", sourceFile.getFilePath()); - - // Traverse all descendants to find if-else statements - sourceFile.forEachDescendant((node) => { - if (node.isKind(SyntaxKind.IfStatement)) { - const ifStatement = node as IfStatement; - const elseStatement = ifStatement.getElseStatement(); - - console.log("Found if statement:", ifStatement.getText()); - if (elseStatement) { - console.log("Found else statement:", elseStatement.getText()); - - const ifCondition = ifStatement.getExpression().getText(); - console.log("Original condition:", ifCondition); - - // Create the original `if-0` block (unchanged `if` part) - const if0Block = `if (${ifCondition}) ${ifStatement.getThenStatement().getText()}`; - console.log("if-0 block:", if0Block); - - // Invert the `else` condition to create `if-1` - const invertedCondition = invertCondition(ifCondition); - console.log("Inverted condition for else block (if-1):", invertedCondition); - - const if1Block = `if (${invertedCondition}) ${elseStatement.getText()}`; - console.log("if-1 block:", if1Block); - - // Combine `if-0` and `if-1` blocks - const newIfBlock = `${if0Block}\n${if1Block}`; - console.log("Combined new if block (if-0 + if-1):\n", newIfBlock); - - // Replace the original if-else block with the new combined block - ifStatement.replaceWithText(newIfBlock); - console.log("Replaced original if-else block with new combined if block."); - } - } - }); - - console.log("Transformation completed."); -} - -/** - * Invert logical condition. Only supports simple conditions, without nesting. - * @param condition Logical condition from an IF statement - * @returns inverted condition text - */ -function invertCondition(condition: string): string { - console.log("Inverting condition:", condition); - - // Handle cases with '&&' and '||' (De Morgan's law) - if (condition.includes('&&')) { - return condition - .split('&&') - .map(part => `!(${part.trim()})`) - .join(' || '); - } - - if (condition.includes('||')) { - return condition - .split('||') - .map(part => `!(${part.trim()})`) - .join(' && '); - } - - // For simple conditions (without && or ||), just negate it - return `!(${condition.trim()})`; -} - -/** - * Flatten spread elements in an array literal node. - * If a spread element is a literal array or an identifier found in vars, - * its elements are inlined. - */ -function flattenSpreadForNode(arrayLiteral: ArrayLiteralExpression, vars?: Map) { - // Use flatMap to replace spread elements with their flattened items. - const flattened = arrayLiteral.getElements().flatMap((element) => { - if (element.getKind() === SyntaxKind.SpreadElement) { - const spreadExpr = (element as SpreadElement).getExpression(); - // Case 1: The spread expression is a literal array. - if (spreadExpr.getKind() === SyntaxKind.ArrayLiteralExpression) { - const innerArray = spreadExpr as ArrayLiteralExpression; - return innerArray.getElements().map((innerEl) => innerEl.getText()); - } - // Case 2: The spread expression is an identifier present in vars. - else if (spreadExpr.getKind() === SyntaxKind.Identifier && vars) { - const id = spreadExpr.getText(); - if (vars.has(id)) { - const literal = vars.get(id)!; - // Remove outer brackets and split by comma. - const inner = literal.slice(1, -1).trim(); - if (inner) { - return inner.split(",").map((s) => s.trim()).filter((s) => s); - } - } - } - // If no flattening possible, return the original text. - return [element.getText()]; - } - return [element.getText()]; - }); - - const newArrayText = `[ ${flattened.join(", ")} ]`; - // Only replace if the flattened version is different. - if (newArrayText !== arrayLiteral.getText()) { - console.log( - "Replacing array literal:", - arrayLiteral.getText(), - "with flattened version:", - newArrayText - ); - arrayLiteral.replaceWithText(newArrayText); - } -} - -/** - * Single function to flatten all nested if statements in the source file - * @param sourceFile - */ -function flattenIfStatements(sourceFile: SourceFile) { - // Iterate over all statements in the source file - sourceFile.getStatements().forEach(statement => { - if (statement.isKind(SyntaxKind.IfStatement)) { - const ifStatement = statement as IfStatement; - - // Flatten the nested if statements recursively - const flattenIf = (ifStatement: IfStatement, parentCondition = ""): string => { - const thenStatement = ifStatement.getThenStatement(); - const currentCondition = ifStatement.getExpression().getText(); - - const combinedCondition = parentCondition - ? `${parentCondition} && ${currentCondition}` - : currentCondition; - - const nestedIfs: string[] = []; - - if (thenStatement.getKind() === SyntaxKind.Block) { - const block = thenStatement as Block; - const blockStatements = block.getStatements(); - - blockStatements.forEach(statement => { - if (statement.getKind() === SyntaxKind.IfStatement) { - const nestedIf = statement as IfStatement; - // Recursively flatten nested if statements - nestedIfs.push(flattenIf(nestedIf, combinedCondition)); - } - }); - } - - if (nestedIfs.length === 0) { - return `if (${combinedCondition}) ${thenStatement.getText()}`; - } - - return nestedIfs.join("\n"); - }; - - // Call the inner flattening function and replace the original statement - const flattenedIf = flattenIf(ifStatement); - ifStatement.replaceWithText(flattenedIf); - } - }); -} - - -/** - * Unroll a simple for loop. - * @param forStatement The for loop to unroll. - * @param vars The context of variable declarations. - */ -function unrollForLoop(forStatement: ForStatement, vars: varsContext) { - // Get the loop initializer (e.g., `let i = 0`) - const initializer = forStatement.getInitializer(); - if (!initializer || !initializer.isKind(SyntaxKind.VariableDeclarationList)) { - console.log("Skipping complex initializer."); - return; - } - - const declarations = initializer.getDeclarations(); - if (declarations.length !== 1) { - console.log("Skipping multi-variable initializer."); - return; - } - - // Get the variable name and initial value - const loopVar = declarations[0].getName(); - let initialValue = declarations[0].getInitializer()?.getText() || "0"; - - // Resolve initial value from context if it's a variable - if (vars.has(initialValue)) { - - initialValue = vars.get(initialValue)!; - // We explicitly check var.has. Eslint is wrong. - } - - if (isNaN(Number(initialValue))) { - console.log(`Skipping non-numeric initializer: ${initialValue}`); - return; - } - - let currentValue = Number(initialValue); - - // Get the loop condition (e.g., `i < 10`) - const condition = forStatement.getCondition(); - if (!condition) { - console.log("Skipping loop with no condition."); - return; - } - - // Ensure the loop boundary is numeric - if (Node.isBinaryExpression(condition)) { - const conditionValue = condition.getRight().getText(); - if (isNaN(Number(conditionValue))) { - console.log(`Skipping loop with non-numeric boundary: ${conditionValue}`); - return; - } - } else { - console.log("Skipping loop with unsupported condition type."); - return; - } - - // Get the incrementor (e.g., `i++`, `i += 2`) - const incrementor = forStatement.getIncrementor(); - if (!incrementor) { - console.log("Skipping loop with no incrementor."); - return; - } - - const incrementText = incrementor.getText(); - let incrementValue = 1; - - if (incrementText.includes("++")) { - incrementValue = 1; - } else if (incrementText.includes("--")) { - incrementValue = -1; - } else if (incrementText.includes("+=")) { - incrementValue = Number(incrementText.split("+=")[1]); - } else if (incrementText.includes("-=")) { - incrementValue = -Number(incrementText.split("-=")[1]); - } else { - console.log("Skipping unsupported incrementor."); - return; - } - - // Get body statements - const statement = forStatement.getStatement(); - const bodyStatements = statement.isKind(SyntaxKind.Block) - ? statement.getStatements() - : [statement]; - - console.log("Unrolling loop:", forStatement.getText()); - console.log("Loop Variable:", loopVar); - console.log("Initial Value:", currentValue); - console.log("Condition:", condition.getText()); - console.log("Incrementor:", incrementText); - - // Generate unrolled statements - const unrolledStatements: string[] = []; - - // Evaluate the loop condition and unroll - while (evaluateCondition(condition.getText(), loopVar, currentValue)) { - bodyStatements.forEach(statement => { - let statementText = statement.getText(); - - // Replace the loop variable with its current value - statementText = statementText.replace(new RegExp(`\\b${loopVar}\\b`, "g"), currentValue.toString()); - - // Resolve any const variables in the statement - vars.forEach((value, variable) => { - statementText = statementText.replace(new RegExp(`\\b${variable}\\b`, "g"), value); - }); - - unrolledStatements.push(statementText); - }); - - // Increment the loop variable - currentValue += incrementValue; - } - - console.log("Unrolled Statements:"); - console.log(unrolledStatements.join("\n")); - - // Replace the original loop with unrolled statements - forStatement.replaceWithText(unrolledStatements.join("\n")); -} - -/** - * Evaluate the loop condition by replacing the loop variable with its current value. - * @param condition The loop condition as a string. - * @param loopVar The loop variable. - * @param currentValue The current value of the loop variable. - * @returns Whether the condition evaluates to true. - */ -function evaluateCondition(condition: string, loopVar: string, currentValue: number): boolean { - const sanitizedCondition = condition.replace(new RegExp(`\\b${loopVar}\\b`, "g"), currentValue.toString()); - try { - // Create a new function to evaluate the condition - const fn = new Function(`return (${sanitizedCondition});`); - return fn(); - } catch (error) { - console.error("Error evaluating condition:", sanitizedCondition, error); - return false; - } -} - - -/** - * Export typescript code as BAF - * @param sourceFile ts-morph source file - * @param bafPath output BAF path - * @param sourceName TBAF source name, to put into comment - */ -function exportBAF(sourceFile: SourceFile, bafPath: string, sourceName: string): void { - let exportContent = `/* Do not edit. This file is generated from ${sourceName}. Make your changes there and regenerate this file. */\n\n`; - - // Traverse all IfStatements in the source file - sourceFile.forEachDescendant((node) => { - if (node.isKind(SyntaxKind.IfStatement)) { - const ifStatement = node as IfStatement; - const condition = ifStatement.getExpression().getText(); - const thenBlock = ifStatement.getThenStatement(); - - // Handle the IF block - exportContent += exportIfCondition(condition); - - // Handle the THEN block - exportContent += "THEN\n"; - exportContent += " RESPONSE #100\n"; - exportContent += exportThenBlock(thenBlock.getText()); - exportContent += "END\n\n"; // Newline after END - } - }); - - exportContent = applyBAFhacks(exportContent); - // Write the content to the specified file - fs.writeFileSync(bafPath, exportContent, 'utf-8'); // Remove any extra trailing newlines - console.log(`Content saved to ${bafPath}`); -} - -/** - * Apply final BAF hacks: GLOBAL, LOCALS, obj() replacement. - */ -function applyBAFhacks(text: string): string { - let result = text.replace(/,\s*LOCALS/g, ', "LOCALS"'); - result = result.replace(/,\s*GLOBAL/g, ', "GLOBAL"'); - // obj specifier replacement: $obj("[ANYONE]") => [ANYONE] - result = result.replace(/\$obj\("\[(.*?)\]"\)/g, '[$1]'); - // Also should work for death vars, so any string is accepted. - result = result.replace(/\$obj\("(.*?)"\)/g, '"$1"'); - // $tra specifier replacement: $tra(number) => @number - result = result.replace(/\$tra\((\d+)\)/g, '@$1'); - result = result.trim() + "\n"; - return result; -} - - -/** - * Convert "if" condition to BAF format. - * Remove &&, convert || to OR. - */ -function exportIfCondition(condition: string): string { - let result = "IF\n"; - - // Split on AND conditions first - const andConditions = condition.split('&&'); - - // Process each AND condition part - andConditions.forEach((andCond) => { - andCond = andCond.trim(); - - // Handle negation (remove wrapping parentheses if negated condition has parentheses) - // Shouldn't have complex conditions here... I think. - if (andCond.startsWith('!') && andCond[1] === '(' && andCond.endsWith(')')) { - andCond = `!${andCond.slice(2, -1).trim()}`; - } - - // Step 2: Remove wrapping parentheses if the condition starts and ends with parentheses - else if (andCond.startsWith('(') && andCond.endsWith(')')) { - andCond = andCond.slice(1, -1).trim(); - } - - // Process OR block if the condition contains || - if (andCond.includes('||')) { - const orConditions = andCond.split('||').map(cond => cond.trim()); - result += ` OR(${orConditions.length})\n`; - orConditions.forEach((orCond) => { - result += ` ${orCond}\n`; // No parentheses in OR block - }); - } else { - // Single condition case (no OR) - result += ` ${andCond}\n`; - } - }); - - return result; -} - - -/** - * Convert "then" block to BAF format. - * Remove curly braces and split statements. - */ -function exportThenBlock(body: string): string { - let result = ""; - - // Remove curly braces and split statements - const cleanBody = body.replace(/[{}]/g, '').trim(); - const statements = cleanBody.split(';'); - - // Process the statements in the THEN block - statements.forEach(statement => { - const trimmedStatement = statement.trim(); - if (trimmedStatement) { - result += ` ${trimmedStatement}\n`; // No semicolon at the end - } - }); - - return result; -} - -/** - * Simplifies conditions in the given source file by removing unnecessary parentheses. - * @param sourceFile The source file to process. - */ -export function simplifyConditions(sourceFile: SourceFile) { - sourceFile.forEachDescendant((node) => { - if (Node.isParenthesizedExpression(node)) { - tryRemoveParentheses(node); - } - }); -} - -/** - * Attempts to remove parentheses if they don't affect expression meaning. - * @param parenExpr The parenthesized expression to examine. - */ -function tryRemoveParentheses(parenExpr: ParenthesizedExpression) { - const innerExpr = parenExpr.getExpression(); - - if (canSafelyRemoveParen(innerExpr, parenExpr)) { - parenExpr.replaceWithText(innerExpr.getText()); - } -} - -/** - * Determines if parentheses around the expression can safely be removed. - * Parentheses around expressions containing OR (`||`) must be kept. - * Parentheses around expressions containing only AND (`&&`) can be removed. - * Parentheses immediately under a prefix unary expression (`!`) must be kept. - */ -function canSafelyRemoveParen(expr: Node, parenExpr: ParenthesizedExpression): boolean { - const parent = parenExpr.getParent(); - - if (parent && Node.isPrefixUnaryExpression(parent)) { - return false; - } - - if (!Node.isBinaryExpression(expr)) return true; - return !containsOrOperator(expr); -} - -/** - * Checks recursively if the binary expression contains any OR (`||`) operators. - */ -function containsOrOperator(expr: BinaryExpression): boolean { - if (expr.getOperatorToken().getKind() === SyntaxKind.BarBarToken) return true; - - const left = expr.getLeft(); - const right = expr.getRight(); - - return ( - (Node.isBinaryExpression(left) && containsOrOperator(left)) || - (Node.isBinaryExpression(right) && containsOrOperator(right)) - ); -} - -/** - * Apply the transformations. Progressize inlining and unrolling, then else inversion and if flattening. - */ -function applyTransformations(sourceFile: SourceFile) { - // Progressive unroll and inline - const MAX_INTERATIONS = 100; - for (let i = 0; i <= MAX_INTERATIONS; i++) { - const previousCode = sourceFile.getFullText(); - - - // Open parentheses if possible - simplifyConditions(sourceFile); - - // Progressive unroll and inline - inlineUnroll(sourceFile); - - - const currentCode = sourceFile.getFullText(); - if (currentCode === previousCode) break; - - if (i == MAX_INTERATIONS) { - console.log("ERROR: reached max interactions, aborting!"); - } - } - - // Convert else blocks to if statements with inverted conditions - convertElseToIf(sourceFile); - - // Flatten nested if conditions - flattenIfStatements(sourceFile); - - // So that BAF exporter does not see function bodies. - removeFunctionDeclarations(sourceFile); - - // Prettify code - sourceFile.formatText(); -} - - -/** - * Removes all function declarations from the given source file. - * @param sourceFile The source file to modify. - */ -function removeFunctionDeclarations(sourceFile: SourceFile) { - const functionDeclarations = sourceFile.getFunctions(); - - functionDeclarations.forEach(func => { - try { - console.log(`Removing function: ${func.getName() || "anonymous"} at ${func.getStartLineNumber()}`); - func.remove(); - } catch (error) { - console.error(`Error removing function: ${func.getName() || "anonymous"}`, error); - } - }); - - console.log(`Removed ${functionDeclarations.length} function(s).`); -} diff --git a/server/src/tbaf/bundle.ts b/server/src/tbaf/bundle.ts new file mode 100644 index 0000000..ddba2de --- /dev/null +++ b/server/src/tbaf/bundle.ts @@ -0,0 +1,92 @@ +/** + * TBAF Bundler + * + * Uses esbuild for in-memory bundling. + */ + +import * as esbuild from "esbuild-wasm"; +import * as path from "path"; +import * as fs from "fs"; +import { ensureEsbuild, cleanupEsbuildOutput, noSideEffectsPlugin } from "../esbuild-utils"; + +/** Marker to identify start of user code in esbuild output */ +const TBAF_CODE_MARKER = "/* __TBAF_CODE_START__ */"; + +/** + * Bundle a TBAF file and its imports into a single TypeScript string. + * + * @param filePath Absolute path to the .tbaf file + * @param sourceText Content of the .tbaf file + * @returns Bundled TypeScript code + */ +export async function bundle(filePath: string, sourceText: string): Promise { + await ensureEsbuild(); + + const resolveDir = path.dirname(filePath); + + // Prepend marker so we can strip esbuild runtime helpers later + const sourceWithMarker = TBAF_CODE_MARKER + "\n" + sourceText; + + const result = await esbuild.build({ + stdin: { + contents: sourceWithMarker, + resolveDir, + sourcefile: filePath, + loader: "ts", + }, + bundle: true, + write: false, + format: "esm", + platform: "neutral", + target: "esnext", + minify: false, + plugins: [ + // Mark node_modules as external + { + name: "external-node-modules", + setup(build) { + build.onResolve({ filter: /.*/ }, (args) => { + if (args.path.includes("node_modules")) { + return { path: args.path, external: true }; + } + // Bare imports (not starting with . or /) are likely from node_modules + if (!args.path.startsWith(".") && !args.path.startsWith("/") && !args.path.endsWith(".tbaf") && !args.path.endsWith(".ts")) { + return { path: args.path, external: true }; + } + return null; + }); + }, + }, + // Plugin to resolve .tbaf files as TypeScript + { + name: "tbaf-resolver", + setup(build) { + build.onResolve({ filter: /\.tbaf$/ }, (args) => { + const resolved = path.resolve(args.resolveDir, args.path); + return { path: resolved, namespace: "tbaf" }; + }); + + build.onLoad({ filter: /.*/, namespace: "tbaf" }, (args) => { + const contents = fs.readFileSync(args.path, "utf-8"); + return { contents, loader: "ts" }; + }); + + build.onResolve({ filter: /\.ts$/ }, (args) => { + const resolved = path.resolve(args.resolveDir, args.path); + if (fs.existsSync(resolved)) { + return { path: resolved }; + } + return null; + }); + }, + }, + noSideEffectsPlugin(), + ], + }); + + if (result.outputFiles && result.outputFiles.length > 0) { + return cleanupEsbuildOutput(result.outputFiles[0].text, TBAF_CODE_MARKER); + } + + throw new Error("esbuild produced no output"); +} diff --git a/server/src/tbaf/cnf.ts b/server/src/tbaf/cnf.ts new file mode 100644 index 0000000..586de42 --- /dev/null +++ b/server/src/tbaf/cnf.ts @@ -0,0 +1,126 @@ +/** + * CNF (Conjunctive Normal Form) conversion utilities for TBAF. + * + * BAF conditions must be in CNF: AND of (atoms or OR-groups). + * When inverting complex expressions, we may get DNF (OR of ANDs), + * which needs to be converted to CNF using the distributive law. + */ + +import { BAFCondition, BAFOrGroup, BAFTopCondition } from "./ir"; + +/** Maximum clauses to generate before erroring (prevents exponential blowup) */ +const MAX_CNF_CLAUSES = 128; + +/** + * Convert DNF (disjunction of conjunctions) to CNF. + * + * Input: Array of "terms", where each term is a conjunction (BAFTopCondition[]). + * The terms are implicitly ORed together. + * + * Example: + * terms = [[!A, !B], [!C]] + * meaning: (!A && !B) || (!C) + * result: [(!A || !C), (!B || !C)] + * meaning: (!A || !C) && (!B || !C) + * + * Uses the distributive law: (A && B) || C = (A || C) && (B || C) + * + * @param terms Array of conjunctions to OR together + * @param maxClauses Maximum allowed clauses in result + * @returns CNF as BAFTopCondition[] + */ +export function dnfToCnf(terms: BAFTopCondition[][], maxClauses = MAX_CNF_CLAUSES): BAFTopCondition[] { + if (terms.length === 0) return []; + if (terms.length === 1) return terms[0]; // Single conjunction is already CNF + + // Filter out empty terms (they represent "false" in the OR, can be ignored) + const nonEmptyTerms = terms.filter(t => t.length > 0); + if (nonEmptyTerms.length === 0) return []; + if (nonEmptyTerms.length === 1) return nonEmptyTerms[0]; + + // Calculate result size to check limits before computing + let resultSize = 1; + for (const term of nonEmptyTerms) { + resultSize *= term.length; + if (resultSize > maxClauses) { + throw new Error( + `Condition inversion would produce ${resultSize}+ clauses (limit: ${maxClauses}). ` + + `Simplify the condition or avoid negating complex AND expressions.` + ); + } + } + + // Cartesian product: pick one element from each term, OR them together + const result: BAFTopCondition[] = []; + + // Generate all combinations using iterative approach + const indices = new Array(nonEmptyTerms.length).fill(0); + + while (true) { + // Create OR group from current combination + const atoms = flattenToAtoms(nonEmptyTerms.map((term, i) => term[indices[i]])); + + // Deduplicate atoms in the OR group + const uniqueAtoms = deduplicateAtoms(atoms); + + if (uniqueAtoms.length === 1) { + result.push(uniqueAtoms[0]); + } else { + result.push({ conditions: uniqueAtoms }); + } + + // Increment indices (like counting in mixed-radix) + let j = nonEmptyTerms.length - 1; + while (j >= 0) { + indices[j]++; + if (indices[j] < nonEmptyTerms[j].length) break; + indices[j] = 0; + j--; + } + if (j < 0) break; // All combinations generated + } + + return result; +} + +/** + * Flatten an array of BAFTopConditions into atoms (BAFCondition[]). + * OR groups are expanded into their constituent atoms. + */ +function flattenToAtoms(conditions: BAFTopCondition[]): BAFCondition[] { + const atoms: BAFCondition[] = []; + for (const cond of conditions) { + if (isOrGroup(cond)) { + atoms.push(...cond.conditions); + } else { + atoms.push(cond); + } + } + return atoms; +} + +/** + * Remove duplicate atoms from an array. + * Two atoms are equal if they have the same name, args, and negation. + */ +function deduplicateAtoms(atoms: BAFCondition[]): BAFCondition[] { + const seen = new Set(); + const result: BAFCondition[] = []; + + for (const atom of atoms) { + const key = `${atom.negated ? "!" : ""}${atom.name}(${atom.args.join(",")})`; + if (!seen.has(key)) { + seen.add(key); + result.push(atom); + } + } + + return result; +} + +/** + * Type guard for BAFOrGroup. + */ +function isOrGroup(cond: BAFTopCondition): cond is BAFOrGroup { + return "conditions" in cond; +} diff --git a/server/src/tbaf/emit.ts b/server/src/tbaf/emit.ts new file mode 100644 index 0000000..02e9b93 --- /dev/null +++ b/server/src/tbaf/emit.ts @@ -0,0 +1,70 @@ +/** + * BAF Emitter + * + * Converts BAF IR to BAF text format. + */ + +import * as path from "path"; +import { BAFAction, BAFBlock, BAFCondition, BAFScript, BAFTopCondition, isOrGroup } from "./ir"; + +/** Emit a complete BAF script */ +export function emitBAF(script: BAFScript): string { + const fileName = path.basename(script.sourceFile); + let output = `/* Do not edit. This file is generated from ${fileName}. Make your changes there and regenerate this file. */\n\n`; + + for (const block of script.blocks) { + output += emitBlock(block); + output += "\n"; + } + + return output.trimEnd() + "\n"; +} + +/** Emit a single IF/THEN/END block */ +function emitBlock(block: BAFBlock): string { + let result = "IF\n"; + + for (const cond of block.conditions) { + result += emitCondition(cond); + } + + result += "THEN\n"; + result += ` RESPONSE #${block.response}\n`; + + for (const action of block.actions) { + result += emitAction(action); + } + + result += "END\n"; + return result; +} + +/** Emit a top-level condition (single or OR group) */ +function emitCondition(cond: BAFTopCondition): string { + if (isOrGroup(cond)) { + let result = ` OR(${cond.conditions.length})\n`; + for (const c of cond.conditions) { + result += ` ${emitSingleCondition(c)}\n`; + } + return result; + } else { + return ` ${emitSingleCondition(cond)}\n`; + } +} + +/** Emit a single condition like See(Player1) or !Global("x", "LOCALS", 0) */ +function emitSingleCondition(cond: BAFCondition): string { + const prefix = cond.negated ? "!" : ""; + const args = cond.args.join(", "); + return `${prefix}${cond.name}(${args})`; +} + +/** Emit an action like Spell(Myself, WIZARD_SHIELD) */ +function emitAction(action: BAFAction): string { + const args = action.args.join(", "); + let result = ` ${action.name}(${args})`; + if (action.comment) { + result += ` // ${action.comment}`; + } + return result + "\n"; +} diff --git a/server/src/tbaf/index.ts b/server/src/tbaf/index.ts new file mode 100644 index 0000000..c71eab1 --- /dev/null +++ b/server/src/tbaf/index.ts @@ -0,0 +1,117 @@ +/** + * TBAF Transpiler - Main Entry Point + * + * Transpiles TypeScript BAF (.tbaf) to BAF format. + */ + +import * as fs from "fs"; +import * as path from "path"; +import { Project } from "ts-morph"; +import { conlog, uriToPath } from "../common"; +import { bundle } from "./bundle"; +import { emitBAF } from "./emit"; +import { BAFScript, isOrGroup } from "./ir"; +import { TBAFTransformer } from "./transform"; + +export const EXT_TBAF = ".tbaf"; + +/** + * Compile a TBAF file to BAF. + * + * @param uri VSCode URI of the file + * @param text Source text content + * @returns Path to generated BAF file + */ +export async function compile(uri: string, text: string): Promise { + const filePath = uriToPath(uri); + const ext = path.extname(filePath).toLowerCase(); + + if (ext !== EXT_TBAF) { + throw new Error(`${uri} is not a .tbaf file`); + } + + // 1. Bundle imports + const bundled = await bundle(filePath, text); + + // 2. Parse bundled code + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile("bundled.ts", bundled); + + // 3. Transform AST to IR + const transformer = new TBAFTransformer(); + const ir = transformer.transform(sourceFile); + + // Use original file path for the header comment + ir.sourceFile = filePath; + + // 4. Apply BAF-specific fixups to IR + applyBAFFixups(ir); + + // 5. Emit BAF text + const baf = emitBAF(ir); + + // 6. Write output + const bafPath = filePath.replace(/\.tbaf$/i, ".baf"); + fs.writeFileSync(bafPath, baf, "utf-8"); + + conlog(`Transpiled to ${bafPath}`); + return bafPath; +} + +/** + * Apply BAF-specific fixups to the IR. + * Handles LOCALS/GLOBAL quoting, $obj(), $tra() replacements. + */ +function applyBAFFixups(script: BAFScript): void { + for (const block of script.blocks) { + // Fix conditions + for (const cond of block.conditions) { + if (isOrGroup(cond)) { + for (const c of cond.conditions) { + fixupArgs(c.args); + } + } else { + fixupArgs(cond.args); + } + } + + // Fix actions + for (const action of block.actions) { + fixupArgs(action.args); + } + } +} + +/** + * Apply BAF fixups to argument list. + * Handles nested $obj() and $tra() calls within arguments. + */ +function fixupArgs(args: string[]): void { + for (let i = 0; i < args.length; i++) { + args[i] = fixupArg(args[i]); + } +} + +/** + * Apply BAF fixups to a single argument string. + */ +function fixupArg(arg: string): string { + // LOCALS and GLOBAL should be quoted + if (arg === "LOCALS") { + return '"LOCALS"'; + } + if (arg === "GLOBAL") { + return '"GLOBAL"'; + } + + // $obj("[ANYONE]") => [ANYONE] (globally, handles nested calls) + arg = arg.replace(/\$obj\("\[(.*?)\]"\)/g, "[$1]"); + + // $obj("string") => "string" (globally, handles nested calls) + arg = arg.replace(/\$obj\("(.*?)"\)/g, '"$1"'); + + // $tra(123) => @123 (globally, handles nested calls) + arg = arg.replace(/\$tra\((\d+)\)/g, "@$1"); + + return arg; +} diff --git a/server/src/tbaf/ir.ts b/server/src/tbaf/ir.ts new file mode 100644 index 0000000..de3f06c --- /dev/null +++ b/server/src/tbaf/ir.ts @@ -0,0 +1,46 @@ +/** + * BAF Intermediate Representation + * + * Structured types representing BAF semantics, not strings. + * Used between the AST transformer and the BAF emitter. + */ + +/** A single condition check like See(Player1) or !Global("x", "LOCALS", 0) */ +export interface BAFCondition { + negated: boolean; + name: string; // "See", "Global", "LevelLT", etc. + args: string[]; // ["Player1"], ["\"x\"", "\"LOCALS\"", "0"] +} + +/** OR group: (a || b || c) - emits as OR(n) followed by conditions */ +export interface BAFOrGroup { + conditions: BAFCondition[]; +} + +/** Top-level condition: either a single condition or an OR group */ +export type BAFTopCondition = BAFCondition | BAFOrGroup; + +/** An action like Spell(Myself, WIZARD_SHIELD) */ +export interface BAFAction { + name: string; + args: string[]; + comment?: string; // Optional inline comment +} + +/** A complete IF/THEN/END block */ +export interface BAFBlock { + conditions: BAFTopCondition[]; // ANDed together + actions: BAFAction[]; + response: number; // Usually 100 +} + +/** Complete BAF script */ +export interface BAFScript { + sourceFile: string; // For header comment + blocks: BAFBlock[]; +} + +/** Type guard: check if a condition is an OR group */ +export function isOrGroup(cond: BAFTopCondition): cond is BAFOrGroup { + return "conditions" in cond; +} diff --git a/server/src/tbaf/transform.ts b/server/src/tbaf/transform.ts new file mode 100644 index 0000000..c3864ef --- /dev/null +++ b/server/src/tbaf/transform.ts @@ -0,0 +1,905 @@ +/** + * TBAF Transformer + * + * Single-pass AST to BAF IR transformer. + * Replaces the iterative multi-transform approach. + */ + +import { + ArrayLiteralExpression, + Block, + CallExpression, + Expression, + ForOfStatement, + ForStatement, + FunctionDeclaration, + IfStatement, + Node, + PrefixUnaryExpression, + Project, + ReturnStatement, + SourceFile, + SpreadElement, + Statement, + SyntaxKind, +} from "ts-morph"; +import { BAFAction, BAFBlock, BAFCondition, BAFOrGroup, BAFScript, BAFTopCondition } from "./ir"; +import { dnfToCnf } from "./cnf"; + +/** Context for variable substitution */ +type VarsContext = Map; + +/** Context for function inlining */ +type FuncsContext = Map; + +export class TBAFTransformer { + private vars: VarsContext = new Map(); + private funcs: FuncsContext = new Map(); + private blocks: BAFBlock[] = []; + private sourceFile!: SourceFile; + + /** + * Transform a bundled TypeScript source file to BAF IR. + */ + transform(sourceFile: SourceFile): BAFScript { + this.sourceFile = sourceFile; + this.blocks = []; + this.vars.clear(); + this.funcs.clear(); + + // Pass 1: Collect declarations (read-only) + this.collectDeclarations(); + + // Pass 2: Transform top-level statements to BAF blocks + for (const stmt of sourceFile.getStatements()) { + this.transformStatement(stmt, []); + } + + return { + sourceFile: sourceFile.getFilePath(), + blocks: this.blocks, + }; + } + + /** + * Collect variable declarations (const/var/let) and function declarations. + * Note: esbuild converts const to var, so we collect all variable declarations. + */ + private collectDeclarations() { + // Collect all variable declarations (esbuild converts const to var) + for (const varDecl of this.sourceFile.getDescendantsOfKind(SyntaxKind.VariableDeclaration)) { + const name = varDecl.getName(); + const init = varDecl.getInitializer(); + if (init) { + const value = this.evaluateExpression(init); + if (value !== undefined) { + this.vars.set(name, value); + } + } + } + + // Collect function declarations + for (const func of this.sourceFile.getFunctions()) { + const name = func.getName(); + if (name) { + this.funcs.set(name, func); + } + } + } + + /** + * Transform a statement, accumulating parent conditions for nested ifs. + */ + private transformStatement(stmt: Statement, parentConditions: BAFTopCondition[]) { + // Skip variable and function declarations + if (stmt.isKind(SyntaxKind.VariableStatement) || stmt.isKind(SyntaxKind.FunctionDeclaration)) { + return; + } + + if (stmt.isKind(SyntaxKind.IfStatement)) { + this.transformIfStatement(stmt as IfStatement, parentConditions); + } else if (stmt.isKind(SyntaxKind.ForOfStatement)) { + this.transformForOfStatement(stmt as ForOfStatement, parentConditions); + } else if (stmt.isKind(SyntaxKind.ForStatement)) { + this.transformForStatement(stmt as ForStatement, parentConditions); + } else if (stmt.isKind(SyntaxKind.Block)) { + for (const s of (stmt as Block).getStatements()) { + this.transformStatement(s, parentConditions); + } + } else if (stmt.isKind(SyntaxKind.ExpressionStatement)) { + const expr = stmt.getExpression(); + if (Node.isCallExpression(expr)) { + const funcName = expr.getExpression().getText(); + + // Skip esbuild helper calls + if (funcName === "__name" || funcName === "__defProp") { + return; + } + + const funcDecl = this.funcs.get(funcName); + + if (funcDecl) { + // User-defined function - inline its body + this.inlineVoidFunctionCall(expr, funcDecl, parentConditions); + } else { + // Built-in action call + const action = this.transformCallToAction(expr); + this.blocks.push({ + conditions: parentConditions.length > 0 ? parentConditions : [this.trueCondition()], + actions: [action], + response: 100, + }); + } + } + } + } + + /** + * Transform an if statement to BAF blocks. + */ + private transformIfStatement(ifStmt: IfStatement, parentConditions: BAFTopCondition[]) { + const condExpr = ifStmt.getExpression(); + const conditions = [...parentConditions, ...this.transformConditionExpr(condExpr)]; + + const thenStmt = ifStmt.getThenStatement(); + const elseStmt = ifStmt.getElseStatement(); + + // Process then block + this.processBlock(this.getBlockStatements(thenStmt), conditions); + + // Handle else block + if (elseStmt) { + const invertedConditions = this.invertConditions(parentConditions, condExpr); + if (elseStmt.isKind(SyntaxKind.IfStatement)) { + // else if - recurse + this.transformIfStatement(elseStmt as IfStatement, invertedConditions); + } else { + // else block + this.processBlock(this.getBlockStatements(elseStmt), invertedConditions); + } + } + } + + /** + * Process a block of statements - either recurse for nested ifs or emit actions. + */ + private processBlock(statements: Statement[], conditions: BAFTopCondition[]) { + const hasNestedIf = statements.some(s => s.isKind(SyntaxKind.IfStatement)); + + if (hasNestedIf) { + for (const s of statements) { + this.transformStatement(s, conditions); + } + } else { + const actions = this.transformActionsFromStatements(statements); + if (actions.length > 0) { + this.blocks.push({ conditions, actions, response: 100 }); + } + } + } + + /** + * Transform a for-of loop by unrolling. + */ + private transformForOfStatement(forOf: ForOfStatement, parentConditions: BAFTopCondition[]) { + const body = forOf.getStatement(); + this.unrollForOf(forOf, () => { + this.transformStatement(body, parentConditions); + }); + } + + /** + * Transform a for loop by unrolling. + */ + private transformForStatement(forStmt: ForStatement, parentConditions: BAFTopCondition[]) { + const body = forStmt.getStatement(); + this.unrollFor(forStmt, () => { + this.transformStatement(body, parentConditions); + }); + } + + /** + * Transform a condition expression to BAFTopCondition[]. + * Handles &&, ||, !, and function calls. + */ + private transformConditionExpr(expr: Expression): BAFTopCondition[] { + // Handle binary && - split into multiple top conditions + if (Node.isBinaryExpression(expr)) { + const opKind = expr.getOperatorToken().getKind(); + + if (opKind === SyntaxKind.AmpersandAmpersandToken) { + // AND: combine conditions + return [ + ...this.transformConditionExpr(expr.getLeft()), + ...this.transformConditionExpr(expr.getRight()), + ]; + } + + if (opKind === SyntaxKind.BarBarToken) { + // OR: create an OR group + const orGroup = this.buildOrGroup(expr); + return [orGroup]; + } + } + + // Handle parenthesized expression + if (Node.isParenthesizedExpression(expr)) { + return this.transformConditionExpr(expr.getExpression()); + } + + // Handle negation + if (Node.isPrefixUnaryExpression(expr)) { + const prefixExpr = expr as PrefixUnaryExpression; + if (prefixExpr.getOperatorToken() === SyntaxKind.ExclamationToken) { + const operand = prefixExpr.getOperand(); + // Check if it's a negated call + if (Node.isCallExpression(operand)) { + const funcName = operand.getExpression().getText(); + const funcDecl = this.funcs.get(funcName); + + if (funcDecl) { + // User function - negate each inlined condition + const conditions = this.inlineFunctionConditions(operand, funcDecl); + return this.negateConditions(conditions); + } + + const cond = this.transformCallToCondition(operand); + return [{ ...cond, negated: true }]; + } + // Check if it's a negated parenthesized expression with OR + if (Node.isParenthesizedExpression(operand)) { + const inner = operand.getExpression(); + if (Node.isBinaryExpression(inner) && inner.getOperatorToken().getKind() === SyntaxKind.BarBarToken) { + // !(a || b) → !a && !b - apply De Morgan + throw new Error( + `Cannot represent "!(${inner.getText()})" in BAF.\n` + + `Negation of OR groups is not supported. Refactor to avoid this pattern.` + ); + } + } + } + } + + // Handle call expression + if (Node.isCallExpression(expr)) { + const funcName = expr.getExpression().getText(); + const funcDecl = this.funcs.get(funcName); + + if (funcDecl) { + // User-defined function - inline its return expression as conditions + return this.inlineFunctionConditions(expr, funcDecl); + } + + // Built-in BAF condition + return [this.transformCallToCondition(expr)]; + } + + // Fallback: treat as opaque condition + return [this.opaqueCondition(expr.getText(), false)]; + } + + /** + * Build an OR group from a binary || expression. + */ + private buildOrGroup(expr: Expression): BAFOrGroup { + const conditions: BAFCondition[] = []; + + const collect = (e: Expression) => { + if (Node.isBinaryExpression(e) && e.getOperatorToken().getKind() === SyntaxKind.BarBarToken) { + collect(e.getLeft()); + collect(e.getRight()); + } else if (Node.isParenthesizedExpression(e)) { + collect(e.getExpression()); + } else { + // Must be an atom (call or negated call) + const cond = this.exprToCondition(e); + conditions.push(cond); + } + }; + + collect(expr); + return { conditions }; + } + + /** + * Convert an expression to a single BAFCondition (used for OR group elements). + */ + private exprToCondition(expr: Expression): BAFCondition { + if (Node.isPrefixUnaryExpression(expr)) { + const prefixExpr = expr as PrefixUnaryExpression; + if (prefixExpr.getOperatorToken() === SyntaxKind.ExclamationToken) { + const operand = prefixExpr.getOperand(); + if (Node.isCallExpression(operand)) { + const funcName = operand.getExpression().getText(); + const funcDecl = this.funcs.get(funcName); + + if (funcDecl) { + const cond = this.inlineFunctionAsSingleCondition(operand, funcDecl); + return { ...cond, negated: !cond.negated }; + } + + const cond = this.transformCallToCondition(operand); + return { ...cond, negated: true }; + } + } + } + + if (Node.isCallExpression(expr)) { + const funcName = expr.getExpression().getText(); + const funcDecl = this.funcs.get(funcName); + + if (funcDecl) { + return this.inlineFunctionAsSingleCondition(expr, funcDecl); + } + + return this.transformCallToCondition(expr); + } + + return this.opaqueCondition(expr.getText(), false); + } + + /** + * Transform a call expression to a BAFCondition (for built-in BAF conditions only). + * For user functions, use inlineFunctionConditions instead. + */ + private transformCallToCondition(call: CallExpression): BAFCondition { + const funcName = call.getExpression().getText(); + const args = call.getArguments().map(a => this.substituteVars(a.getText())); + return { negated: false, name: funcName, args }; + } + + /** + * Inline a user function call, returning its conditions. + * Handles complex return expressions like "A() && B() && C()". + */ + private inlineFunctionConditions(call: CallExpression, funcDecl: FunctionDeclaration): BAFTopCondition[] { + const body = funcDecl.getBody()?.asKindOrThrow(SyntaxKind.Block); + if (!body) return [this.trueCondition()]; + + const returnStmt = body.getStatements().find(s => s.isKind(SyntaxKind.ReturnStatement)); + if (!returnStmt) return [this.trueCondition()]; + + const returnExpr = (returnStmt as ReturnStatement).getExpression(); + if (!returnExpr) return [this.trueCondition()]; + + // Substitute params in return expression text + const paramMap = this.buildParamMap(call, funcDecl); + let returnText = returnExpr.getText(); + paramMap.forEach((value, key) => { + returnText = returnText.replace(new RegExp(`\\b${key}\\b`, "g"), value); + }); + + // Parse the substituted return expression + const project = new Project({ useInMemoryFileSystem: true }); + const tempFile = project.createSourceFile("temp.ts", `const _x_ = ${returnText};`); + const varDecl = tempFile.getVariableDeclarations()[0]; + const expr = varDecl.getInitializerOrThrow(); + + // Transform the parsed expression using the full condition transformer + return this.transformConditionExpr(expr); + } + + /** + * Inline a user function call as a single condition (for OR groups). + * Throws if the function returns multiple conditions. + */ + private inlineFunctionAsSingleCondition(call: CallExpression, funcDecl: FunctionDeclaration): BAFCondition { + const conditions = this.inlineFunctionConditions(call, funcDecl); + + if (conditions.length !== 1) { + throw new Error( + `Cannot use function "${funcDecl.getName()}" inside OR group: ` + + `it returns ${conditions.length} conditions, but OR elements must be single conditions.` + ); + } + + const cond = conditions[0]; + if ("conditions" in cond) { + throw new Error( + `Cannot use function "${funcDecl.getName()}" inside OR group: ` + + `it returns an OR group, which cannot be nested inside another OR.` + ); + } + + return cond; + } + + /** + * Transform a call expression to a BAFAction. + */ + private transformCallToAction(call: CallExpression): BAFAction { + const funcName = call.getExpression().getText(); + const args = call.getArguments().map(a => this.substituteVars(a.getText())); + return { name: funcName, args }; + } + + /** + * Transform statements to BAFActions. + */ + private transformActionsFromStatements(statements: Statement[]): BAFAction[] { + const actions: BAFAction[] = []; + + for (const stmt of statements) { + if (stmt.isKind(SyntaxKind.ExpressionStatement)) { + const expr = stmt.getExpression(); + if (Node.isCallExpression(expr)) { + const funcName = expr.getExpression().getText(); + const funcDecl = this.funcs.get(funcName); + + if (funcDecl) { + // Inline void function: get its body statements as actions + const inlinedActions = this.inlineVoidFunction(expr, funcDecl); + actions.push(...inlinedActions); + } else { + actions.push(this.transformCallToAction(expr)); + } + } + } else if (stmt.isKind(SyntaxKind.ForOfStatement)) { + // Unroll for-of loop into actions + const unrolledActions = this.unrollForOfAsActions(stmt as ForOfStatement); + actions.push(...unrolledActions); + } else if (stmt.isKind(SyntaxKind.ForStatement)) { + // Unroll for loop into actions + const unrolledActions = this.unrollForAsActions(stmt as ForStatement); + actions.push(...unrolledActions); + } + } + + return actions; + } + + /** + * Unroll a for-of loop into actions. + */ + private unrollForOfAsActions(forOf: ForOfStatement): BAFAction[] { + const bodyStatements = this.getBlockStatements(forOf.getStatement()); + const actions: BAFAction[] = []; + this.unrollForOf(forOf, () => { + actions.push(...this.transformActionsFromStatements(bodyStatements)); + }); + return actions; + } + + /** + * Unroll a for loop into actions. + */ + private unrollForAsActions(forStmt: ForStatement): BAFAction[] { + const bodyStatements = this.getBlockStatements(forStmt.getStatement()); + const actions: BAFAction[] = []; + this.unrollFor(forStmt, () => { + actions.push(...this.transformActionsFromStatements(bodyStatements)); + }); + return actions; + } + + /** + * Unroll a for-of loop, calling the callback for each element. + * Sets the loop variable in vars context during each iteration. + */ + private unrollForOf(forOf: ForOfStatement, onIteration: () => void): void { + const arrayExpr = forOf.getExpression(); + const elements = this.resolveArrayElements(arrayExpr); + + if (!elements) { + throw new Error(`Cannot unroll for-of: array expression "${arrayExpr.getText()}" is not resolvable`); + } + + const loopVar = forOf.getInitializer().getText().replace(/^const\s+/, "").replace(/^let\s+/, ""); + + for (const element of elements) { + this.vars.set(loopVar, element); + onIteration(); + } + + this.vars.delete(loopVar); + } + + /** Maximum iterations for loop unrolling to prevent infinite loops */ + private static readonly MAX_LOOP_ITERATIONS = 1000; + + /** + * Unroll a for loop, calling the callback for each iteration. + * Sets the loop variable in vars context during each iteration. + */ + private unrollFor(forStmt: ForStatement, onIteration: () => void): void { + const initializer = forStmt.getInitializer(); + if (!initializer || !initializer.isKind(SyntaxKind.VariableDeclarationList)) { + throw new Error("Cannot unroll for loop: complex initializer"); + } + + const decls = initializer.getDeclarations(); + if (decls.length !== 1) { + throw new Error("Cannot unroll for loop: multi-variable initializer"); + } + + const loopVar = decls[0].getName(); + const initValue = this.evaluateNumeric(decls[0].getInitializer()); + if (initValue === undefined) { + throw new Error("Cannot unroll for loop: non-numeric initializer"); + } + + const condition = forStmt.getCondition(); + if (!condition) { + throw new Error("Cannot unroll for loop: no condition"); + } + + const incrementor = forStmt.getIncrementor(); + if (!incrementor) { + throw new Error("Cannot unroll for loop: no incrementor"); + } + + const increment = this.parseIncrement(incrementor.getText()); + let current = initValue; + let iterations = 0; + + while (this.evaluateLoopCondition(condition.getText(), loopVar, current)) { + if (iterations >= TBAFTransformer.MAX_LOOP_ITERATIONS) { + throw new Error( + `Loop exceeded maximum ${TBAFTransformer.MAX_LOOP_ITERATIONS} iterations. ` + + `This likely indicates an infinite loop or a design issue. ` + + `BAF scripts should not need many iterations.` + ); + } + this.vars.set(loopVar, current.toString()); + onIteration(); + current += increment; + iterations++; + } + + this.vars.delete(loopVar); + } + + /** + * Inline a void function call at top level, transforming its body statements. + * This handles functions containing if statements, loops, etc. + */ + private inlineVoidFunctionCall(call: CallExpression, funcDecl: FunctionDeclaration, parentConditions: BAFTopCondition[]) { + const body = funcDecl.getBody()?.asKindOrThrow(SyntaxKind.Block); + if (!body) return; + + this.withParamContext(call, funcDecl, () => { + for (const stmt of body.getStatements()) { + this.transformStatement(stmt, parentConditions); + } + }); + } + + /** + * Inline a void function call, returning its actions. + * Used when inlining inside an if block's action list. + */ + private inlineVoidFunction(call: CallExpression, funcDecl: FunctionDeclaration): BAFAction[] { + const body = funcDecl.getBody()?.asKindOrThrow(SyntaxKind.Block); + if (!body) return []; + + let actions: BAFAction[] = []; + this.withParamContext(call, funcDecl, () => { + actions = this.transformActionsFromStatements(body.getStatements()); + }); + return actions; + } + + /** + * Execute a function with parameter substitutions in the vars context. + * Saves and restores the vars context around the callback. + */ + private withParamContext(call: CallExpression, funcDecl: FunctionDeclaration, fn: () => void): void { + const paramMap = this.buildParamMap(call, funcDecl); + + const savedVars = new Map(this.vars); + paramMap.forEach((value, key) => this.vars.set(key, value)); + + fn(); + + this.vars = savedVars; + } + + /** + * Build a map of parameter names to substituted argument values. + */ + private buildParamMap(call: CallExpression, funcDecl: FunctionDeclaration): Map { + const params = funcDecl.getParameters(); + const args = call.getArguments(); + const paramMap = new Map(); + + params.forEach((param, i) => { + const paramName = param.getName(); + const argText = args[i]?.getText() || param.getInitializer()?.getText() || ""; + paramMap.set(paramName, this.substituteVars(argText)); + }); + + return paramMap; + } + + /** + * Invert conditions for else block. + */ + private invertConditions(parentConditions: BAFTopCondition[], condExpr: Expression): BAFTopCondition[] { + // For else, we need: parentConditions AND NOT(condExpr) + const inverted = this.invertExpression(condExpr); + return [...parentConditions, ...inverted]; + } + + /** + * Invert an expression using De Morgan's law. + * Returns BAFTopCondition[] since inversion of AND produces OR. + */ + private invertExpression(expr: Expression): BAFTopCondition[] { + if (Node.isBinaryExpression(expr)) { + const opKind = expr.getOperatorToken().getKind(); + + if (opKind === SyntaxKind.AmpersandAmpersandToken) { + // !(a && b) → !a || !b + // Each inverted operand may be a conjunction (multiple ANDed conditions). + // We need to OR these conjunctions, which produces DNF. + // Convert DNF to CNF using the distributive law. + const leftConds = this.invertExpression(expr.getLeft()); + const rightConds = this.invertExpression(expr.getRight()); + + // If both results are single atoms, we can directly create an OR group + if (leftConds.length === 1 && rightConds.length === 1 && + !("conditions" in leftConds[0]) && !("conditions" in rightConds[0])) { + return [{ conditions: [leftConds[0], rightConds[0]] }]; + } + + // Otherwise, use DNF→CNF conversion + return dnfToCnf([leftConds, rightConds]); + } + + if (opKind === SyntaxKind.BarBarToken) { + // !(a || b) → !a && !b - produces multiple ANDed conditions + const leftConds = this.invertExpression(expr.getLeft()); + const rightConds = this.invertExpression(expr.getRight()); + return [...leftConds, ...rightConds]; + } + } + + if (Node.isParenthesizedExpression(expr)) { + return this.invertExpression(expr.getExpression()); + } + + if (Node.isPrefixUnaryExpression(expr)) { + const prefixExpr = expr as PrefixUnaryExpression; + if (prefixExpr.getOperatorToken() === SyntaxKind.ExclamationToken) { + // !!a → a (double negation) + const operand = prefixExpr.getOperand(); + if (Node.isCallExpression(operand)) { + const funcName = operand.getExpression().getText(); + const funcDecl = this.funcs.get(funcName); + + if (funcDecl) { + // Un-negate user function conditions + return this.inlineFunctionConditions(operand, funcDecl); + } + + const cond = this.transformCallToCondition(operand); + return [{ ...cond, negated: false }]; + } + return this.transformConditionExpr(operand); + } + } + + if (Node.isCallExpression(expr)) { + const funcName = expr.getExpression().getText(); + const funcDecl = this.funcs.get(funcName); + + if (funcDecl) { + // Negate each condition from the user function + const conditions = this.inlineFunctionConditions(expr, funcDecl); + return this.negateConditions(conditions); + } + + const cond = this.transformCallToCondition(expr); + return [{ ...cond, negated: true }]; + } + + return [this.opaqueCondition(expr.getText(), true)]; + } + + /** + * Negate a CNF expression using De Morgan's law. + * + * Input: [C1, C2, ...] meaning C1 && C2 && ... + * Output: CNF for !(C1 && C2 && ...) = !C1 || !C2 || ... + * + * Each !Ci: + * - If Ci is atom A: !Ci = !A + * - If Ci is OR(A,B,...): !Ci = !A && !B && ... (conjunction) + * + * Result is DNF (OR of conjunctions), converted to CNF. + */ + private negateConditions(conditions: BAFTopCondition[]): BAFTopCondition[] { + // Build DNF terms: each term is a conjunction (negated Ci) + const terms: BAFTopCondition[][] = []; + + for (const c of conditions) { + if ("conditions" in c) { + // OR group: !(A || B || ...) = !A && !B && ... (De Morgan) + const negatedAtoms: BAFCondition[] = c.conditions.map( + inner => ({ ...inner, negated: !inner.negated }) + ); + terms.push(negatedAtoms); + } else { + // Atom: just negate it + terms.push([{ ...c, negated: !c.negated }]); + } + } + + // Convert DNF (OR of terms) to CNF + return dnfToCnf(terms); + } + + /** + * Get statements from a block or single statement. + */ + private getBlockStatements(stmt: Statement): Statement[] { + if (stmt.isKind(SyntaxKind.Block)) { + return (stmt as Block).getStatements(); + } + return [stmt]; + } + + /** + * Evaluate an expression to a string value if possible. + */ + private evaluateExpression(expr: Expression): string | undefined { + if (expr.isKind(SyntaxKind.ArrayLiteralExpression)) { + const arr = expr as ArrayLiteralExpression; + const elements = this.flattenArrayElements(arr); + return `[${elements.join(", ")}]`; + } + + if (expr.isKind(SyntaxKind.StringLiteral) || expr.isKind(SyntaxKind.NumericLiteral)) { + return expr.getText(); + } + + if (expr.isKind(SyntaxKind.Identifier)) { + const name = expr.getText(); + if (this.vars.has(name)) { + return this.vars.get(name); + } + } + + return expr.getText(); + } + + /** + * Flatten array elements, resolving spreads. + */ + private flattenArrayElements(arr: ArrayLiteralExpression): string[] { + const result: string[] = []; + + for (const el of arr.getElements()) { + if (el.isKind(SyntaxKind.SpreadElement)) { + const spreadExpr = (el as SpreadElement).getExpression(); + if (spreadExpr.isKind(SyntaxKind.ArrayLiteralExpression)) { + result.push(...this.flattenArrayElements(spreadExpr as ArrayLiteralExpression)); + } else if (spreadExpr.isKind(SyntaxKind.Identifier)) { + const name = spreadExpr.getText(); + if (this.vars.has(name)) { + const value = this.vars.get(name)!; + // Parse array literal + const inner = value.slice(1, -1).trim(); + if (inner) { + result.push(...inner.split(",").map(s => s.trim())); + } + } + } + } else { + result.push(this.substituteVars(el.getText())); + } + } + + return result; + } + + /** + * Resolve array elements from an expression. + */ + private resolveArrayElements(expr: Expression): string[] | null { + if (expr.isKind(SyntaxKind.ArrayLiteralExpression)) { + return this.flattenArrayElements(expr as ArrayLiteralExpression); + } + + if (expr.isKind(SyntaxKind.Identifier)) { + const name = expr.getText(); + if (this.vars.has(name)) { + const value = this.vars.get(name)!; + if (value.startsWith("[") && value.endsWith("]")) { + const inner = value.slice(1, -1).trim(); + if (inner) { + return inner.split(",").map(s => s.trim()); + } + return []; + } + } + } + + return null; + } + + /** + * Evaluate a numeric expression. + */ + private evaluateNumeric(expr: Expression | undefined): number | undefined { + if (!expr) return undefined; + + const text = this.substituteVars(expr.getText()); + const num = Number(text); + return isNaN(num) ? undefined : num; + } + + /** + * Parse increment from for loop. + */ + private parseIncrement(text: string): number { + if (text.includes("++")) return 1; + if (text.includes("--")) return -1; + if (text.includes("+=")) return Number(text.split("+=")[1]) || 1; + if (text.includes("-=")) return -(Number(text.split("-=")[1]) || 1); + return 1; + } + + /** + * Evaluate loop condition. + */ + private evaluateLoopCondition(condition: string, loopVar: string, value: number): boolean { + // Substitute loop variable + let substituted = condition.replace(new RegExp(`\\b${loopVar}\\b`, "g"), value.toString()); + // Substitute other variables (like iterations) + substituted = this.substituteVars(substituted); + try { + const fn = new Function(`return (${substituted});`); + return fn(); + } catch (e) { + throw new Error( + `Cannot evaluate loop condition "${condition}" with ${loopVar}=${value}. ` + + `Substituted: "${substituted}". Error: ${e instanceof Error ? e.message : e}` + ); + } + } + + /** + * Substitute variables in text. + */ + private substituteVars(text: string): string { + let result = text; + this.vars.forEach((value, key) => { + result = result.replace(new RegExp(`\\b${key}\\b`, "g"), value); + }); + return result; + } + + /** + * Create an opaque condition (for expressions we can't parse). + */ + private opaqueCondition(text: string, negated: boolean): BAFCondition { + // Try to parse as a function call + const match = text.match(/^(\w+)\((.*)\)$/); + if (match) { + return { + negated, + name: match[1], + args: match[2] ? match[2].split(",").map(s => s.trim()) : [], + }; + } + + // Fallback: use True() or False() wrapper + return { + negated, + name: "True", + args: [], + }; + } + + /** + * Create a True() condition placeholder. + */ + private trueCondition(): BAFCondition { + return { + negated: false, + name: "True", + args: [], + }; + } +} diff --git a/server/src/tssl.ts b/server/src/tssl.ts new file mode 100644 index 0000000..3251084 --- /dev/null +++ b/server/src/tssl.ts @@ -0,0 +1,1261 @@ +import * as fs from "fs"; +import * as path from "path"; +import { + Project, + SourceFile, + SyntaxKind, + Node +} from 'ts-morph'; +import * as esbuild from 'esbuild-wasm'; +import { fileURLToPath } from "url"; +import { ensureEsbuild, cleanupEsbuildOutput, noSideEffectsPlugin } from "./esbuild-utils"; + +// Use console.log directly for CLI compatibility (conlog depends on LSP connection) +const conlog = console.log; + +export const EXT_TSSL = ".tssl"; +const uriToPath = (uri: string) => uri.startsWith('file://') ? fileURLToPath(uri) : uri; + +/** Marker to identify start of user code in esbuild output */ +const TSSL_CODE_MARKER = "/* __TSSL_CODE_START__ */"; + +// Inline function metadata: maps function name to its expansion +interface InlineFunc { + targetFunc: string; // Function being called, e.g., "sfall_func2" or "reg_anim_func" + args: InlineArg[]; // Arguments in order, either param references or constants + params: string[]; // Ordered parameter names from function signature +} + +interface InlineArg { + type: 'param' | 'constant'; + value: string; // param name or constant value +} + +/** + * Context object passed through transpilation functions. + * Replaces module-level globals for cleaner data flow. + */ +interface TsslContext { + inlineFunctions: Map; + definedFunctions: Set; + functionJsDocs: Map; + doStatementCounter: number; +} + +/** + * JavaScript built-ins that are not available in SSL runtime. + * Usage of these will cause transpilation to fail. + */ +const FORBIDDEN_GLOBALS = new Set([ + 'Object', + 'Array', + 'JSON', + 'Math', + 'Date', + 'Promise', + 'Map', + 'Set', + 'WeakMap', + 'WeakSet', + 'Symbol', + 'Reflect', + 'Proxy', +]); + +/** Variable names that conflict with folib exports and cause esbuild renaming issues */ +const RESERVED_VAR_NAMES = new Set([ + 'list', + 'map', +]); + +/** + * Data extracted from the main source file before bundling. + * Grouped to reduce parameter count in function signatures. + */ +interface MainFileData { + constants: Map; + letVars: Set; + includes: string[]; +} + + +/** + * How many lines to look backwards when searching for esbuild source comments. + * esbuild inserts comments like "// node_modules/folib/sfall.ts" before bundled code. + */ +const SOURCE_COMMENT_LOOKBACK = 10; + +/** + * Convert TSSL to SSL. + * @param uri VSCode document URI or file path + * @param text Source text content + * @returns Path to generated SSL file + */ +export async function compile(uri: string, text: string): Promise { + const filePath = uriToPath(uri); + const parsed = path.parse(filePath); + if (parsed.ext.toLowerCase() != EXT_TSSL) { + throw new Error(`${uri} is not a .tssl file`); + } + + // Initialize the TypeScript project (reused across extraction functions) + const project = new Project(); + + // Extract includes, constants, and let vars from the original source + const { constants, letVars } = extractTopLevelVars(project, text); + const mainFileData: MainFileData = { + constants, + letVars, + includes: extractIncludes(text), + }; + + // Create context for this compilation + const ctx: TsslContext = { + inlineFunctions: new Map(), + definedFunctions: new Set(), + functionJsDocs: new Map(), + doStatementCounter: 0, + }; + + // Extract JSDoc from main source file before bundling (esbuild strips them) + const mainSource = project.addSourceFileAtPath(filePath); + extractJsDocs(mainSource, ctx); + conlog(`Extracted JSDoc for ${ctx.functionJsDocs.size} functions from main file`); + + const bundleResult = await bundle(filePath, text); + + // Strip ESM module boilerplate from esbuild output + const bundledCode = cleanupEsbuildOutput(bundleResult.code, TSSL_CODE_MARKER, mainFileData.constants); + + // Create source file in memory from cleaned bundled code + const sourceFile = project.createSourceFile("bundled.ts", bundledCode, { overwrite: true }); + + // Extract inline functions from files that were actually bundled + ctx.inlineFunctions = extractInlineFunctionsFromFiles(project, bundleResult.inputFiles); + conlog(`Found ${ctx.inlineFunctions.size} inline functions`); + + // Save to SSL file, same directory + const sslPath = path.join(parsed.dir, `${parsed.name}.ssl`); + exportSSL(sourceFile, sslPath, parsed.base, mainFileData, ctx); + + return sslPath; +} + +/** + * Extract #include directives from magic comments. + * Looks for lines like: // #include "path/to/header.h" + * @param sourceText The original TypeScript source text + * @returns Array of include paths + */ +function extractIncludes(sourceText: string): string[] { + const includes: string[] = []; + const regex = /^\/\/\s*#include\s+["']([^"']+)["']\s*$/gm; + let match; + while ((match = regex.exec(sourceText)) !== null) { + includes.push(match[1]); + } + return includes; +} + +/** + * Extract top-level constants and let variables from source. + * Constants become #define, let variables become SSL variable declarations. + * @param project ts-morph Project instance to reuse + * @param sourceText The original TypeScript source text + * @returns Object with constants map and letVars set + */ +function extractTopLevelVars(project: Project, sourceText: string): { constants: Map; letVars: Set } { + const constants = new Map(); + const letVars = new Set(); + const tempSourceFile = project.createSourceFile("temp-vars.ts", sourceText, { overwrite: true }); + + for (const stmt of tempSourceFile.getStatements()) { + if (stmt.getKind() === SyntaxKind.VariableStatement) { + const varStmt = stmt.asKind(SyntaxKind.VariableStatement); + if (!varStmt) continue; + + const declList = varStmt.getDeclarationList(); + const keywordNode = declList.getFirstChild(); + const keywordKind = keywordNode ? keywordNode.getKind() : undefined; + + if (keywordKind === SyntaxKind.ConstKeyword) { + for (const decl of declList.getDeclarations()) { + const name = decl.getName(); + const initializer = decl.getInitializer(); + if (initializer) { + // Convert operators to SSL syntax (| → bwor, etc.) + const value = convertOperatorsAST(initializer); + constants.set(name, value); + } + } + } else if (keywordKind === SyntaxKind.LetKeyword) { + for (const decl of declList.getDeclarations()) { + letVars.add(decl.getName()); + } + } + } + } + + return { constants, letVars }; +} + +/** + * Standard Fallout script procedures called by the engine. + * These must be preserved from tree-shaking. + */ +const ENGINE_PROCEDURES = [ + 'barter_init_p_proc', + 'barter_p_proc', + 'combat_p_proc', + 'create_p_proc', + 'critter_p_proc', + 'damage_p_proc', + 'description_p_proc', + 'destroy_p_proc', + 'drop_p_proc', + 'look_at_p_proc', + 'map_enter_p_proc', + 'map_exit_p_proc', + 'map_update_p_proc', + 'pickup_p_proc', + 'spatial_p_proc', + 'start', + 'talk_p_proc', + 'timed_event_p_proc', + 'use_ad_on_p_proc', + 'use_disad_on_p_proc', + 'use_obj_on_p_proc', + 'use_p_proc', + 'use_skill_on_p_proc', +]; + +/** + * Extract function names that should be preserved from tree-shaking. + * Includes engine procedures and any function passed to register_hook_proc. + */ +function extractPreserveFunctions(text: string): string[] { + const preserve = [...ENGINE_PROCEDURES]; + // Extract functions passed to register_hook_proc or register_hook_proc_spec + const hookRegex = /register_hook_proc(?:_spec)?\s*\([^,]+,\s*(\w+)\s*\)/g; + let match; + while ((match = hookRegex.exec(text)) !== null) { + preserve.push(match[1]); + } + return preserve; +} + +/** + * Extract functions marked with @inline JSDoc tag from bundled source files. + * Uses the list of input files from esbuild's metafile. + * @param project ts-morph Project instance to reuse + * @param inputFiles List of input file paths from esbuild metafile + */ +function extractInlineFunctionsFromFiles(project: Project, inputFiles: string[]): Map { + const result = new Map(); + + for (const filePath of inputFiles) { + if (!fs.existsSync(filePath)) continue; + const source = project.addSourceFileAtPath(filePath); + extractInlineFunctionsFromSource(source, result); + } + + return result; +} + +function extractInlineFunctionsFromSource(source: SourceFile, result: Map) { + for (const stmt of source.getStatements()) { + if (stmt.getKind() !== SyntaxKind.FunctionDeclaration) continue; + + const func = stmt.asKind(SyntaxKind.FunctionDeclaration); + if (!func) continue; + + // Check for @inline JSDoc tag + const jsDocs = func.getJsDocs(); + const hasInlineTag = jsDocs.some(doc => doc.getText().includes('@inline')); + if (!hasInlineTag) continue; + + const funcName = func.getName(); + if (!funcName) continue; + + // Extract the call from the body + const body = func.getBody(); + if (!body) continue; + + // Get parameter names to identify which args are params vs constants + const paramNames = new Set(func.getParameters().map(p => p.getName())); + const params = func.getParameters().map(p => p.getName()); + + let targetFunc: string | undefined; + let inlineArgs: InlineArg[] = []; + + // Helper to extract call info + const extractCallInfo = (call: Node) => { + const callExpr = call.asKindOrThrow(SyntaxKind.CallExpression); + targetFunc = callExpr.getExpression().getText(); + const args = callExpr.getArguments(); + + for (const arg of args) { + const argText = arg.getText(); + if (paramNames.has(argText)) { + inlineArgs.push({ type: 'param', value: argText }); + } else { + // Convert operators to SSL syntax (| → bwor, etc.) + inlineArgs.push({ type: 'constant', value: convertOperatorsAST(arg) }); + } + } + }; + + // Look for return statement first + const returnStmt = body.getFirstDescendantByKind(SyntaxKind.ReturnStatement); + if (returnStmt) { + let returnExpr = returnStmt.getExpression(); + // Unwrap AsExpression (e.g., `sfall_func2(...) as ObjectPtr`) + if (returnExpr?.getKind() === SyntaxKind.AsExpression) { + returnExpr = returnExpr.asKindOrThrow(SyntaxKind.AsExpression).getExpression(); + } + if (returnExpr?.getKind() === SyntaxKind.CallExpression) { + extractCallInfo(returnExpr); + } + } else { + // Check for expression statement (void functions) + const exprStmt = body.getFirstDescendantByKind(SyntaxKind.ExpressionStatement); + if (exprStmt) { + const expr = exprStmt.getExpression(); + if (expr.getKind() === SyntaxKind.CallExpression) { + extractCallInfo(expr); + } + } + } + + if (!targetFunc) continue; + + result.set(funcName, { targetFunc, args: inlineArgs, params }); + } +} + +/** + * Generate #define macros from inline functions that are actually used. + * @param inlineFuncs Map of function names to InlineFunc metadata + * @param usedFuncs Set of function names that are actually called in the code + * @returns Array of #define statements + */ +function generateInlineMacros(inlineFuncs: Map, usedFuncs: Set): string[] { + const macros: string[] = []; + for (const [funcName, inline] of inlineFuncs) { + if (!usedFuncs.has(funcName)) continue; + const paramList = inline.params.length > 0 ? `(${inline.params.join(', ')})` : ''; + const argList = inline.args.map(a => a.value).join(', '); + macros.push(`#define ${funcName}${paramList} ${inline.targetFunc}(${argList})`); + } + return macros; +} + +/** + * Find all inline functions that are actually used in the source file. + */ +function findUsedInlineFunctions(source: SourceFile, inlineFuncs: Map): Set { + const used = new Set(); + + function visit(node: Node) { + if (node.getKind() === SyntaxKind.CallExpression) { + const call = node.asKindOrThrow(SyntaxKind.CallExpression); + const fnName = call.getExpression().getText(); + if (inlineFuncs.has(fnName)) { + used.add(fnName); + } + } + node.forEachChild(visit); + } + + source.forEachChild(visit); + return used; +} + +/** + * Extract JSDoc comments from a single source file for all functions. + * This must be done before bundling since esbuild strips JSDoc. + */ +function extractJsDocs(sourceFile: SourceFile, ctx: TsslContext): void { + sourceFile.getFunctions().forEach(func => { + const name = func.getName(); + if (!name) return; + + const jsDocs = func.getJsDocs(); + if (jsDocs.length > 0) { + // Keep the original JSDoc format - SSL supports it + const jsDocText = jsDocs.map(doc => doc.getText()).join('\n'); + if (jsDocText) { + ctx.functionJsDocs.set(name, jsDocText); + } + } + }); +} + +interface BundleResult { + code: string; + inputFiles: string[]; +} + +/** + * Bundle functions with esbuild, returning bundled code and input files. + * @param filePath Original file path (for resolving imports) + * @param text Source text + * @returns Bundled code and list of input files from metafile + */ +async function bundle(filePath: string, text: string): Promise { + const preserveFunctions = extractPreserveFunctions(text); + + // Prepend marker and append fake usage to preserve functions from tree-shaking + const preserveCode = `\n// Preserve functions\nif ((globalThis as any).__preserve__) { console.log(${preserveFunctions.join(', ')}); }`; + const sourceWithMarker = TSSL_CODE_MARKER + "\n" + text + preserveCode; + + await ensureEsbuild(); + const result = await esbuild.build({ + stdin: { + contents: sourceWithMarker, + resolveDir: path.dirname(filePath), + sourcefile: path.basename(filePath).replace('.tssl', '.ts'), + loader: 'ts', + }, + bundle: true, + write: false, // Return output in memory + metafile: true, // Get list of input files + format: 'esm', + treeShaking: true, + minify: false, + keepNames: false, + target: 'es2022', + platform: 'neutral', + plugins: [ + // Mark .d.ts imports as external - they're engine builtins + { + name: 'external-declarations', + setup(build) { + build.onResolve({ filter: /\.d(\.ts)?$/ }, args => ({ + path: args.path, + external: true + })); + } + }, + noSideEffectsPlugin(), + ] + }); + + if (result.outputFiles && result.outputFiles.length > 0) { + conlog(`Bundling complete!`); + // Extract input files from metafile (only .ts files, not .d.ts) + const inputFiles = result.metafile + ? Object.keys(result.metafile.inputs).filter(f => f.endsWith('.ts') && !f.endsWith('.d.ts')) + : []; + return { code: result.outputFiles[0].text, inputFiles }; + } + throw new Error('esbuild produced no output'); +} + +interface SourceSection { + source: string; + defines: string[]; + variables: string[]; + declarations: string[]; + procedures: string[]; +} + +/** + * Export typescript code as SSL + * @param sourceFile ts-morph source file + * @param sslPath output SSL path + * @param sourceName tssl source name, to put into comment + * @param mainFileData Data extracted from main file (constants, letVars, includes) + * @param ctx Transpilation context + */ +function exportSSL(sourceFile: SourceFile, sslPath: string, sourceName: string, mainFileData: MainFileData, ctx: TsslContext): void { + conlog(`Starting conversion of: ${sourceName}`); + + const header = `/* Do not edit. This file is generated from ${sourceName}. Make your changes there and regenerate this file. */\n\n`; + const { sections } = processInput(sourceFile, mainFileData, ctx); + let output = header; + + // Includes first to avoid redefinition warnings + if (mainFileData.includes.length > 0) { + for (const inc of mainFileData.includes) { + output += `#include "${inc}"\n`; + } + output += '\n'; + } + + // Output main file constants (these may have been inlined/removed by bundler) + if (mainFileData.constants.size > 0) { + for (const [name, value] of mainFileData.constants) { + output += `#define ${name} ${value}\n`; + } + output += '\n'; + } + + // Separate bundled vs main sections + // Main file has sourceName (e.g., "foo.tssl" -> "foo.ts" in esbuild source comments) + const mainFileMarker = sourceName.replace('.tssl', '.ts'); + const mainSections = sections.filter(s => s.source.includes(mainFileMarker)); + const bundledSections = sections.filter(s => !s.source.includes(mainFileMarker)); + + // Collect all defines, declarations, variables, procedures + const allDefines: string[] = []; + const allDeclarations: string[] = []; + const bundledVariables: string[] = []; + const bundledProcedures: string[] = []; + const mainVariables: string[] = []; + const mainProcedures: string[] = []; + + for (const s of bundledSections) { + allDefines.push(...s.defines); + allDeclarations.push(...s.declarations); + bundledVariables.push(...s.variables); + bundledProcedures.push(...s.procedures); + } + for (const s of mainSections) { + allDefines.push(...s.defines); + allDeclarations.push(...s.declarations); + mainVariables.push(...s.variables); + mainProcedures.push(...s.procedures); + } + + // Add inline function macros to defines (only for functions actually used) + const usedInlineFuncs = findUsedInlineFunctions(sourceFile, ctx.inlineFunctions); + const inlineMacros = generateInlineMacros(ctx.inlineFunctions, usedInlineFuncs); + allDefines.push(...inlineMacros); + + // Output in order: defines, declarations, bundled code, main code + if (allDefines.length > 0) output += allDefines.join('\n') + '\n\n'; + if (allDeclarations.length > 0) output += allDeclarations.join('\n') + '\n'; + if (bundledVariables.length > 0 || bundledProcedures.length > 0) { + output += '\n/* ===== bundled ===== */\n'; + if (bundledVariables.length > 0) output += bundledVariables.join('\n') + '\n'; + if (bundledProcedures.length > 0) output += bundledProcedures.join('\n') + '\n'; + output += '/* ===== end bundled ===== */\n'; + } + if (mainVariables.length > 0 || mainProcedures.length > 0) { + output += '\n/* ===== main body ===== */\n'; + if (mainVariables.length > 0) output += mainVariables.join('\n') + '\n'; + if (mainProcedures.length > 0) output += mainProcedures.join('\n') + '\n'; + output += '/* ===== end main body ===== */\n'; + } + + // Replace sfall_typeof with typeof (TS keyword conflict workaround) + output = output.replace(/\bsfall_typeof\b/g, 'typeof'); + + // Write the content to the specified file + fs.writeFileSync(sslPath, output, 'utf-8'); + conlog(`Content saved to ${sslPath}`); +} + +/** + * iterate over top level statements and print them + * @param source ts-morph source file + * @param mainFileData Data extracted from main file (constants, letVars, includes) + * @param ctx Transpilation context + * @returns sections grouped by source file + */ +function processInput(source: SourceFile, mainFileData: MainFileData, ctx: TsslContext): { sections: SourceSection[] } { + const sections: SourceSection[] = []; + let currentSection: SourceSection | null = null; + + // Build a set of defined function names + ctx.definedFunctions.clear(); + for (const stmt of source.getStatements()) { + if (stmt.getKind() === SyntaxKind.FunctionDeclaration) { + const func = stmt.asKind(SyntaxKind.FunctionDeclaration); + const name = func?.getName(); + if (name) { + ctx.definedFunctions.add(name); + } + } + } + + function getOrCreateSection(sourcePath: string): SourceSection { + if (!currentSection || currentSection.source !== sourcePath) { + currentSection = { source: sourcePath, defines: [], variables: [], declarations: [], procedures: [] }; + sections.push(currentSection); + } + return currentSection; + } + + // Track current source from esbuild comments + let currentSource = "unknown"; + const sourceText = source.getFullText(); + const lines = sourceText.split('\n'); + + function updateSourceFromLine(stmtStart: number): void { + const stmtLine = source.getLineAndColumnAtPos(stmtStart).line; + // Look backwards from statement to find source comment (stmtLine is 1-based, lines is 0-based) + // lines[stmtLine - 2] is the line immediately before the statement + for (let i = stmtLine - 2; i >= 0 && i >= stmtLine - SOURCE_COMMENT_LOOKBACK; i--) { + const line = lines[i]?.trim(); + if (line?.startsWith('// ') && (line.includes('/') || line.includes('.ts'))) { + currentSource = line.substring(3); + break; + } + } + } + + for (const stmt of source.getStatements()) { + updateSourceFromLine(stmt.getStart()); + const section = getOrCreateSection(currentSource); + + if (stmt.getKind() === SyntaxKind.VariableStatement) { + const varStmt = stmt.asKind(SyntaxKind.VariableStatement); + if (!varStmt) continue; + const declList = varStmt.getDeclarationList(); + const keywordKind = declList.getFirstChild()?.getKind(); + + for (const decl of declList.getDeclarations()) { + const name = decl.getName(); + const init = decl.getInitializer(); + const initText = init ? convertOperatorsAST(init, ctx) : ''; + + // const → #define + // var (was const after esbuild) → #define, unless it's a main file let + // let → variable + // Skip entirely if this was a main file const (already output as #define) + const isMainFileLetVar = mainFileData.letVars.has(name); + const isMainFileConst = mainFileData.constants.has(name); + if (isMainFileConst) { + // Skip - already output as #define from mainFileConstants + } else if (keywordKind === SyntaxKind.ConstKeyword || + (keywordKind === SyntaxKind.VarKeyword && !isMainFileLetVar)) { + section.defines.push(`#define ${name} ${initText}`); + } else if (keywordKind === SyntaxKind.LetKeyword || keywordKind === SyntaxKind.VarKeyword) { + section.variables.push(`variable ${name}${initText ? ` = ${initText}` : ''};`); + } + } + } else if (stmt.getKind() === SyntaxKind.FunctionDeclaration) { + const func = stmt.asKind(SyntaxKind.FunctionDeclaration); + if (!func) continue; + const name = func.getName(); + + // Skip inline functions - they are expanded at call sites, not emitted as procedures + if (name && ctx.inlineFunctions.has(name)) continue; + + // Skip list() and map() - they are converted to array/map literals by the transpiler + if (name === 'list' || name === 'map') continue; + + const paramsWithDefaults = func.getParameters().map(p => { + const paramName = p.getName(); + const init = p.getInitializer(); + if (init) { + return `variable ${paramName} = ${convertOperatorsAST(init, ctx)}`; + } + return `variable ${paramName}`; + }).join(", "); + const params = func.getParameters().map(p => `variable ${p.getName()}`).join(", "); + const body = func.getBody() ? processFunctionBody(func.getBody()!, " ", ctx) : ""; + + section.declarations.push(`procedure ${name}(${paramsWithDefaults});`); + + // Include JSDoc as SSL comment if available + const jsDoc = name ? ctx.functionJsDocs.get(name) : undefined; + const procCode = `procedure ${name}(${params}) begin\n${body ? body + '\n' : ''}end`; + section.procedures.push(jsDoc ? `${jsDoc}\n${procCode}` : procCode); + } + } + return { sections }; +} + +// ============================================================================ +// Statement Handlers - each handles one TypeScript statement type +// ============================================================================ + +function handleIfStatement(stmt: Node, indent: string, ctx: TsslContext): string { + const ifStmt = stmt.asKindOrThrow(SyntaxKind.IfStatement); + const cond = convertOperatorsAST(ifStmt.getExpression(), ctx); + const thenStmt = ifStmt.getThenStatement(); + let result = `${indent}if (${cond}) then begin\n`; + result += processFunctionBody(thenStmt, indent + " ", ctx); + result += `\n${indent}end`; + const elseStmt = ifStmt.getElseStatement(); + if (elseStmt) { + result += ` else begin\n`; + result += processFunctionBody(elseStmt, indent + " ", ctx); + result += `\n${indent}end`; + } + return result + `\n`; +} + +function handleVariableStatement(stmt: Node, indent: string, ctx: TsslContext): string { + const varStmt = stmt.asKind(SyntaxKind.VariableStatement); + if (varStmt) { + const declList = varStmt.getDeclarationList(); + const keywordNode = declList.getFirstChild(); + const keywordKind = keywordNode ? keywordNode.getKind() : undefined; + if (keywordKind === SyntaxKind.LetKeyword || keywordKind === SyntaxKind.ConstKeyword) { + const converted = convertVarOrConstToVariable(stmt, ctx).trim(); + return `${indent}${converted}\n`; + } + } + return `${indent}${stmt.getText().trim()}\n`; +} + +function handleExpressionStatement(stmt: Node, indent: string, ctx: TsslContext): string { + const exprStmt = stmt.asKindOrThrow(SyntaxKind.ExpressionStatement); + const expr = exprStmt.getExpression(); + if (expr.getKind() === SyntaxKind.CallExpression) { + return `${indent}${processCallExpression(expr, ctx)};\n`; + } + const converted = convertOperatorsAST(expr, ctx); + return `${indent}${converted};\n`; +} + +function handleReturnStatement(stmt: Node, indent: string, ctx: TsslContext): string { + const ret = stmt.asKindOrThrow(SyntaxKind.ReturnStatement); + const expr = ret.getExpression(); + return `${indent}return${expr ? ' ' + convertOperatorsAST(expr, ctx) : ''};\n`; +} + +function handleForStatement(stmt: Node, indent: string, ctx: TsslContext): string { + const forStmt = stmt.asKindOrThrow(SyntaxKind.ForStatement); + const init = forStmt.getInitializer(); + const cond = forStmt.getCondition(); + const incr = forStmt.getIncrementor(); + const body = forStmt.getStatement(); + + let initStr = ""; + if (init) { + if (init.getKind() === SyntaxKind.VariableDeclarationList) { + const declList = init.asKindOrThrow(SyntaxKind.VariableDeclarationList); + const decls = declList.getDeclarations(); + if (decls.length > 0) { + const decl = decls[0]; + const name = decl.getName(); + const initializer = decl.getInitializer(); + initStr = `variable ${name} = ${initializer ? convertOperatorsAST(initializer, ctx) : '0'}`; + } + } else { + initStr = convertOperatorsAST(init, ctx); + } + } + + const condStr = cond ? convertOperatorsAST(cond, ctx) : "true"; + const incrStr = incr ? convertOperatorsAST(incr, ctx) : ""; + + let result = `${indent}for (${initStr}; ${condStr}; ${incrStr}) begin\n`; + result += processFunctionBody(body, indent + " ", ctx); + return result + `\n${indent}end\n`; +} + +function handleWhileStatement(stmt: Node, indent: string, ctx: TsslContext): string { + const whileStmt = stmt.asKindOrThrow(SyntaxKind.WhileStatement); + const cond = convertOperatorsAST(whileStmt.getExpression(), ctx); + const body = whileStmt.getStatement(); + + let result = `${indent}while (${cond}) do begin\n`; + result += processFunctionBody(body, indent + " ", ctx); + return result + `\n${indent}end\n`; +} + +function handleForEachStatement(stmt: Node, indent: string, ctx: TsslContext): string { + const forStmt = stmt.getKind() === SyntaxKind.ForInStatement + ? stmt.asKindOrThrow(SyntaxKind.ForInStatement) + : stmt.asKindOrThrow(SyntaxKind.ForOfStatement); + const init = forStmt.getInitializer(); + const expr = forStmt.getExpression(); + const body = forStmt.getStatement(); + + let varPart = ""; + if (init.getKind() === SyntaxKind.VariableDeclarationList) { + const declList = init.asKindOrThrow(SyntaxKind.VariableDeclarationList); + const decls = declList.getDeclarations(); + if (decls.length > 0) { + const decl = decls[0]; + const nameNode = decl.getNameNode(); + // Check for array destructuring: const [k, v] -> variable k: v + if (nameNode.getKind() === SyntaxKind.ArrayBindingPattern) { + const binding = nameNode.asKindOrThrow(SyntaxKind.ArrayBindingPattern); + const elements = binding.getElements(); + if (elements.length === 2) { + const key = elements[0].asKind(SyntaxKind.BindingElement)?.getName() ?? elements[0].getText(); + const val = elements[1].asKind(SyntaxKind.BindingElement)?.getName() ?? elements[1].getText(); + varPart = `variable ${key}: ${val}`; + } else { + throw new Error(`foreach destructuring must have exactly 2 elements, got ${elements.length}`); + } + } else { + varPart = `variable ${decl.getName()}`; + } + } + } else { + varPart = init.getText(); + } + + let result = `${indent}foreach (${varPart} in ${convertOperatorsAST(expr, ctx)}) begin\n`; + result += processFunctionBody(body, indent + " ", ctx); + return result + `\n${indent}end\n`; +} + +function handleSwitchStatement(stmt: Node, indent: string, ctx: TsslContext): string { + const switchStmt = stmt.asKindOrThrow(SyntaxKind.SwitchStatement); + const expr = switchStmt.getExpression(); + const clauses = switchStmt.getCaseBlock().getClauses(); + const caseIndent = indent + " "; + const bodyIndent = indent + " "; + + let result = `${indent}switch (${convertOperatorsAST(expr, ctx)}) begin\n`; + for (const clause of clauses) { + if (clause.getKind() === SyntaxKind.CaseClause) { + const caseClause = clause.asKindOrThrow(SyntaxKind.CaseClause); + const caseExpr = caseClause.getExpression(); + const statements = caseClause.getStatements(); + const filteredStmts = statements.filter(s => s.getKind() !== SyntaxKind.BreakStatement); + result += `${caseIndent}case ${convertOperatorsAST(caseExpr, ctx)}:\n`; + for (const s of filteredStmts) { + result += processFunctionBody(s, bodyIndent, ctx) + "\n"; + } + } else if (clause.getKind() === SyntaxKind.DefaultClause) { + const defaultClause = clause.asKindOrThrow(SyntaxKind.DefaultClause); + const statements = defaultClause.getStatements(); + const filteredStmts = statements.filter(s => s.getKind() !== SyntaxKind.BreakStatement); + result += `${caseIndent}default:\n`; + for (const s of filteredStmts) { + result += processFunctionBody(s, bodyIndent, ctx) + "\n"; + } + } + } + return result + `${indent}end\n`; +} + +function handleDoStatement(stmt: Node, indent: string, ctx: TsslContext): string { + const doStmt = stmt.asKindOrThrow(SyntaxKind.DoStatement); + const cond = convertOperatorsAST(doStmt.getExpression(), ctx); + const body = doStmt.getStatement(); + + const varName = `__tssl_do_${ctx.doStatementCounter++}`; + let result = `${indent}variable ${varName} = 1;\n`; + result += `${indent}while (${varName} or (${cond})) do begin\n`; + result += `${indent} ${varName} = 0;\n`; + result += processFunctionBody(body, indent + " ", ctx); + return result + `\n${indent}end\n`; +} + +function handleTryStatement(stmt: Node, indent: string, ctx: TsslContext): string { + const tryStmt = stmt.asKindOrThrow(SyntaxKind.TryStatement); + const tryBlock = tryStmt.getTryBlock(); + conlog(`TSSL warning: try-catch not supported in SSL, catch block will be ignored`); + let result = `${indent}/* TSSL: try-catch not supported in SSL, executing try block only */\n`; + result += processFunctionBody(tryBlock, indent, ctx); + return result + `\n`; +} + +// ============================================================================ +// Main function body processor +// ============================================================================ + +/** + * Traverse the function body AST and convert statements to SSL syntax. + */ +function processFunctionBody(bodyNode: Node, indent: string = "", ctx: TsslContext): string { + let stmts: Node[] = []; + if (bodyNode.getKind() === SyntaxKind.Block) { + stmts = bodyNode.asKindOrThrow(SyntaxKind.Block).getStatements(); + } else if (bodyNode.getKind() === SyntaxKind.CaseClause) { + stmts = bodyNode.asKindOrThrow(SyntaxKind.CaseClause).getStatements(); + } else if (bodyNode.getKind() === SyntaxKind.DefaultClause) { + stmts = bodyNode.asKindOrThrow(SyntaxKind.DefaultClause).getStatements(); + } else { + stmts = [bodyNode]; + } + + let result = ""; + for (let i = 0; i < stmts.length; i++) { + const stmt = stmts[i]; + const prevStmt = i > 0 ? stmts[i - 1] : null; + + // Add blank line between statements if they were on different source lines + if (prevStmt && result.length > 0) { + const prevLine = prevStmt.getEndLineNumber(); + const currLine = stmt.getStartLineNumber(); + if (currLine - prevLine > 1) { + result += '\n'; + } + } + + switch (stmt.getKind()) { + case SyntaxKind.IfStatement: + result += handleIfStatement(stmt, indent, ctx); + break; + case SyntaxKind.VariableStatement: + result += handleVariableStatement(stmt, indent, ctx); + break; + case SyntaxKind.ExpressionStatement: + result += handleExpressionStatement(stmt, indent, ctx); + break; + case SyntaxKind.ReturnStatement: + result += handleReturnStatement(stmt, indent, ctx); + break; + case SyntaxKind.ForStatement: + result += handleForStatement(stmt, indent, ctx); + break; + case SyntaxKind.WhileStatement: + result += handleWhileStatement(stmt, indent, ctx); + break; + case SyntaxKind.ForInStatement: + case SyntaxKind.ForOfStatement: + result += handleForEachStatement(stmt, indent, ctx); + break; + case SyntaxKind.SwitchStatement: + result += handleSwitchStatement(stmt, indent, ctx); + break; + case SyntaxKind.DoStatement: + result += handleDoStatement(stmt, indent, ctx); + break; + case SyntaxKind.TryStatement: + result += handleTryStatement(stmt, indent, ctx); + break; + case SyntaxKind.ContinueStatement: + result += `${indent}continue;\n`; + break; + case SyntaxKind.BreakStatement: + result += `${indent}break;\n`; + break; + default: + throw new Error(`Unhandled statement type: ${stmt.getKindName()}. Code: ${stmt.getText().substring(0, 100)}`); + break; + } + } + return result.trimEnd(); +} + +/** + * Process a call expression and add 'call' keyword if needed + * @param callExpr The call expression node + * @param ctx Transpilation context + * @returns The processed call expression as a string + */ +function processCallExpression(callExpr: Node, ctx: TsslContext): string { + // We need to cast to the appropriate type + const callExpression = callExpr.asKindOrThrow(SyntaxKind.CallExpression); + + // Get the expression being called (usually an identifier) + const expression = callExpression.getExpression(); + + // Get the arguments + const args = callExpression.getArguments(); + + // Get function name + const fnName = expression.getText(); + + // Special handling for list() and map() - convert to SSL array/map literals + if (fnName === 'list') { + const processedArgs = args.map((arg: Node) => convertOperatorsAST(arg, ctx)); + return `[${processedArgs.join(', ')}]`; + } + if (fnName === 'map') { + // map() takes a single object argument, just output it directly + if (args.length === 1) { + return convertOperatorsAST(args[0], ctx); + } + } + + // Convert arguments with operator conversion + const processedArgs = args.map((arg: Node) => convertOperatorsAST(arg, ctx)); + + // Check if this is a standalone call expression (not part of an assignment or return) + const parent = callExpr.getParent(); + const isStandaloneCall = parent && parent.getKind() === SyntaxKind.ExpressionStatement; + + // Only add 'call' keyword if it's a standalone call AND the function is defined in our file (not inline) + if (isStandaloneCall && ctx.definedFunctions.has(fnName) && !ctx.inlineFunctions.has(fnName)) { + return `call ${fnName}(${processedArgs.join(', ')})`; + } + + // For zero-arg inline macros, don't include parentheses + const inlineFunc = ctx.inlineFunctions.get(fnName); + if (args.length === 0 && inlineFunc?.params.length === 0) { + return fnName; + } + + // For zero-arg external function calls (not defined in this file), don't include parentheses + // SSL doesn't use parens for zero-arg calls like get_light_level, game_loaded, etc. + if (args.length === 0 && !ctx.definedFunctions.has(fnName)) { + return fnName; + } + + // Otherwise keep as is (either it's an external function or part of an assignment) + return `${fnName}(${processedArgs.join(', ')})`; +} + +/** + * Converts a let or const VariableStatement node to a 'variable' statement, preserving formatting. + * + * @param stmt The ts-morph Node representing the VariableStatement. + * @param ctx Transpilation context + * @returns The converted 'variable' statement as a string. + * @throws Error if the statement is not a let/const variable declaration. + */ +function convertVarOrConstToVariable(stmt: Node, ctx: TsslContext): string { + const varStmt = stmt.asKind(SyntaxKind.VariableStatement); + if (!varStmt) throw new Error("Statement is not a VariableStatement"); + + const declList = varStmt.getDeclarationList(); + const keywordNode = declList.getFirstChild(); + const keywordKind = keywordNode ? keywordNode.getKind() : undefined; + + if (keywordKind !== SyntaxKind.LetKeyword && keywordKind !== SyntaxKind.ConstKeyword) { + throw new Error("VariableStatement is not a let/const declaration"); + } + + // Use AST positions to do precise substitution + let originalText = stmt.getText(); + const stmtStart = stmt.getStart(); + + // Collect all replacements with their positions, then apply from end to start + // to avoid position shifts + const replacements: { start: number; end: number; text: string }[] = []; + + for (const decl of declList.getDeclarations()) { + const varName = decl.getName(); + if (RESERVED_VAR_NAMES.has(varName)) { + throw new Error(`Variable name '${varName}' conflicts with folib export. Use a different name.`); + } + const initializer = decl.getInitializer(); + if (initializer) { + const converted = convertOperatorsAST(initializer, ctx); + if (converted !== initializer.getText()) { + replacements.push({ + start: initializer.getStart() - stmtStart, + end: initializer.getEnd() - stmtStart, + text: converted + }); + } + } + } + + // Apply replacements from end to start + replacements.sort((a, b) => b.start - a.start); + for (const r of replacements) { + originalText = originalText.substring(0, r.start) + r.text + originalText.substring(r.end); + } + + // Get the position of the keyword in the source file + const keywordPos = keywordNode!.getStart(); + const keywordEnd = keywordNode!.getEnd(); + const keywordRelativePos = keywordPos - stmt.getStart(); // Position within the statement text + + // Replace only the keyword exactly + const beforeKeyword = originalText.substring(0, keywordRelativePos); + const afterKeyword = originalText.substring(keywordRelativePos + (keywordEnd - keywordPos)); + let result = beforeKeyword + "variable" + afterKeyword; + + // Add semicolon at the end if needed (only if it doesn't already end with one) + if (!result.trim().endsWith(';')) { + const lastNonWhitespacePos = result.trimEnd().length; + result = result.substring(0, lastNonWhitespacePos) + ";" + result.substring(lastNonWhitespacePos); + } + + return result; +} + +/** + * Converts operators from TypeScript to SSL syntax using the AST + * @param node The expression node containing operators to convert + * @param ctx Optional transpilation context (not available during early extraction phases) + * @returns The expression with converted operators + */ +function convertOperatorsAST(node: Node, ctx?: TsslContext): string { + // Different handling based on node kind + switch (node.getKind()) { + case SyntaxKind.BinaryExpression: { + const binary = node.asKindOrThrow(SyntaxKind.BinaryExpression); + const operator = binary.getOperatorToken().getText(); + + // Handle comma expression (0, expr) - just return the right side + if (operator === ',') { + return convertOperatorsAST(binary.getRight(), ctx); + } + + const left = convertOperatorsAST(binary.getLeft(), ctx); + const right = convertOperatorsAST(binary.getRight(), ctx); + + // Convert operator + let sslOperator = operator; + switch (operator) { + case '&&': sslOperator = 'and'; break; + case '||': sslOperator = 'or'; break; + case '&': sslOperator = 'bwand'; break; + case '|': sslOperator = 'bwor'; break; + case '^': sslOperator = 'bxor'; break; + } + + return `${left} ${sslOperator} ${right}`; + } + + case SyntaxKind.PrefixUnaryExpression: { + const prefix = node.asKindOrThrow(SyntaxKind.PrefixUnaryExpression); + const operand = convertOperatorsAST(prefix.getOperand(), ctx); + const operator = prefix.getOperatorToken(); + + // Convert unary operator + if (operator === SyntaxKind.ExclamationToken) { + return `not ${operand}`; + } else if (operator === SyntaxKind.TildeToken) { + return `bnot ${operand}`; + } + + // Other unary operators (++x, --x, -x, +x) remain as-is + return prefix.getText(); + } + + case SyntaxKind.PostfixUnaryExpression: { + const postfix = node.asKindOrThrow(SyntaxKind.PostfixUnaryExpression); + const operand = convertOperatorsAST(postfix.getOperand(), ctx); + const operator = postfix.getOperatorToken(); + + // i++ and i-- work the same in SSL + if (operator === SyntaxKind.PlusPlusToken) { + return `${operand}++`; + } else if (operator === SyntaxKind.MinusMinusToken) { + return `${operand}--`; + } + + return postfix.getText(); + } + + case SyntaxKind.ParenthesizedExpression: { + const parens = node.asKindOrThrow(SyntaxKind.ParenthesizedExpression); + const expression = convertOperatorsAST(parens.getExpression(), ctx); + return `(${expression})`; + } + + // Handle array literals + case SyntaxKind.ArrayLiteralExpression: { + const array = node.asKindOrThrow(SyntaxKind.ArrayLiteralExpression); + const elements = array.getElements().map(element => convertOperatorsAST(element, ctx)).join(', '); + return `[${elements}]`; + } + + // Handle object literals + case SyntaxKind.ObjectLiteralExpression: { + const obj = node.asKindOrThrow(SyntaxKind.ObjectLiteralExpression); + const properties = obj.getProperties().map(prop => { + if (prop.getKind() === SyntaxKind.PropertyAssignment) { + const propAssignment = prop.asKindOrThrow(SyntaxKind.PropertyAssignment); + const nameNode = propAssignment.getNameNode(); + const initNode = propAssignment.getInitializer(); + const initializer = initNode ? convertOperatorsAST(initNode, ctx) : ''; + // Handle computed property names: [PID_MINIGUN] -> PID_MINIGUN + if (nameNode.getKind() === SyntaxKind.ComputedPropertyName) { + const computed = nameNode.asKindOrThrow(SyntaxKind.ComputedPropertyName); + const expr = computed.getExpression(); + return `${expr.getText()}: ${initializer}`; + } + // String literal key - already quoted, use as-is + if (nameNode.getKind() === SyntaxKind.StringLiteral) { + return `${nameNode.getText()}: ${initializer}`; + } + // Numeric literal key - no quotes needed + if (nameNode.getKind() === SyntaxKind.NumericLiteral) { + return `${nameNode.getText()}: ${initializer}`; + } + // Identifier key - add quotes + return `"${propAssignment.getName()}": ${initializer}`; + } + return prop.getText(); + }).join(', '); + return `{${properties}}`; + } + + // Handle conditional expressions (ternary) + case SyntaxKind.ConditionalExpression: { + const conditional = node.asKindOrThrow(SyntaxKind.ConditionalExpression); + const condition = convertOperatorsAST(conditional.getCondition(), ctx); + const whenTrue = convertOperatorsAST(conditional.getWhenTrue(), ctx); + const whenFalse = convertOperatorsAST(conditional.getWhenFalse(), ctx); + return `(${condition}) ? ${whenTrue} : ${whenFalse}`; + } + + // Handle property access - strip folib_exports. prefix + case SyntaxKind.PropertyAccessExpression: { + const propAccess = node.asKindOrThrow(SyntaxKind.PropertyAccessExpression); + const obj = propAccess.getExpression().getText(); + const prop = propAccess.getName(); + + // Check for forbidden globals + if (FORBIDDEN_GLOBALS.has(obj)) { + throw new Error(`${obj}.${prop} is not available in SSL runtime`); + } + + // Strip folib_exports. or similar _exports. prefixes + if (obj.endsWith('_exports')) { + return prop; + } + return `${convertOperatorsAST(propAccess.getExpression(), ctx)}.${prop}`; + } + + // Handle element access (array indexing) - need to process the index expression + case SyntaxKind.ElementAccessExpression: { + const elemAccess = node.asKindOrThrow(SyntaxKind.ElementAccessExpression); + const obj = convertOperatorsAST(elemAccess.getExpression(), ctx); + const arg = elemAccess.getArgumentExpression(); + const index = arg ? convertOperatorsAST(arg, ctx) : ''; + return `${obj}[${index}]`; + } + + // Handle call expressions which might contain operators in arguments + case SyntaxKind.CallExpression: { + const call = node.asKindOrThrow(SyntaxKind.CallExpression); + const callExpr = call.getExpression(); + const fnName = convertOperatorsAST(callExpr, ctx); + + // Special handling for list() and map() - convert to SSL array/map literals + if (fnName === 'list') { + const args = call.getArguments().map(arg => convertOperatorsAST(arg, ctx)); + return `[${args.join(', ')}]`; + } + if (fnName === 'map') { + // map() takes a single object argument, just output it directly + const mapArgs = call.getArguments(); + if (mapArgs.length === 1) { + return convertOperatorsAST(mapArgs[0], ctx); + } + } + + const args = call.getArguments().map(arg => convertOperatorsAST(arg, ctx)); + + // For zero-arg inline macros, don't use parentheses (only if ctx available) + if (ctx) { + const inlineFunc = ctx.inlineFunctions.get(fnName); + if (args.length === 0 && inlineFunc?.params.length === 0) { + return fnName; + } + + // In SSL, external functions (declarations) with no args don't use parentheses + if (args.length === 0 && !ctx.definedFunctions.has(fnName)) { + return fnName; + } + } + + return `${fnName}(${args.join(', ')})`; + } + + case SyntaxKind.NumericLiteral: { + const text = node.getText(); + // Preserve float literals - if it has a decimal point, keep it + // If it's an integer but was originally written as X.0, esbuild strips the .0 + // We can't recover that, but we can ensure numbers with decimals stay as floats + if (text.includes('.')) { + return text; + } + // For integers, just return as-is + return text; + } + + case SyntaxKind.Identifier: { + const text = node.getText(); + // FLOAT1 is a special constant that forces float division + // esbuild strips .0 from float literals, so we use FLOAT1 * a / b + // to ensure float division in SSL output + if (text === 'FLOAT1') return '1.0'; + return text; + } + + default: + // If no operators to convert, return the original text + return node.getText(); + } +}