From a5cbc45d2acd5224128a6ce47638dd02b250ad17 Mon Sep 17 00:00:00 2001 From: Magus Date: Mon, 22 Dec 2025 00:41:43 +0700 Subject: [PATCH 01/12] TSSL, initial implementation --- .gitignore | 2 + .vscode/settings.json | 3 + client/src/extension.ts | 1 + package.json | 2 +- server/package.json | 13 +- server/pnpm-lock.yaml | 986 ++++++++++++++++++++++++++++++ server/src/compile.ts | 8 +- server/src/tssl.ts | 1262 +++++++++++++++++++++++++++++++++++++++ 8 files changed, 2269 insertions(+), 8 deletions(-) create mode 100644 server/pnpm-lock.yaml create mode 100644 server/src/tssl.ts 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/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..ec0083a 100644 --- a/server/package.json +++ b/server/package.json @@ -15,23 +15,24 @@ }, "dependencies": { "@rollup/plugin-typescript": "^12.1.2", + "@rollup/rollup-darwin-arm64": "^4.30.1", + "@rollup/rollup-darwin-x64": "^4.30.1", + "@rollup/rollup-linux-x64-gnu": "^4.30.1", + "@rollup/rollup-win32-x64-msvc": "^4.30.1", "@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..584b707 --- /dev/null +++ b/server/pnpm-lock.yaml @@ -0,0 +1,986 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@rollup/plugin-typescript': + specifier: ^12.1.2 + version: 12.3.0(rollup@4.53.3)(tslib@2.8.1)(typescript@4.9.5) + '@rollup/rollup-darwin-arm64': + specifier: ^4.30.1 + version: 4.53.3 + '@rollup/rollup-darwin-x64': + specifier: ^4.30.1 + version: 4.53.3 + '@rollup/rollup-linux-x64-gnu': + specifier: ^4.30.1 + version: 4.53.3 + '@rollup/rollup-win32-x64-msvc': + specifier: ^4.30.1 + version: 4.53.3 + '@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 + rollup: + specifier: ^4.30.1 + version: 4.53.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'} + + '@rollup/plugin-typescript@12.3.0': + resolution: {integrity: sha512-7DP0/p7y3t67+NabT9f8oTBFE6gGkto4SA6Np2oudYmZE/m1dt8RB0SjL1msMxFpLo631qjRCcBlAbq1ml/Big==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^2.14.0||^3.0.0||^4.0.0 + tslib: '*' + typescript: '>=3.7.0' + peerDependenciesMeta: + rollup: + optional: true + tslib: + optional: true + + '@rollup/pluginutils@5.3.0': + resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.53.3': + resolution: {integrity: sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.53.3': + resolution: {integrity: sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.53.3': + resolution: {integrity: sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.53.3': + resolution: {integrity: sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.53.3': + resolution: {integrity: sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.53.3': + resolution: {integrity: sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.53.3': + resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.53.3': + resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.53.3': + resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.53.3': + resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.53.3': + resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.53.3': + resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.53.3': + resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.53.3': + resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.53.3': + resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.53.3': + resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.53.3': + resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openharmony-arm64@4.53.3': + resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.53.3': + resolution: {integrity: sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.53.3': + resolution: {integrity: sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.53.3': + resolution: {integrity: sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.53.3': + resolution: {integrity: sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==} + cpu: [x64] + os: [win32] + + '@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/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@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 + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + 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'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + 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'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + 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-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + 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==} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + 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==} + + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + + 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'} + + rollup@4.53.3: + resolution: {integrity: sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + 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==} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + 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 + + '@rollup/plugin-typescript@12.3.0(rollup@4.53.3)(tslib@2.8.1)(typescript@4.9.5)': + dependencies: + '@rollup/pluginutils': 5.3.0(rollup@4.53.3) + resolve: 1.22.11 + typescript: 4.9.5 + optionalDependencies: + rollup: 4.53.3 + tslib: 2.8.1 + + '@rollup/pluginutils@5.3.0(rollup@4.53.3)': + dependencies: + '@types/estree': 1.0.8 + estree-walker: 2.0.2 + picomatch: 4.0.3 + optionalDependencies: + rollup: 4.53.3 + + '@rollup/rollup-android-arm-eabi@4.53.3': + optional: true + + '@rollup/rollup-android-arm64@4.53.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.53.3': {} + + '@rollup/rollup-darwin-x64@4.53.3': {} + + '@rollup/rollup-freebsd-arm64@4.53.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.53.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.53.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.53.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.53.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.53.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.53.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.53.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.53.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.53.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.53.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.53.3': {} + + '@rollup/rollup-linux-x64-musl@4.53.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.53.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.53.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.53.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.53.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.53.3': {} + + '@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/estree@1.0.8': {} + + '@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: {} + + estree-walker@2.0.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 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-east-asian-width@1.4.0: {} + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + 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-core-module@2.16.1: + dependencies: + hasown: 2.0.2 + + 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: {} + + path-parse@1.0.7: {} + + 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 + + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + reusify@1.1.0: {} + + rollup@4.53.3: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.53.3 + '@rollup/rollup-android-arm64': 4.53.3 + '@rollup/rollup-darwin-arm64': 4.53.3 + '@rollup/rollup-darwin-x64': 4.53.3 + '@rollup/rollup-freebsd-arm64': 4.53.3 + '@rollup/rollup-freebsd-x64': 4.53.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.53.3 + '@rollup/rollup-linux-arm-musleabihf': 4.53.3 + '@rollup/rollup-linux-arm64-gnu': 4.53.3 + '@rollup/rollup-linux-arm64-musl': 4.53.3 + '@rollup/rollup-linux-loong64-gnu': 4.53.3 + '@rollup/rollup-linux-ppc64-gnu': 4.53.3 + '@rollup/rollup-linux-riscv64-gnu': 4.53.3 + '@rollup/rollup-linux-riscv64-musl': 4.53.3 + '@rollup/rollup-linux-s390x-gnu': 4.53.3 + '@rollup/rollup-linux-x64-gnu': 4.53.3 + '@rollup/rollup-linux-x64-musl': 4.53.3 + '@rollup/rollup-openharmony-arm64': 4.53.3 + '@rollup/rollup-win32-arm64-msvc': 4.53.3 + '@rollup/rollup-win32-ia32-msvc': 4.53.3 + '@rollup/rollup-win32-x64-gnu': 4.53.3 + '@rollup/rollup-win32-x64-msvc': 4.53.3 + fsevents: 2.3.3 + + 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 + + supports-preserve-symlinks-flag@1.0.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..bb96aa0 100644 --- a/server/src/compile.ts +++ b/server/src/compile.ts @@ -4,6 +4,7 @@ import { conlog, isDirectory, tmpDir } from "./common"; import * as fallout from "./fallout"; import { connection, getDocumentSettings } from "./server"; import * as tbaf from "./tbaf"; +import * as tssl from "./tssl"; import * as weidu from "./weidu"; /** Only these languages can be compiled */ @@ -70,7 +71,12 @@ export async function compile(uri: string, langId: string, interactive = false, } if (langId == "typescript") { - tbaf.compile(uri, text); + if (uri.toLowerCase().endsWith(".tbaf")) { + tbaf.compile(uri, text); + } + if (uri.toLowerCase().endsWith(".tssl")) { + tssl.compile(uri, text); + } return; } diff --git a/server/src/tssl.ts b/server/src/tssl.ts new file mode 100644 index 0000000..a981bd3 --- /dev/null +++ b/server/src/tssl.ts @@ -0,0 +1,1262 @@ +import * as fs from "fs"; +import * as path from "path"; +import { + Project, + SourceFile, + SyntaxKind, + Node +} from 'ts-morph'; +import { conlog, uriToPath } from "./common"; +import { connection } from "./server"; +import * as esbuild from 'esbuild-wasm'; + +export const EXT_TSSL = ".tssl"; + +/** 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', +]); + +/** + * 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[]; +} + +let esbuildInitialized = false; +async function initEsbuild() { + if (esbuildInitialized) return; + // In Node.js, esbuild-wasm auto-detects the wasm file location + await esbuild.initialize({}); + esbuildInitialized = true; +} + +/** + * 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 + * @param text Source text content + */ +export async function compile(uri: string, text: string) { + try { + const filePath = uriToPath(uri); + const parsed = path.parse(filePath); + if (parsed.ext.toLowerCase() != EXT_TSSL) { + const msg = `${uri} is not a .tssl file, cannot process!`; + conlog(msg); + connection.window.showInformationMessage(msg); + return; + } + + // 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`); + + let bundledCode = await bundle(filePath, text); + + // Strip ESM module boilerplate from esbuild output + bundledCode = cleanupEsbuildOutput(bundledCode); + + // Create source file in memory from cleaned bundled code + const sourceFile = project.createSourceFile("bundled.ts", bundledCode, { overwrite: true }); + + // Extract inline functions from original source files (before bundling stripped comments) + ctx.inlineFunctions = extractInlineFunctionsFromDir(project, parsed.dir); + conlog(`Found ${ctx.inlineFunctions.size} inline functions: ${[...ctx.inlineFunctions.keys()].join(', ')}`); + + // Save to SSL file, same directory + const sslName = path.join(parsed.dir, `${parsed.name}.ssl`); + exportSSL(sourceFile, sslName, parsed.base, mainFileData, ctx); + connection.window.showInformationMessage(`Transpiled to ${sslName}.`); + } catch (error) { + conlog("Error compiling TSSL: " + error); + const errorMsg = (error instanceof Error) ? error.message : String(error); + connection.window.showErrorMessage(`Failed to compile: ${errorMsg}`); + } +} + +/** + * 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 source files. + * Scans node_modules for library files with @inline markers. + * @param project ts-morph Project instance to reuse + * @param dir Directory to scan for node_modules + */ +function extractInlineFunctionsFromDir(project: Project, dir: string): Map { + const result = new Map(); + + // Find all .ts files in node_modules (excluding .d.ts) + const tsFiles: string[] = []; + function scanDir(d: string) { + if (!fs.existsSync(d)) return; + for (const entry of fs.readdirSync(d, { withFileTypes: true })) { + const fullPath = path.join(d, entry.name); + if (entry.isDirectory()) { + scanDir(fullPath); + } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) { + tsFiles.push(fullPath); + } + } + } + scanDir(path.join(dir, 'node_modules')); + + for (const filePath of tsFiles) { + 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); + } + } + }); +} + +/** + * Bundle functions with esbuild, returning bundled code in memory. + * @param filePath Original file path (for resolving imports) + * @param text Source text + * @returns Bundled code as string + */ +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 initEsbuild(); + 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 + format: 'esm', + treeShaking: true, + minify: false, + keepNames: false, + target: 'es2022', + platform: 'neutral', + // Mark .d.ts imports as external - they're engine builtins + plugins: [{ + name: 'external-declarations', + setup(build) { + build.onResolve({ filter: /\.d(\.ts)?$/ }, args => ({ + path: args.path, + external: true + })); + } + }] + }); + + if (result.outputFiles && result.outputFiles.length > 0) { + conlog(`Bundling complete!`); + return result.outputFiles[0].text; + } + throw new Error('esbuild produced no output'); +} + +/** + * Clean up esbuild output by stripping everything before our marker, + * then handling import aliases and removing import statements. + */ +function cleanupEsbuildOutput(bundledCode: string): string { + // Strip everything before our marker (esbuild runtime helpers, etc.) + const markerIndex = bundledCode.indexOf(TSSL_CODE_MARKER); + if (markerIndex !== -1) { + bundledCode = bundledCode.substring(markerIndex + TSSL_CODE_MARKER.length).trimStart(); + } + + // Handle import aliases - esbuild renames duplicate external imports + // e.g., import { dude_obj as dude_obj2 } from "folib/base.d"; + const aliasMap = new Map(); + const importRegex = /import\s*\{([^}]+)\}\s*from\s*['"][^'"]+['"]\s*;?/g; + let match; + while ((match = importRegex.exec(bundledCode)) !== null) { + const imports = match[1].split(','); + for (const imp of imports) { + const asMatch = imp.trim().match(/^(\w+)\s+as\s+(\w+)$/); + if (asMatch) { + aliasMap.set(asMatch[2], asMatch[1]); + } + } + } + for (const [alias, original] of aliasMap) { + // Escape regex special characters in alias + const escapedAlias = alias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + bundledCode = bundledCode.replace(new RegExp(`\\b${escapedAlias}\\b`, 'g'), original); + } + + // Remove import statements + bundledCode = bundledCode.replace(/import\s+[\s\S]*?from\s+['"][^'"]+['"];?\s*/g, ''); + + return bundledCode; +} + +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; + + 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(); + 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: + result += `${indent}/* TSSL: unhandled statement type ${stmt.getKindName()} */\n`; + result += `${indent}${stmt.getText().trim()}\n`; + 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(); + + // 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 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}`; + } + // Regular property name - output unquoted + 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); + 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(); + } +} From a3788c700a32c8b6d2c8279e482a048866df6ee5 Mon Sep 17 00:00:00 2001 From: Magus Date: Mon, 22 Dec 2025 19:20:20 +0700 Subject: [PATCH 02/12] tssl - fix symlink handling and property quoting --- server/src/tssl.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/server/src/tssl.ts b/server/src/tssl.ts index a981bd3..9e39842 100644 --- a/server/src/tssl.ts +++ b/server/src/tssl.ts @@ -132,7 +132,7 @@ export async function compile(uri: string, text: string) { // Extract inline functions from original source files (before bundling stripped comments) ctx.inlineFunctions = extractInlineFunctionsFromDir(project, parsed.dir); - conlog(`Found ${ctx.inlineFunctions.size} inline functions: ${[...ctx.inlineFunctions.keys()].join(', ')}`); + conlog(`Found ${ctx.inlineFunctions.size} inline functions`); // Save to SSL file, same directory const sslName = path.join(parsed.dir, `${parsed.name}.ssl`); @@ -263,7 +263,9 @@ function extractInlineFunctionsFromDir(project: Project, dir: string): Map Date: Tue, 30 Dec 2025 02:07:49 +0700 Subject: [PATCH 03/12] =?UTF-8?q?1.=20showInfo/showError=20wrappers=20-=20?= =?UTF-8?q?Shorter=20aliases=20for=20connection.window.*=20methods=202.=20?= =?UTF-8?q?bundle()=20returns=20metafile=20data=20-=20Returns=20{code,=20i?= =?UTF-8?q?nputFiles}=20instead=20of=20just=20code=20string=203.=20extract?= =?UTF-8?q?InlineFunctionsFromFiles=20-=20Uses=20esbuild's=20metafile=20in?= =?UTF-8?q?put=20list=20instead=20of=20scanning=20all=20of=20node=5Fmodule?= =?UTF-8?q?s=20directory=204.=20AST-based=20cleanupEsbuildOutput=20-=20Use?= =?UTF-8?q?s=20ts-morph=20instead=20of=20regex=20for=20alias=20resolution?= =?UTF-8?q?=20and=20import=20removal=205.=20Collision=20detection=20-=20Ha?= =?UTF-8?q?ndles=20esbuild's=20name2=20=E2=86=92=20name22=20renaming=20pat?= =?UTF-8?q?tern=206.=20Numeric=20literal=20keys=20-=20Object=20keys=20like?= =?UTF-8?q?=20{1:=20value}=20no=20longer=20get=20quoted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/tssl.ts | 135 +++++++++++++++++++++++++++------------------ 1 file changed, 82 insertions(+), 53 deletions(-) diff --git a/server/src/tssl.ts b/server/src/tssl.ts index 9e39842..cd68610 100644 --- a/server/src/tssl.ts +++ b/server/src/tssl.ts @@ -6,12 +6,15 @@ import { SyntaxKind, Node } from 'ts-morph'; +import * as esbuild from 'esbuild-wasm'; import { conlog, uriToPath } from "./common"; import { connection } from "./server"; -import * as esbuild from 'esbuild-wasm'; export const EXT_TSSL = ".tssl"; +const showInfo = (msg: string) => connection.window.showInformationMessage(msg); +const showError = (msg: string) => connection.window.showErrorMessage(msg); + /** Marker to identify start of user code in esbuild output */ const TSSL_CODE_MARKER = "/* __TSSL_CODE_START__ */"; @@ -94,7 +97,7 @@ export async function compile(uri: string, text: string) { if (parsed.ext.toLowerCase() != EXT_TSSL) { const msg = `${uri} is not a .tssl file, cannot process!`; conlog(msg); - connection.window.showInformationMessage(msg); + showInfo(msg); return; } @@ -122,26 +125,26 @@ export async function compile(uri: string, text: string) { extractJsDocs(mainSource, ctx); conlog(`Extracted JSDoc for ${ctx.functionJsDocs.size} functions from main file`); - let bundledCode = await bundle(filePath, text); + const bundleResult = await bundle(filePath, text); // Strip ESM module boilerplate from esbuild output - bundledCode = cleanupEsbuildOutput(bundledCode); + const bundledCode = cleanupEsbuildOutput(bundleResult.code, project); // Create source file in memory from cleaned bundled code const sourceFile = project.createSourceFile("bundled.ts", bundledCode, { overwrite: true }); - // Extract inline functions from original source files (before bundling stripped comments) - ctx.inlineFunctions = extractInlineFunctionsFromDir(project, parsed.dir); + // 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 sslName = path.join(parsed.dir, `${parsed.name}.ssl`); exportSSL(sourceFile, sslName, parsed.base, mainFileData, ctx); - connection.window.showInformationMessage(`Transpiled to ${sslName}.`); + showInfo(`Transpiled to ${sslName}.`); } catch (error) { conlog("Error compiling TSSL: " + error); const errorMsg = (error instanceof Error) ? error.message : String(error); - connection.window.showErrorMessage(`Failed to compile: ${errorMsg}`); + showError(`Failed to compile: ${errorMsg}`); } } @@ -249,32 +252,16 @@ function extractPreserveFunctions(text: string): string[] { } /** - * Extract functions marked with @inline JSDoc tag from source files. - * Scans node_modules for library files with @inline markers. + * 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 dir Directory to scan for node_modules + * @param inputFiles List of input file paths from esbuild metafile */ -function extractInlineFunctionsFromDir(project: Project, dir: string): Map { +function extractInlineFunctionsFromFiles(project: Project, inputFiles: string[]): Map { const result = new Map(); - // Find all .ts files in node_modules (excluding .d.ts) - const tsFiles: string[] = []; - function scanDir(d: string) { - if (!fs.existsSync(d)) return; - for (const entry of fs.readdirSync(d, { withFileTypes: true })) { - const fullPath = path.join(d, entry.name); - // Use stat to follow symlinks - const stat = fs.statSync(fullPath); - if (stat.isDirectory()) { - scanDir(fullPath); - } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) { - tsFiles.push(fullPath); - } - } - } - scanDir(path.join(dir, 'node_modules')); - - for (const filePath of tsFiles) { + for (const filePath of inputFiles) { + if (!fs.existsSync(filePath)) continue; const source = project.addSourceFileAtPath(filePath); extractInlineFunctionsFromSource(source, result); } @@ -411,13 +398,18 @@ function extractJsDocs(sourceFile: SourceFile, ctx: TsslContext): void { }); } +interface BundleResult { + code: string; + inputFiles: string[]; +} + /** - * Bundle functions with esbuild, returning bundled code in memory. + * 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 as string + * @returns Bundled code and list of input files from metafile */ -async function bundle(filePath: string, text: string): Promise { +async function bundle(filePath: string, text: string): Promise { const preserveFunctions = extractPreserveFunctions(text); // Prepend marker and append fake usage to preserve functions from tree-shaking @@ -434,6 +426,7 @@ async function bundle(filePath: string, text: string): Promise { }, bundle: true, write: false, // Return output in memory + metafile: true, // Get list of input files format: 'esm', treeShaking: true, minify: false, @@ -454,46 +447,78 @@ async function bundle(filePath: string, text: string): Promise { if (result.outputFiles && result.outputFiles.length > 0) { conlog(`Bundling complete!`); - return result.outputFiles[0].text; + // 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'); } /** * Clean up esbuild output by stripping everything before our marker, - * then handling import aliases and removing import statements. + * then handling import aliases using AST and removing import statements. */ -function cleanupEsbuildOutput(bundledCode: string): string { +function cleanupEsbuildOutput(bundledCode: string, project: Project): string { // Strip everything before our marker (esbuild runtime helpers, etc.) const markerIndex = bundledCode.indexOf(TSSL_CODE_MARKER); if (markerIndex !== -1) { bundledCode = bundledCode.substring(markerIndex + TSSL_CODE_MARKER.length).trimStart(); } - // Handle import aliases - esbuild renames duplicate external imports - // e.g., import { dude_obj as dude_obj2 } from "folib/base.d"; + // Parse the bundled code + const sourceFile = project.createSourceFile("esbuild-output.ts", bundledCode, { overwrite: true }); + + // Build alias map from import statements const aliasMap = new Map(); - const importRegex = /import\s*\{([^}]+)\}\s*from\s*['"][^'"]+['"]\s*;?/g; - let match; - while ((match = importRegex.exec(bundledCode)) !== null) { - const imports = match[1].split(','); - for (const imp of imports) { - const asMatch = imp.trim().match(/^(\w+)\s+as\s+(\w+)$/); - if (asMatch) { - aliasMap.set(asMatch[2], asMatch[1]); + for (const importDecl of sourceFile.getImportDeclarations()) { + for (const named of importDecl.getNamedImports()) { + const alias = named.getAliasNode(); + if (alias) { + // import { original as alias } → alias should become original + 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 { critter_inven_obj as critter_inven_obj2 } causes bundled + // critter_inven_obj2 to become critter_inven_obj22 + // In this case: rename critter_inven_obj22→critter_inven_obj2, and DON'T + // rename critter_inven_obj2→critter_inven_obj (that would cause collision) + const allIdentifiers = new Set(); + sourceFile.getDescendantsOfKind(SyntaxKind.Identifier).forEach(id => { + allIdentifiers.add(id.getText()); + }); + for (const [alias] of [...aliasMap]) { + // Look for alias + digits pattern in identifiers + for (const id of allIdentifiers) { + if (id.startsWith(alias) && id !== alias && /^\d+$/.test(id.slice(alias.length))) { + if (!aliasMap.has(id)) { + // Add mapping for the collision-renamed identifier + aliasMap.set(id, alias); + // Remove the original alias mapping - 'alias' is the real function name + aliasMap.delete(alias); + } } } } - for (const [alias, original] of aliasMap) { - // Escape regex special characters in alias - const escapedAlias = alias.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - bundledCode = bundledCode.replace(new RegExp(`\\b${escapedAlias}\\b`, 'g'), original); + + // 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 statements - bundledCode = bundledCode.replace(/import\s+[\s\S]*?from\s+['"][^'"]+['"];?\s*/g, ''); + // Remove import declarations + sourceFile.getImportDeclarations().forEach(decl => decl.remove()); - return bundledCode; + return sourceFile.getFullText(); } interface SourceSection { @@ -1173,6 +1198,10 @@ function convertOperatorsAST(node: Node, ctx?: TsslContext): string { 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}`; } From 302c46a0d2815be0083696dec4486d299e905c55 Mon Sep 17 00:00:00 2001 From: Magus Date: Tue, 30 Dec 2025 02:58:16 +0700 Subject: [PATCH 04/12] Auto compile ssl with tssl, also show message, but make it less verbose --- server/src/fallout.ts | 4 ++-- server/src/tssl.ts | 13 ++++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) 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/tssl.ts b/server/src/tssl.ts index cd68610..64999e0 100644 --- a/server/src/tssl.ts +++ b/server/src/tssl.ts @@ -7,8 +7,9 @@ import { Node } from 'ts-morph'; import * as esbuild from 'esbuild-wasm'; -import { conlog, uriToPath } from "./common"; -import { connection } from "./server"; +import { conlog, pathToUri, uriToPath } from "./common"; +import * as fallout from "./fallout"; +import { connection, getDocumentSettings } from "./server"; export const EXT_TSSL = ".tssl"; @@ -140,7 +141,13 @@ export async function compile(uri: string, text: string) { // Save to SSL file, same directory const sslName = path.join(parsed.dir, `${parsed.name}.ssl`); exportSSL(sourceFile, sslName, parsed.base, mainFileData, ctx); - showInfo(`Transpiled to ${sslName}.`); + showInfo(`Transpiled to ${parsed.name}.ssl`); + + // Compile the generated SSL file + const sslUri = pathToUri(sslName); + const sslText = fs.readFileSync(sslName, 'utf-8'); + const settings = await getDocumentSettings(uri); + await fallout.compile(sslUri, settings.falloutSSL, true, sslText); } catch (error) { conlog("Error compiling TSSL: " + error); const errorMsg = (error instanceof Error) ? error.message : String(error); From c79fe36158c5afabea7144b830e5d4fb49f54a65 Mon Sep 17 00:00:00 2001 From: Magus Date: Tue, 30 Dec 2025 11:36:05 +0700 Subject: [PATCH 05/12] Move compile tasks to compile --- server/src/compile.ts | 16 +++++- server/src/tssl.ts | 119 ++++++++++++++++++------------------------ 2 files changed, 66 insertions(+), 69 deletions(-) diff --git a/server/src/compile.ts b/server/src/compile.ts index bb96aa0..fabc3e0 100644 --- a/server/src/compile.ts +++ b/server/src/compile.ts @@ -1,6 +1,7 @@ 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"; @@ -75,7 +76,18 @@ export async function compile(uri: string, langId: string, interactive = false, tbaf.compile(uri, text); } if (uri.toLowerCase().endsWith(".tssl")) { - tssl.compile(uri, text); + 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/tssl.ts b/server/src/tssl.ts index 64999e0..25b8877 100644 --- a/server/src/tssl.ts +++ b/server/src/tssl.ts @@ -7,14 +7,12 @@ import { Node } from 'ts-morph'; import * as esbuild from 'esbuild-wasm'; -import { conlog, pathToUri, uriToPath } from "./common"; -import * as fallout from "./fallout"; -import { connection, getDocumentSettings } from "./server"; +import { fileURLToPath } from "url"; export const EXT_TSSL = ".tssl"; -const showInfo = (msg: string) => connection.window.showInformationMessage(msg); -const showError = (msg: string) => connection.window.showErrorMessage(msg); +// TODO: use conlog instead of console.log (requires refactoring conlog to not depend on server.ts) +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__ */"; @@ -88,71 +86,58 @@ const SOURCE_COMMENT_LOOKBACK = 10; /** * Convert TSSL to SSL. - * @param uri VSCode document URI + * @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) { - try { - const filePath = uriToPath(uri); - const parsed = path.parse(filePath); - if (parsed.ext.toLowerCase() != EXT_TSSL) { - const msg = `${uri} is not a .tssl file, cannot process!`; - conlog(msg); - showInfo(msg); - return; - } +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(); + // 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), - }; + // 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, - }; + // 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, project); - - // 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 sslName = path.join(parsed.dir, `${parsed.name}.ssl`); - exportSSL(sourceFile, sslName, parsed.base, mainFileData, ctx); - showInfo(`Transpiled to ${parsed.name}.ssl`); - - // Compile the generated SSL file - const sslUri = pathToUri(sslName); - const sslText = fs.readFileSync(sslName, 'utf-8'); - const settings = await getDocumentSettings(uri); - await fallout.compile(sslUri, settings.falloutSSL, true, sslText); - } catch (error) { - conlog("Error compiling TSSL: " + error); - const errorMsg = (error instanceof Error) ? error.message : String(error); - showError(`Failed to compile: ${errorMsg}`); - } + // Extract JSDoc from main source file before bundling (esbuild strips them) + const mainSource = project.addSourceFileAtPath(filePath); + extractJsDocs(mainSource, ctx); + console.log(`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, project); + + // 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); + console.log(`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; } /** @@ -453,7 +438,7 @@ async function bundle(filePath: string, text: string): Promise { }); if (result.outputFiles && result.outputFiles.length > 0) { - conlog(`Bundling complete!`); + console.log(`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')) @@ -545,7 +530,7 @@ interface SourceSection { * @param ctx Transpilation context */ function exportSSL(sourceFile: SourceFile, sslPath: string, sourceName: string, mainFileData: MainFileData, ctx: TsslContext): void { - conlog(`Starting conversion of: ${sourceName}`); + console.log(`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); @@ -620,7 +605,7 @@ function exportSSL(sourceFile: SourceFile, sslPath: string, sourceName: string, // Write the content to the specified file fs.writeFileSync(sslPath, output, 'utf-8'); - conlog(`Content saved to ${sslPath}`); + console.log(`Content saved to ${sslPath}`); } /** From b008259c49f4651572851da4fbca60a1889eef44 Mon Sep 17 00:00:00 2001 From: Magus Date: Wed, 31 Dec 2025 00:42:55 +0700 Subject: [PATCH 06/12] Cleanup tbaf code smells --- server/src/tbaf.ts | 554 ++++++++++++++++++++++++++------------------- 1 file changed, 317 insertions(+), 237 deletions(-) diff --git a/server/src/tbaf.ts b/server/src/tbaf.ts index e407540..50cc37e 100644 --- a/server/src/tbaf.ts +++ b/server/src/tbaf.ts @@ -12,6 +12,7 @@ import { IfStatement, Node, ParenthesizedExpression, + PrefixUnaryExpression, Project, ReturnStatement, SourceFile, @@ -75,7 +76,7 @@ export async function compile(uri: string, text: string) { // 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}`); + conlog(`\nTransformed code saved to ${TMP_FINAL}`); // Save to BAF file, same directory const dirName = path.parse(filePath).dir; @@ -91,136 +92,86 @@ export async function compile(uri: string, text: string) { * For tracking variable values in context */ type varsContext = Map; + /** * Inline and unroll loops and other constructs. + * Uses collect-then-transform pattern to avoid AST mutation during traversal. * @param sourceFile The source file to modify. */ function inlineUnroll(sourceFile: SourceFile) { const functionDeclarations = sourceFile.getFunctions(); - const variablesContext: varsContext = new Map(); // Track const declarations + const variablesContext: varsContext = new Map(); - // Collect variables - sourceFile.forEachDescendant(node => { + // Step 1: Collect const variable declarations (no mutation, just gathering info) + collectConstVariables(sourceFile, variablesContext); - 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; - } + // Step 2: Flatten spread elements in array literals (collect then transform) + const arrayLiterals = sourceFile.getDescendantsOfKind(SyntaxKind.ArrayLiteralExpression); + // Process in reverse order to avoid position shifts + for (let i = arrayLiterals.length - 1; i >= 0; i--) { + flattenSpreadForNode(arrayLiterals[i], variablesContext); + } - // Evaluate and replace spread expressions in arrays - case SyntaxKind.ArrayLiteralExpression: { - evaluateAndReplaceSpreadExpressions(node as ArrayLiteralExpression, variablesContext); - break; - } + // Step 3: Unroll for...of loops (collect then transform) + const forOfStatements = sourceFile.getDescendantsOfKind(SyntaxKind.ForOfStatement); + for (let i = forOfStatements.length - 1; i >= 0; i--) { + unrollForOfLoop(forOfStatements[i], variablesContext); + } - default: - break; - } - }); -} + // Step 4: Unroll for loops (collect then transform) + const forStatements = sourceFile.getDescendantsOfKind(SyntaxKind.ForStatement); + for (let i = forStatements.length - 1; i >= 0; i--) { + unrollForLoop(forStatements[i], variablesContext); + } -// 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(", ")} ]`; + // Step 5: Handle function calls - substitute variables and inline (collect then transform) + const callExpressions = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression); + for (let i = callExpressions.length - 1; i >= 0; i--) { + const callExpr = callExpressions[i]; + // Check if node is still valid (might have been removed by previous inlining) + if (callExpr.wasForgotten()) continue; + + substituteVariables(callExpr, variablesContext); + + const functionName = callExpr.getExpression().getText(); + if (functionDeclarations.some(func => func.getName() === functionName)) { + inlineFunction(callExpr, functionDeclarations, variablesContext); + } } - 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. + * Collect const variable declarations into the context. + * This pass only reads, doesn't modify the AST. */ -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 - } +function collectConstVariables(sourceFile: SourceFile, variablesContext: varsContext) { + const varDeclarations = sourceFile.getDescendantsOfKind(SyntaxKind.VariableDeclaration); + + for (const variableDeclaration of varDeclarations) { + const parentDeclarationList = variableDeclaration.getParent() as VariableDeclarationList; + if (parentDeclarationList.getDeclarationKind() === VariableDeclarationKind.Const) { + const name = variableDeclaration.getName(); + const init = variableDeclaration.getInitializer(); + if (init) { + const initializerText = getCleanInitializerText(init); + if (initializerText.includes("...")) { + conlog(`Skipping collection for ${name} because initializer still contains spread.`); } else { - evaluatedArray.push(element.getText()); // Keep unresolved spreads + variablesContext.set(name, initializerText); + conlog(`Collected const variable: ${name} = ${initializerText}`); } } - } 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}`); +// 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(); } /** @@ -233,7 +184,7 @@ function substituteVariables(callExpression: CallExpression, vars: Map ${substitution}`); + conlog(`Substituting variable in argument: ${argText} -> ${substitution}`); arg.replaceWithText(substitution); } }); @@ -241,12 +192,12 @@ function substituteVariables(callExpression: CallExpression, vars: Map literal.getText() === arrayExpression); if (!arrayLiteral) { - console.log("Not a literal array, skipping..."); + conlog("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); + conlog("Loop Variable:", loopVariable); // Get body statements inside the loop const statement = forOfStatement.getStatement(); @@ -364,8 +315,8 @@ function unrollForOfLoop(forOfStatement: ForOfStatement, vars: varsContext) { ? statement.getStatements() : [statement]; - console.log("Body Statements:"); - bodyStatements.forEach(stmt => console.log(stmt.getText())); + conlog("Body Statements:"); + bodyStatements.forEach(stmt => conlog(stmt.getText())); // Unroll the loop const unrolledStatements = arrayLiteral.getElements().map(element => { @@ -384,8 +335,8 @@ function unrollForOfLoop(forOfStatement: ForOfStatement, vars: varsContext) { }).join("\n"); }); - console.log("Unrolled Statements:"); - console.log(unrolledStatements.join("\n")); + conlog("Unrolled Statements:"); + conlog(unrolledStatements.join("\n")); // Replace the original loop with unrolled statements forOfStatement.replaceWithText(unrolledStatements.join("\n")); @@ -416,80 +367,195 @@ async function bundle(input: string, text: string) { format: 'esm', }); - console.log('Bundling complete!'); + conlog('Bundling complete!'); } /** - * Convert all ELSE statement to IF statements with inverted conditions, like BAF needs - * @param sourceFile + * Convert all ELSE statement to IF statements with inverted conditions, like BAF needs. + * Uses collect-then-transform pattern to avoid AST mutation during traversal. + * @param sourceFile */ function convertElseToIf(sourceFile: SourceFile) { - console.log("Starting transformation on source file:", sourceFile.getFilePath()); + conlog("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(); + // Collect all if statements with else blocks + const ifStatements = sourceFile.getDescendantsOfKind(SyntaxKind.IfStatement) + .filter(ifStmt => ifStmt.getElseStatement() !== undefined); - console.log("Found if statement:", ifStatement.getText()); - if (elseStatement) { - console.log("Found else statement:", elseStatement.getText()); + // Process in reverse order to avoid position shifts + for (let i = ifStatements.length - 1; i >= 0; i--) { + const ifStatement = ifStatements[i]; + if (ifStatement.wasForgotten()) continue; - const ifCondition = ifStatement.getExpression().getText(); - console.log("Original condition:", ifCondition); + const elseStatement = ifStatement.getElseStatement(); + if (!elseStatement) continue; - // Create the original `if-0` block (unchanged `if` part) - const if0Block = `if (${ifCondition}) ${ifStatement.getThenStatement().getText()}`; - console.log("if-0 block:", if0Block); + conlog("Found if statement:", ifStatement.getText()); + conlog("Found else statement:", elseStatement.getText()); - // Invert the `else` condition to create `if-1` - const invertedCondition = invertCondition(ifCondition); - console.log("Inverted condition for else block (if-1):", invertedCondition); + const ifCondition = ifStatement.getExpression().getText(); + conlog("Original condition:", ifCondition); - const if1Block = `if (${invertedCondition}) ${elseStatement.getText()}`; - console.log("if-1 block:", if1Block); + // Create the original `if-0` block (unchanged `if` part) + const if0Block = `if (${ifCondition}) ${ifStatement.getThenStatement().getText()}`; + conlog("if-0 block:", if0Block); - // Combine `if-0` and `if-1` blocks - const newIfBlock = `${if0Block}\n${if1Block}`; - console.log("Combined new if block (if-0 + if-1):\n", newIfBlock); + // Invert the `else` condition to create `if-1` + const invertedCondition = invertCondition(ifCondition); + conlog("Inverted condition for else block (if-1):", invertedCondition); - // 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."); - } - } - }); + const if1Block = `if (${invertedCondition}) ${elseStatement.getText()}`; + conlog("if-1 block:", if1Block); - console.log("Transformation completed."); + // Combine `if-0` and `if-1` blocks + const newIfBlock = `${if0Block}\n${if1Block}`; + conlog("Combined new if block (if-0 + if-1):\n", newIfBlock); + + // Replace the original if-else block with the new combined block + ifStatement.replaceWithText(newIfBlock); + conlog("Replaced original if-else block with new combined if block."); + } + + conlog("Transformation completed."); } /** - * Invert logical condition. Only supports simple conditions, without nesting. + * Invert logical condition using AST-based De Morgan's law transformation. + * Properly handles nested conditions like (a && b) || c. + * + * Note: BAF requires CNF (Conjunctive Normal Form): AND of (ORs or atoms). + * Inverting some valid CNF conditions produces non-CNF results that BAF can't represent. + * For example: (a || b) && c → (!a && !b) || !c (DNF, not CNF) + * * @param condition Logical condition from an IF statement * @returns inverted condition text */ function invertCondition(condition: string): string { - console.log("Inverting condition:", condition); + conlog("Inverting condition:", condition); + + // Parse the condition as an expression using ts-morph + const project = new Project({ useInMemoryFileSystem: true }); + const tempFile = project.createSourceFile("temp.ts", `const _x_ = ${condition};`); + const varDecl = tempFile.getVariableDeclarations()[0]; + const expr = varDecl.getInitializerOrThrow(); + + const result = invertExpression(expr); + + // Validate result is valid CNF for BAF + if (!isValidCNF(result)) { + throw new Error( + `Cannot invert condition for BAF: "${condition}" → "${result}"\n` + + `The inverted condition is not valid CNF (Conjunctive Normal Form).\n` + + `BAF requires: AND of (atoms or OR-groups). Got OR containing AND.\n` + + `Consider simplifying or restructuring your if/else to avoid this pattern.` + ); + } + + return result; +} + +/** + * Check if a condition string is valid CNF for BAF. + * Valid: AND of (atoms or OR-groups), where OR-groups contain only atoms. + * Invalid: OR at top level containing AND, nested parens, etc. + */ +function isValidCNF(condition: string): boolean { + // Parse and check structure + try { + const project = new Project({ useInMemoryFileSystem: true }); + const tempFile = project.createSourceFile("temp2.ts", `const _x_ = ${condition};`); + const varDecl = tempFile.getVariableDeclarations()[0]; + const expr = varDecl.getInitializerOrThrow(); + + return checkCNF(expr, false); + } catch { + return false; + } +} + +/** + * Recursively check if expression is valid CNF. + * @param expr The expression to check + * @param insideOr Whether we're inside an OR group (AND not allowed inside OR) + */ +function checkCNF(expr: Expression, insideOr: boolean): boolean { + if (Node.isBinaryExpression(expr)) { + const opKind = expr.getOperatorToken().getKind(); + + if (opKind === SyntaxKind.AmpersandAmpersandToken) { + // AND is not allowed inside OR groups + if (insideOr) return false; + return checkCNF(expr.getLeft(), false) && checkCNF(expr.getRight(), false); + } + + if (opKind === SyntaxKind.BarBarToken) { + // OR is allowed, but contents must not have AND + return checkCNF(expr.getLeft(), true) && checkCNF(expr.getRight(), true); + } + } + + if (Node.isParenthesizedExpression(expr)) { + return checkCNF(expr.getExpression(), insideOr); + } + + if (Node.isPrefixUnaryExpression(expr)) { + // Negation of atom is fine, negation of complex expression is not + const operand = (expr as PrefixUnaryExpression).getOperand(); + return !Node.isBinaryExpression(operand); + } + + // Atoms (identifiers, calls, etc.) are always valid + return true; +} + +/** + * Recursively invert an expression using De Morgan's law. + * - a && b → !a || !b + * - a || b → !a && !b + * - !a → a + * - (expr) → (inverted expr) with parens preserved where needed + * - other → !(other) + */ +function invertExpression(expr: Expression): string { + // Handle binary expressions (&&, ||) + if (Node.isBinaryExpression(expr)) { + const left = expr.getLeft(); + const right = expr.getRight(); + const opKind = expr.getOperatorToken().getKind(); + + if (opKind === SyntaxKind.AmpersandAmpersandToken) { + // a && b → !a || !b + return `${invertExpression(left)} || ${invertExpression(right)}`; + } + if (opKind === SyntaxKind.BarBarToken) { + // a || b → !a && !b + return `${invertExpression(left)} && ${invertExpression(right)}`; + } + } - // Handle cases with '&&' and '||' (De Morgan's law) - if (condition.includes('&&')) { - return condition - .split('&&') - .map(part => `!(${part.trim()})`) - .join(' || '); + // Handle prefix unary ! (double negation elimination) + if (Node.isPrefixUnaryExpression(expr)) { + const prefixExpr = expr as PrefixUnaryExpression; + if (prefixExpr.getOperatorToken() === SyntaxKind.ExclamationToken) { + // !a → a + return prefixExpr.getOperand().getText(); + } } - if (condition.includes('||')) { - return condition - .split('||') - .map(part => `!(${part.trim()})`) - .join(' && '); + // Handle parenthesized expressions + if (Node.isParenthesizedExpression(expr)) { + const inner = expr.getExpression(); + const inverted = invertExpression(inner); + // Keep parens if the inner expression is a binary expression (for clarity) + if (Node.isBinaryExpression(inner)) { + return `(${inverted})`; + } + return inverted; } - // For simple conditions (without && or ||), just negate it - return `!(${condition.trim()})`; + // Default: wrap with !() + return `!(${expr.getText()})`; } /** @@ -528,7 +594,7 @@ function flattenSpreadForNode(arrayLiteral: ArrayLiteralExpression, vars?: Map { - 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)); - } - }); + // Collect top-level if statements + const ifStatements = sourceFile.getStatements() + .filter(stmt => stmt.isKind(SyntaxKind.IfStatement)) as IfStatement[]; + + // Process in reverse order to avoid position shifts + for (let i = ifStatements.length - 1; i >= 0; i--) { + const ifStatement = ifStatements[i]; + if (ifStatement.wasForgotten()) continue; + + // Flatten the nested if statements recursively + const flattenIf = (ifStmt: IfStatement, parentCondition = ""): string => { + const thenStatement = ifStmt.getThenStatement(); + const currentCondition = ifStmt.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(); + + for (const statement of blockStatements) { + 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()}`; - } + if (nestedIfs.length === 0) { + return `if (${combinedCondition}) ${thenStatement.getText()}`; + } - return nestedIfs.join("\n"); - }; + return nestedIfs.join("\n"); + }; - // Call the inner flattening function and replace the original statement - const flattenedIf = flattenIf(ifStatement); - ifStatement.replaceWithText(flattenedIf); - } - }); + // Call the inner flattening function and replace the original statement + const flattenedIf = flattenIf(ifStatement); + ifStatement.replaceWithText(flattenedIf); + } } @@ -596,13 +666,13 @@ 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."); + conlog("Skipping complex initializer."); return; } const declarations = initializer.getDeclarations(); if (declarations.length !== 1) { - console.log("Skipping multi-variable initializer."); + conlog("Skipping multi-variable initializer."); return; } @@ -618,7 +688,7 @@ function unrollForLoop(forStatement: ForStatement, vars: varsContext) { } if (isNaN(Number(initialValue))) { - console.log(`Skipping non-numeric initializer: ${initialValue}`); + conlog(`Skipping non-numeric initializer: ${initialValue}`); return; } @@ -627,7 +697,7 @@ function unrollForLoop(forStatement: ForStatement, vars: varsContext) { // Get the loop condition (e.g., `i < 10`) const condition = forStatement.getCondition(); if (!condition) { - console.log("Skipping loop with no condition."); + conlog("Skipping loop with no condition."); return; } @@ -635,18 +705,18 @@ function unrollForLoop(forStatement: ForStatement, vars: varsContext) { if (Node.isBinaryExpression(condition)) { const conditionValue = condition.getRight().getText(); if (isNaN(Number(conditionValue))) { - console.log(`Skipping loop with non-numeric boundary: ${conditionValue}`); + conlog(`Skipping loop with non-numeric boundary: ${conditionValue}`); return; } } else { - console.log("Skipping loop with unsupported condition type."); + conlog("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."); + conlog("Skipping loop with no incrementor."); return; } @@ -662,7 +732,7 @@ function unrollForLoop(forStatement: ForStatement, vars: varsContext) { } else if (incrementText.includes("-=")) { incrementValue = -Number(incrementText.split("-=")[1]); } else { - console.log("Skipping unsupported incrementor."); + conlog("Skipping unsupported incrementor."); return; } @@ -672,11 +742,11 @@ function unrollForLoop(forStatement: ForStatement, vars: varsContext) { ? 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); + conlog("Unrolling loop:", forStatement.getText()); + conlog("Loop Variable:", loopVar); + conlog("Initial Value:", currentValue); + conlog("Condition:", condition.getText()); + conlog("Incrementor:", incrementText); // Generate unrolled statements const unrolledStatements: string[] = []; @@ -701,8 +771,8 @@ function unrollForLoop(forStatement: ForStatement, vars: varsContext) { currentValue += incrementValue; } - console.log("Unrolled Statements:"); - console.log(unrolledStatements.join("\n")); + conlog("Unrolled Statements:"); + conlog(unrolledStatements.join("\n")); // Replace the original loop with unrolled statements forStatement.replaceWithText(unrolledStatements.join("\n")); @@ -710,6 +780,12 @@ function unrollForLoop(forStatement: ForStatement, vars: varsContext) { /** * Evaluate the loop condition by replacing the loop variable with its current value. + * + * Note: Uses `new Function()` which is similar to eval. This is acceptable here because: + * - TBAF is a development tool that transpiles the user's own code + * - Users already have full control over their development environment + * - No untrusted input is being processed + * * @param condition The loop condition as a string. * @param loopVar The loop variable. * @param currentValue The current value of the loop variable. @@ -718,11 +794,10 @@ function unrollForLoop(forStatement: ForStatement, vars: varsContext) { 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); + conlog("Error evaluating condition:", sanitizedCondition, error); return false; } } @@ -758,7 +833,7 @@ function exportBAF(sourceFile: SourceFile, bafPath: string, sourceName: string): 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}`); + conlog(`Content saved to ${bafPath}`); } /** @@ -844,14 +919,19 @@ function exportThenBlock(body: string): string { /** * Simplifies conditions in the given source file by removing unnecessary parentheses. + * Uses collect-then-transform pattern to avoid AST mutation during traversal. * @param sourceFile The source file to process. */ export function simplifyConditions(sourceFile: SourceFile) { - sourceFile.forEachDescendant((node) => { - if (Node.isParenthesizedExpression(node)) { - tryRemoveParentheses(node); - } - }); + // Collect all parenthesized expressions + const parenExprs = sourceFile.getDescendantsOfKind(SyntaxKind.ParenthesizedExpression); + + // Process in reverse order to avoid position shifts + for (let i = parenExprs.length - 1; i >= 0; i--) { + const parenExpr = parenExprs[i]; + if (parenExpr.wasForgotten()) continue; + tryRemoveParentheses(parenExpr); + } } /** @@ -919,7 +999,7 @@ function applyTransformations(sourceFile: SourceFile) { if (currentCode === previousCode) break; if (i == MAX_INTERATIONS) { - console.log("ERROR: reached max interactions, aborting!"); + conlog("ERROR: reached max interactions, aborting!"); } } @@ -946,12 +1026,12 @@ function removeFunctionDeclarations(sourceFile: SourceFile) { functionDeclarations.forEach(func => { try { - console.log(`Removing function: ${func.getName() || "anonymous"} at ${func.getStartLineNumber()}`); + conlog(`Removing function: ${func.getName() || "anonymous"} at ${func.getStartLineNumber()}`); func.remove(); } catch (error) { - console.error(`Error removing function: ${func.getName() || "anonymous"}`, error); + conlog(`Error removing function: ${func.getName() || "anonymous"}`, error); } }); - console.log(`Removed ${functionDeclarations.length} function(s).`); + conlog(`Removed ${functionDeclarations.length} function(s).`); } From 4981912f90bf8960255d1e2dcd00255f6d3ef215 Mon Sep 17 00:00:00 2001 From: Magus Date: Wed, 31 Dec 2025 02:31:24 +0700 Subject: [PATCH 07/12] Refactor tbaf, switch to esbuild --- .vscodeignore | 2 - server/package.json | 6 - server/pnpm-lock.yaml | 323 ----------- server/src/compile.ts | 2 +- server/src/tbaf.ts | 1037 ---------------------------------- server/src/tbaf/bundle.ts | 178 ++++++ server/src/tbaf/emit.ts | 70 +++ server/src/tbaf/index.ts | 126 +++++ server/src/tbaf/ir.ts | 46 ++ server/src/tbaf/transform.ts | 880 +++++++++++++++++++++++++++++ 10 files changed, 1301 insertions(+), 1369 deletions(-) delete mode 100644 server/src/tbaf.ts create mode 100644 server/src/tbaf/bundle.ts create mode 100644 server/src/tbaf/emit.ts create mode 100644 server/src/tbaf/index.ts create mode 100644 server/src/tbaf/ir.ts create mode 100644 server/src/tbaf/transform.ts 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/server/package.json b/server/package.json index ec0083a..8b2f4a6 100644 --- a/server/package.json +++ b/server/package.json @@ -14,15 +14,9 @@ "url": "https://github.com/BGforgeNet/VScode-BGforge-MLS/server" }, "dependencies": { - "@rollup/plugin-typescript": "^12.1.2", - "@rollup/rollup-darwin-arm64": "^4.30.1", - "@rollup/rollup-darwin-x64": "^4.30.1", - "@rollup/rollup-linux-x64-gnu": "^4.30.1", - "@rollup/rollup-win32-x64-msvc": "^4.30.1", "@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", diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml index 584b707..7d98c7d 100644 --- a/server/pnpm-lock.yaml +++ b/server/pnpm-lock.yaml @@ -8,21 +8,6 @@ importers: .: dependencies: - '@rollup/plugin-typescript': - specifier: ^12.1.2 - version: 12.3.0(rollup@4.53.3)(tslib@2.8.1)(typescript@4.9.5) - '@rollup/rollup-darwin-arm64': - specifier: ^4.30.1 - version: 4.53.3 - '@rollup/rollup-darwin-x64': - specifier: ^4.30.1 - version: 4.53.3 - '@rollup/rollup-linux-x64-gnu': - specifier: ^4.30.1 - version: 4.53.3 - '@rollup/rollup-win32-x64-msvc': - specifier: ^4.30.1 - version: 4.53.3 '@supercharge/promise-pool': specifier: ^2.3.2 version: 2.4.0 @@ -32,9 +17,6 @@ importers: fast-glob: specifier: ^3.2.12 version: 3.3.3 - rollup: - specifier: ^4.30.1 - version: 4.53.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 @@ -87,138 +69,6 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@rollup/plugin-typescript@12.3.0': - resolution: {integrity: sha512-7DP0/p7y3t67+NabT9f8oTBFE6gGkto4SA6Np2oudYmZE/m1dt8RB0SjL1msMxFpLo631qjRCcBlAbq1ml/Big==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^2.14.0||^3.0.0||^4.0.0 - tslib: '*' - typescript: '>=3.7.0' - peerDependenciesMeta: - rollup: - optional: true - tslib: - optional: true - - '@rollup/pluginutils@5.3.0': - resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - - '@rollup/rollup-android-arm-eabi@4.53.3': - resolution: {integrity: sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.53.3': - resolution: {integrity: sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.53.3': - resolution: {integrity: sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.53.3': - resolution: {integrity: sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.53.3': - resolution: {integrity: sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.53.3': - resolution: {integrity: sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.53.3': - resolution: {integrity: sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm-musleabihf@4.53.3': - resolution: {integrity: sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm64-gnu@4.53.3': - resolution: {integrity: sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-arm64-musl@4.53.3': - resolution: {integrity: sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-loong64-gnu@4.53.3': - resolution: {integrity: sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-ppc64-gnu@4.53.3': - resolution: {integrity: sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-riscv64-gnu@4.53.3': - resolution: {integrity: sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-riscv64-musl@4.53.3': - resolution: {integrity: sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-s390x-gnu@4.53.3': - resolution: {integrity: sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==} - cpu: [s390x] - os: [linux] - - '@rollup/rollup-linux-x64-gnu@4.53.3': - resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-linux-x64-musl@4.53.3': - resolution: {integrity: sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-openharmony-arm64@4.53.3': - resolution: {integrity: sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.53.3': - resolution: {integrity: sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.53.3': - resolution: {integrity: sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-gnu@4.53.3': - resolution: {integrity: sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==} - cpu: [x64] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.53.3': - resolution: {integrity: sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==} - cpu: [x64] - os: [win32] - '@supercharge/promise-pool@2.4.0': resolution: {integrity: sha512-O9CMipBlq5OObdt1uKJGIzm9cdjpPWfj+a+Zw9EgWKxaMNHKC7EU7X9taj3H0EGQNLOSq2jAcOa3EzxlfHsD6w==} engines: {node: '>=8'} @@ -226,9 +76,6 @@ packages: '@ts-morph/common@0.25.0': resolution: {integrity: sha512-kMnZz+vGGHi4GoHnLmMhGNjm44kGtKUXGnOvrKmMwAuvNjM/PgKVGfUnL7IDvK7Jb2QQ82jq3Zmp04Gy+r3Dkg==} - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - '@types/vscode@1.105.0': resolution: {integrity: sha512-Lotk3CTFlGZN8ray4VxJE7axIyLZZETQJVWi/lYoUVQuqfRxlQhVOfoejsD2V3dVXPSbS15ov5ZyowMAzgUqcw==} @@ -294,9 +141,6 @@ packages: engines: {node: '>=18'} hasBin: true - estree-walker@2.0.2: - resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} - fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} engines: {node: '>=8.6.0'} @@ -317,14 +161,6 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - function-bind@1.1.2: - resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - get-east-asian-width@1.4.0: resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} engines: {node: '>=18'} @@ -333,10 +169,6 @@ packages: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - http-proxy-agent@7.0.2: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} @@ -351,10 +183,6 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} - engines: {node: '>= 0.4'} - is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -425,9 +253,6 @@ packages: path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} - path-parse@1.0.7: - resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} - picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} @@ -445,11 +270,6 @@ packages: readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} - resolve@1.22.11: - resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} - engines: {node: '>= 0.4'} - hasBin: true - restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -458,11 +278,6 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rollup@4.53.3: - resolution: {integrity: sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -504,10 +319,6 @@ packages: strip-literal@1.3.0: resolution: {integrity: sha512-PugKzOsyXpArk0yWmUwqOZecSO0GH0bPoctLcqNDH9J04pVW3lflYE0ujElBGTloevcxF5MofAOZ7C5l2b+wLg==} - supports-preserve-symlinks-flag@1.0.0: - resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} - engines: {node: '>= 0.4'} - tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -579,85 +390,6 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.19.1 - '@rollup/plugin-typescript@12.3.0(rollup@4.53.3)(tslib@2.8.1)(typescript@4.9.5)': - dependencies: - '@rollup/pluginutils': 5.3.0(rollup@4.53.3) - resolve: 1.22.11 - typescript: 4.9.5 - optionalDependencies: - rollup: 4.53.3 - tslib: 2.8.1 - - '@rollup/pluginutils@5.3.0(rollup@4.53.3)': - dependencies: - '@types/estree': 1.0.8 - estree-walker: 2.0.2 - picomatch: 4.0.3 - optionalDependencies: - rollup: 4.53.3 - - '@rollup/rollup-android-arm-eabi@4.53.3': - optional: true - - '@rollup/rollup-android-arm64@4.53.3': - optional: true - - '@rollup/rollup-darwin-arm64@4.53.3': {} - - '@rollup/rollup-darwin-x64@4.53.3': {} - - '@rollup/rollup-freebsd-arm64@4.53.3': - optional: true - - '@rollup/rollup-freebsd-x64@4.53.3': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.53.3': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.53.3': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.53.3': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.53.3': - optional: true - - '@rollup/rollup-linux-loong64-gnu@4.53.3': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.53.3': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.53.3': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.53.3': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.53.3': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.53.3': {} - - '@rollup/rollup-linux-x64-musl@4.53.3': - optional: true - - '@rollup/rollup-openharmony-arm64@4.53.3': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.53.3': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.53.3': - optional: true - - '@rollup/rollup-win32-x64-gnu@4.53.3': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.53.3': {} - '@supercharge/promise-pool@2.4.0': {} '@ts-morph/common@0.25.0': @@ -666,8 +398,6 @@ snapshots: path-browserify: 1.0.1 tinyglobby: 0.2.15 - '@types/estree@1.0.8': {} - '@types/vscode@1.105.0': {} '@vscode/test-electron@2.5.2': @@ -716,8 +446,6 @@ snapshots: esbuild-wasm@0.24.2: {} - estree-walker@2.0.2: {} - fast-glob@3.3.3: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -738,21 +466,12 @@ snapshots: dependencies: to-regex-range: 5.0.1 - fsevents@2.3.3: - optional: true - - function-bind@1.1.2: {} - get-east-asian-width@1.4.0: {} glob-parent@5.1.2: dependencies: is-glob: 4.0.3 - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 @@ -771,10 +490,6 @@ snapshots: inherits@2.0.4: {} - is-core-module@2.16.1: - dependencies: - hasown: 2.0.2 - is-extglob@2.1.1: {} is-glob@4.0.3: @@ -842,8 +557,6 @@ snapshots: path-browserify@1.0.1: {} - path-parse@1.0.7: {} - picomatch@2.3.1: {} picomatch@4.0.3: {} @@ -862,12 +575,6 @@ snapshots: string_decoder: 1.1.1 util-deprecate: 1.0.2 - resolve@1.22.11: - dependencies: - is-core-module: 2.16.1 - path-parse: 1.0.7 - supports-preserve-symlinks-flag: 1.0.0 - restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -875,34 +582,6 @@ snapshots: reusify@1.1.0: {} - rollup@4.53.3: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.53.3 - '@rollup/rollup-android-arm64': 4.53.3 - '@rollup/rollup-darwin-arm64': 4.53.3 - '@rollup/rollup-darwin-x64': 4.53.3 - '@rollup/rollup-freebsd-arm64': 4.53.3 - '@rollup/rollup-freebsd-x64': 4.53.3 - '@rollup/rollup-linux-arm-gnueabihf': 4.53.3 - '@rollup/rollup-linux-arm-musleabihf': 4.53.3 - '@rollup/rollup-linux-arm64-gnu': 4.53.3 - '@rollup/rollup-linux-arm64-musl': 4.53.3 - '@rollup/rollup-linux-loong64-gnu': 4.53.3 - '@rollup/rollup-linux-ppc64-gnu': 4.53.3 - '@rollup/rollup-linux-riscv64-gnu': 4.53.3 - '@rollup/rollup-linux-riscv64-musl': 4.53.3 - '@rollup/rollup-linux-s390x-gnu': 4.53.3 - '@rollup/rollup-linux-x64-gnu': 4.53.3 - '@rollup/rollup-linux-x64-musl': 4.53.3 - '@rollup/rollup-openharmony-arm64': 4.53.3 - '@rollup/rollup-win32-arm64-msvc': 4.53.3 - '@rollup/rollup-win32-ia32-msvc': 4.53.3 - '@rollup/rollup-win32-x64-gnu': 4.53.3 - '@rollup/rollup-win32-x64-msvc': 4.53.3 - fsevents: 2.3.3 - run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -937,8 +616,6 @@ snapshots: dependencies: acorn: 8.15.0 - supports-preserve-symlinks-flag@1.0.0: {} - tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) diff --git a/server/src/compile.ts b/server/src/compile.ts index fabc3e0..5ceb916 100644 --- a/server/src/compile.ts +++ b/server/src/compile.ts @@ -4,7 +4,7 @@ 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"; diff --git a/server/src/tbaf.ts b/server/src/tbaf.ts deleted file mode 100644 index 50cc37e..0000000 --- a/server/src/tbaf.ts +++ /dev/null @@ -1,1037 +0,0 @@ -import * as fs from "fs"; -import * as path from "path"; -import { - ArrayLiteralExpression, - BinaryExpression, - Block, - CallExpression, - Expression, - ForOfStatement, - ForStatement, - FunctionDeclaration, - IfStatement, - Node, - ParenthesizedExpression, - PrefixUnaryExpression, - 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(); - conlog(`\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. - * Uses collect-then-transform pattern to avoid AST mutation during traversal. - * @param sourceFile The source file to modify. - */ -function inlineUnroll(sourceFile: SourceFile) { - const functionDeclarations = sourceFile.getFunctions(); - const variablesContext: varsContext = new Map(); - - // Step 1: Collect const variable declarations (no mutation, just gathering info) - collectConstVariables(sourceFile, variablesContext); - - // Step 2: Flatten spread elements in array literals (collect then transform) - const arrayLiterals = sourceFile.getDescendantsOfKind(SyntaxKind.ArrayLiteralExpression); - // Process in reverse order to avoid position shifts - for (let i = arrayLiterals.length - 1; i >= 0; i--) { - flattenSpreadForNode(arrayLiterals[i], variablesContext); - } - - // Step 3: Unroll for...of loops (collect then transform) - const forOfStatements = sourceFile.getDescendantsOfKind(SyntaxKind.ForOfStatement); - for (let i = forOfStatements.length - 1; i >= 0; i--) { - unrollForOfLoop(forOfStatements[i], variablesContext); - } - - // Step 4: Unroll for loops (collect then transform) - const forStatements = sourceFile.getDescendantsOfKind(SyntaxKind.ForStatement); - for (let i = forStatements.length - 1; i >= 0; i--) { - unrollForLoop(forStatements[i], variablesContext); - } - - // Step 5: Handle function calls - substitute variables and inline (collect then transform) - const callExpressions = sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression); - for (let i = callExpressions.length - 1; i >= 0; i--) { - const callExpr = callExpressions[i]; - // Check if node is still valid (might have been removed by previous inlining) - if (callExpr.wasForgotten()) continue; - - substituteVariables(callExpr, variablesContext); - - const functionName = callExpr.getExpression().getText(); - if (functionDeclarations.some(func => func.getName() === functionName)) { - inlineFunction(callExpr, functionDeclarations, variablesContext); - } - } -} - -/** - * Collect const variable declarations into the context. - * This pass only reads, doesn't modify the AST. - */ -function collectConstVariables(sourceFile: SourceFile, variablesContext: varsContext) { - const varDeclarations = sourceFile.getDescendantsOfKind(SyntaxKind.VariableDeclaration); - - for (const variableDeclaration of varDeclarations) { - const parentDeclarationList = variableDeclaration.getParent() as VariableDeclarationList; - if (parentDeclarationList.getDeclarationKind() === VariableDeclarationKind.Const) { - const name = variableDeclaration.getName(); - const init = variableDeclaration.getInitializer(); - if (init) { - const initializerText = getCleanInitializerText(init); - if (initializerText.includes("...")) { - conlog(`Skipping collection for ${name} because initializer still contains spread.`); - } else { - variablesContext.set(name, initializerText); - conlog(`Collected const variable: ${name} = ${initializerText}`); - } - } - } - } -} - -// 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(); -} - -/** - * 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)!; - conlog(`Substituting variable in argument: ${argText} -> ${substitution}`); - arg.replaceWithText(substitution); - } - }); -} - -function inlineFunction(callExpression: CallExpression, functionDeclarations: FunctionDeclaration[], vars: varsContext) { - const functionName = callExpression.getExpression().getText(); - conlog(`Processing function: ${functionName}`); - - const parent = callExpression.getParent(); - // Short-circuit if the function call is inverted with '!' - if (Node.isPrefixUnaryExpression(parent) && parent.getOperatorToken() === SyntaxKind.ExclamationToken) { - conlog(`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); - - conlog(`return text is ${returnText}`); - if (needsParentheses(returnText)) returnText = `(${returnText})`; - parent.replaceWithText(parent.getText().replace(callExpression.getText(), returnText)); - conlog(`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); - conlog(`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)!; - } - - conlog("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) { - conlog("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+/, ''); - conlog("Loop Variable:", loopVariable); - - // Get body statements inside the loop - const statement = forOfStatement.getStatement(); - const bodyStatements = statement.isKind(SyntaxKind.Block) - ? statement.getStatements() - : [statement]; - - conlog("Body Statements:"); - bodyStatements.forEach(stmt => conlog(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"); - }); - - conlog("Unrolled Statements:"); - conlog(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', - }); - - conlog('Bundling complete!'); -} - - -/** - * Convert all ELSE statement to IF statements with inverted conditions, like BAF needs. - * Uses collect-then-transform pattern to avoid AST mutation during traversal. - * @param sourceFile - */ -function convertElseToIf(sourceFile: SourceFile) { - conlog("Starting transformation on source file:", sourceFile.getFilePath()); - - // Collect all if statements with else blocks - const ifStatements = sourceFile.getDescendantsOfKind(SyntaxKind.IfStatement) - .filter(ifStmt => ifStmt.getElseStatement() !== undefined); - - // Process in reverse order to avoid position shifts - for (let i = ifStatements.length - 1; i >= 0; i--) { - const ifStatement = ifStatements[i]; - if (ifStatement.wasForgotten()) continue; - - const elseStatement = ifStatement.getElseStatement(); - if (!elseStatement) continue; - - conlog("Found if statement:", ifStatement.getText()); - conlog("Found else statement:", elseStatement.getText()); - - const ifCondition = ifStatement.getExpression().getText(); - conlog("Original condition:", ifCondition); - - // Create the original `if-0` block (unchanged `if` part) - const if0Block = `if (${ifCondition}) ${ifStatement.getThenStatement().getText()}`; - conlog("if-0 block:", if0Block); - - // Invert the `else` condition to create `if-1` - const invertedCondition = invertCondition(ifCondition); - conlog("Inverted condition for else block (if-1):", invertedCondition); - - const if1Block = `if (${invertedCondition}) ${elseStatement.getText()}`; - conlog("if-1 block:", if1Block); - - // Combine `if-0` and `if-1` blocks - const newIfBlock = `${if0Block}\n${if1Block}`; - conlog("Combined new if block (if-0 + if-1):\n", newIfBlock); - - // Replace the original if-else block with the new combined block - ifStatement.replaceWithText(newIfBlock); - conlog("Replaced original if-else block with new combined if block."); - } - - conlog("Transformation completed."); -} - -/** - * Invert logical condition using AST-based De Morgan's law transformation. - * Properly handles nested conditions like (a && b) || c. - * - * Note: BAF requires CNF (Conjunctive Normal Form): AND of (ORs or atoms). - * Inverting some valid CNF conditions produces non-CNF results that BAF can't represent. - * For example: (a || b) && c → (!a && !b) || !c (DNF, not CNF) - * - * @param condition Logical condition from an IF statement - * @returns inverted condition text - */ -function invertCondition(condition: string): string { - conlog("Inverting condition:", condition); - - // Parse the condition as an expression using ts-morph - const project = new Project({ useInMemoryFileSystem: true }); - const tempFile = project.createSourceFile("temp.ts", `const _x_ = ${condition};`); - const varDecl = tempFile.getVariableDeclarations()[0]; - const expr = varDecl.getInitializerOrThrow(); - - const result = invertExpression(expr); - - // Validate result is valid CNF for BAF - if (!isValidCNF(result)) { - throw new Error( - `Cannot invert condition for BAF: "${condition}" → "${result}"\n` + - `The inverted condition is not valid CNF (Conjunctive Normal Form).\n` + - `BAF requires: AND of (atoms or OR-groups). Got OR containing AND.\n` + - `Consider simplifying or restructuring your if/else to avoid this pattern.` - ); - } - - return result; -} - -/** - * Check if a condition string is valid CNF for BAF. - * Valid: AND of (atoms or OR-groups), where OR-groups contain only atoms. - * Invalid: OR at top level containing AND, nested parens, etc. - */ -function isValidCNF(condition: string): boolean { - // Parse and check structure - try { - const project = new Project({ useInMemoryFileSystem: true }); - const tempFile = project.createSourceFile("temp2.ts", `const _x_ = ${condition};`); - const varDecl = tempFile.getVariableDeclarations()[0]; - const expr = varDecl.getInitializerOrThrow(); - - return checkCNF(expr, false); - } catch { - return false; - } -} - -/** - * Recursively check if expression is valid CNF. - * @param expr The expression to check - * @param insideOr Whether we're inside an OR group (AND not allowed inside OR) - */ -function checkCNF(expr: Expression, insideOr: boolean): boolean { - if (Node.isBinaryExpression(expr)) { - const opKind = expr.getOperatorToken().getKind(); - - if (opKind === SyntaxKind.AmpersandAmpersandToken) { - // AND is not allowed inside OR groups - if (insideOr) return false; - return checkCNF(expr.getLeft(), false) && checkCNF(expr.getRight(), false); - } - - if (opKind === SyntaxKind.BarBarToken) { - // OR is allowed, but contents must not have AND - return checkCNF(expr.getLeft(), true) && checkCNF(expr.getRight(), true); - } - } - - if (Node.isParenthesizedExpression(expr)) { - return checkCNF(expr.getExpression(), insideOr); - } - - if (Node.isPrefixUnaryExpression(expr)) { - // Negation of atom is fine, negation of complex expression is not - const operand = (expr as PrefixUnaryExpression).getOperand(); - return !Node.isBinaryExpression(operand); - } - - // Atoms (identifiers, calls, etc.) are always valid - return true; -} - -/** - * Recursively invert an expression using De Morgan's law. - * - a && b → !a || !b - * - a || b → !a && !b - * - !a → a - * - (expr) → (inverted expr) with parens preserved where needed - * - other → !(other) - */ -function invertExpression(expr: Expression): string { - // Handle binary expressions (&&, ||) - if (Node.isBinaryExpression(expr)) { - const left = expr.getLeft(); - const right = expr.getRight(); - const opKind = expr.getOperatorToken().getKind(); - - if (opKind === SyntaxKind.AmpersandAmpersandToken) { - // a && b → !a || !b - return `${invertExpression(left)} || ${invertExpression(right)}`; - } - if (opKind === SyntaxKind.BarBarToken) { - // a || b → !a && !b - return `${invertExpression(left)} && ${invertExpression(right)}`; - } - } - - // Handle prefix unary ! (double negation elimination) - if (Node.isPrefixUnaryExpression(expr)) { - const prefixExpr = expr as PrefixUnaryExpression; - if (prefixExpr.getOperatorToken() === SyntaxKind.ExclamationToken) { - // !a → a - return prefixExpr.getOperand().getText(); - } - } - - // Handle parenthesized expressions - if (Node.isParenthesizedExpression(expr)) { - const inner = expr.getExpression(); - const inverted = invertExpression(inner); - // Keep parens if the inner expression is a binary expression (for clarity) - if (Node.isBinaryExpression(inner)) { - return `(${inverted})`; - } - return inverted; - } - - // Default: wrap with !() - return `!(${expr.getText()})`; -} - -/** - * 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()) { - conlog( - "Replacing array literal:", - arrayLiteral.getText(), - "with flattened version:", - newArrayText - ); - arrayLiteral.replaceWithText(newArrayText); - } -} - -/** - * Single function to flatten all nested if statements in the source file. - * Uses collect-then-transform pattern to avoid AST mutation during traversal. - * @param sourceFile - */ -function flattenIfStatements(sourceFile: SourceFile) { - // Collect top-level if statements - const ifStatements = sourceFile.getStatements() - .filter(stmt => stmt.isKind(SyntaxKind.IfStatement)) as IfStatement[]; - - // Process in reverse order to avoid position shifts - for (let i = ifStatements.length - 1; i >= 0; i--) { - const ifStatement = ifStatements[i]; - if (ifStatement.wasForgotten()) continue; - - // Flatten the nested if statements recursively - const flattenIf = (ifStmt: IfStatement, parentCondition = ""): string => { - const thenStatement = ifStmt.getThenStatement(); - const currentCondition = ifStmt.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(); - - for (const statement of blockStatements) { - 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)) { - conlog("Skipping complex initializer."); - return; - } - - const declarations = initializer.getDeclarations(); - if (declarations.length !== 1) { - conlog("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))) { - conlog(`Skipping non-numeric initializer: ${initialValue}`); - return; - } - - let currentValue = Number(initialValue); - - // Get the loop condition (e.g., `i < 10`) - const condition = forStatement.getCondition(); - if (!condition) { - conlog("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))) { - conlog(`Skipping loop with non-numeric boundary: ${conditionValue}`); - return; - } - } else { - conlog("Skipping loop with unsupported condition type."); - return; - } - - // Get the incrementor (e.g., `i++`, `i += 2`) - const incrementor = forStatement.getIncrementor(); - if (!incrementor) { - conlog("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 { - conlog("Skipping unsupported incrementor."); - return; - } - - // Get body statements - const statement = forStatement.getStatement(); - const bodyStatements = statement.isKind(SyntaxKind.Block) - ? statement.getStatements() - : [statement]; - - conlog("Unrolling loop:", forStatement.getText()); - conlog("Loop Variable:", loopVar); - conlog("Initial Value:", currentValue); - conlog("Condition:", condition.getText()); - conlog("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; - } - - conlog("Unrolled Statements:"); - conlog(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. - * - * Note: Uses `new Function()` which is similar to eval. This is acceptable here because: - * - TBAF is a development tool that transpiles the user's own code - * - Users already have full control over their development environment - * - No untrusted input is being processed - * - * @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 { - const fn = new Function(`return (${sanitizedCondition});`); - return fn(); - } catch (error) { - conlog("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 - conlog(`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. - * Uses collect-then-transform pattern to avoid AST mutation during traversal. - * @param sourceFile The source file to process. - */ -export function simplifyConditions(sourceFile: SourceFile) { - // Collect all parenthesized expressions - const parenExprs = sourceFile.getDescendantsOfKind(SyntaxKind.ParenthesizedExpression); - - // Process in reverse order to avoid position shifts - for (let i = parenExprs.length - 1; i >= 0; i--) { - const parenExpr = parenExprs[i]; - if (parenExpr.wasForgotten()) continue; - tryRemoveParentheses(parenExpr); - } -} - -/** - * 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) { - conlog("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 { - conlog(`Removing function: ${func.getName() || "anonymous"} at ${func.getStartLineNumber()}`); - func.remove(); - } catch (error) { - conlog(`Error removing function: ${func.getName() || "anonymous"}`, error); - } - }); - - conlog(`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..8d9846e --- /dev/null +++ b/server/src/tbaf/bundle.ts @@ -0,0 +1,178 @@ +/** + * TBAF Bundler + * + * Uses esbuild for in-memory bundling (replaces Rollup). + */ + +import * as esbuild from "esbuild-wasm"; +import * as path from "path"; +import * as fs from "fs"; +import { Project, SyntaxKind } from "ts-morph"; + +let esbuildInitialized = false; + +/** Marker to identify start of user code in esbuild output */ +const TBAF_CODE_MARKER = "/* __TBAF_CODE_START__ */"; + +/** + * Initialize esbuild (must be called once before bundling). + */ +async function ensureEsbuild() { + if (!esbuildInitialized) { + await esbuild.initialize({}); + esbuildInitialized = true; + } +} + +/** + * 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; + + // Create a virtual entry point + const result = await esbuild.build({ + stdin: { + contents: sourceWithMarker, + resolveDir, + sourcefile: filePath, + loader: "ts", + }, + bundle: true, + write: false, + format: "esm", + platform: "neutral", + target: "esnext", + // Don't minify - we need readable output for transformation + minify: false, + plugins: [ + // Mark node_modules as external + { + name: "external-node-modules", + setup(build) { + build.onResolve({ filter: /.*/ }, (args) => { + // External if it's in node_modules or is a bare import (no ./ or ../) + 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; // Let other resolvers handle it + }); + }, + }, + // Plugin to resolve .tbaf files as TypeScript + { + name: "tbaf-resolver", + setup(build) { + // Resolve .tbaf imports + build.onResolve({ filter: /\.tbaf$/ }, (args) => { + const resolved = path.resolve(args.resolveDir, args.path); + return { path: resolved, namespace: "tbaf" }; + }); + + // Load .tbaf files as TypeScript + build.onLoad({ filter: /.*/, namespace: "tbaf" }, (args) => { + const contents = fs.readFileSync(args.path, "utf-8"); + return { contents, loader: "ts" }; + }); + + // Also handle .ts imports that might exist + build.onResolve({ filter: /\.ts$/ }, (args) => { + const resolved = path.resolve(args.resolveDir, args.path); + if (fs.existsSync(resolved)) { + return { path: resolved }; + } + return null; + }); + }, + }, + ], + }); + + if (result.outputFiles && result.outputFiles.length > 0) { + let output = result.outputFiles[0].text; + + // Strip everything before our marker (esbuild runtime helpers like __defProp, __name) + const markerIndex = output.indexOf(TBAF_CODE_MARKER); + if (markerIndex !== -1) { + output = output.substring(markerIndex + TBAF_CODE_MARKER.length).trimStart(); + } + + // Clean up esbuild's import aliasing and name collision renaming + output = cleanupEsbuildOutput(output); + + return output; + } + + throw new Error("esbuild produced no output"); +} + +/** + * Clean up esbuild output by handling import aliases and collision renaming. + * esbuild renames identifiers when there are name collisions (e.g., See → See2). + */ +function cleanupEsbuildOutput(bundledCode: string): string { + const project = new Project({ useInMemoryFileSystem: true }); + const sourceFile = project.createSourceFile("esbuild-output.ts", bundledCode); + + // 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) { + // import { original as alias } → alias should become original + 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]) { + // Look for alias + digits pattern in identifiers + for (const id of allIdentifiers) { + if (id.startsWith(alias) && id !== alias && /^\d+$/.test(id.slice(alias.length))) { + if (!aliasMap.has(id)) { + // Add mapping for the collision-renamed identifier + aliasMap.set(id, alias); + // Remove the original alias mapping - 'alias' is the real function name + aliasMap.delete(alias); + } + } + } + } + + // 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(); +} 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..fb1bf55 --- /dev/null +++ b/server/src/tbaf/index.ts @@ -0,0 +1,126 @@ +/** + * 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 { connection } from "../server"; +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 + */ +export async function compile(uri: string, text: string): Promise { + const filePath = uriToPath(uri); + const ext = path.extname(filePath).toLowerCase(); + + if (ext !== EXT_TBAF) { + const msg = `${uri} is not a .tbaf file, cannot process!`; + conlog(msg); + connection.window.showErrorMessage(msg); + return; + } + + try { + // 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}`); + connection.window.showInformationMessage(`Transpiled to ${path.basename(bafPath)}`); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + conlog(`TBAF compile error: ${msg}`); + connection.window.showErrorMessage(`TBAF error: ${msg}`); + } +} + +/** + * 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..e707783 --- /dev/null +++ b/server/src/tbaf/transform.ts @@ -0,0 +1,880 @@ +/** + * 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"; + +/** 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) && iterations < TBAFTransformer.MAX_LOOP_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 - creates an OR group + const leftConds = this.invertExpression(expr.getLeft()); + const rightConds = this.invertExpression(expr.getRight()); + + // Flatten into single OR group + const allConditions: BAFCondition[] = []; + for (const c of [...leftConds, ...rightConds]) { + if ("conditions" in c) { + // Already an OR group - this means nested AND inside OR, which is invalid CNF + throw new Error( + `Cannot invert condition for BAF: result would not be valid CNF.\n` + + `Expression: ${expr.getText()}` + ); + } else { + allConditions.push(c); + } + } + + return [{ conditions: allConditions }]; + } + + 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 all conditions in a list, toggling their negated flags. + */ + private negateConditions(conditions: BAFTopCondition[]): BAFTopCondition[] { + return conditions.map(c => { + if ("conditions" in c) { + // OR group - negate all its conditions + return { + conditions: c.conditions.map(inner => ({ ...inner, negated: !inner.negated })), + }; + } + return { ...c, negated: !c.negated }; + }); + } + + /** + * 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 { + return false; + } + } + + /** + * 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: [], + }; + } +} From 792c3842da5cc967a4d52d546dfe55e5c940051e Mon Sep 17 00:00:00 2001 From: Magus Date: Wed, 31 Dec 2025 02:42:12 +0700 Subject: [PATCH 08/12] Deduplicate code between tbaf and tssl --- server/src/esbuild-utils.ts | 111 ++++++++++++++++++++++++++++++++++++ server/src/tbaf/bundle.ts | 95 ++---------------------------- server/src/tssl.ts | 77 +------------------------ 3 files changed, 118 insertions(+), 165 deletions(-) create mode 100644 server/src/esbuild-utils.ts diff --git a/server/src/esbuild-utils.ts b/server/src/esbuild-utils.ts new file mode 100644 index 0000000..2a901fd --- /dev/null +++ b/server/src/esbuild-utils.ts @@ -0,0 +1,111 @@ +/** + * 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. Renames identifiers back to originals + * 5. Removes import declarations + * + * @param code Bundled code from esbuild + * @param marker Marker string to find start of user code + * @returns Cleaned code + */ +export function cleanupEsbuildOutput(code: string, marker: string): 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); + } + } + } + } + + // 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 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/tbaf/bundle.ts b/server/src/tbaf/bundle.ts index 8d9846e..60fcf0c 100644 --- a/server/src/tbaf/bundle.ts +++ b/server/src/tbaf/bundle.ts @@ -1,29 +1,17 @@ /** * TBAF Bundler * - * Uses esbuild for in-memory bundling (replaces Rollup). + * Uses esbuild for in-memory bundling. */ import * as esbuild from "esbuild-wasm"; import * as path from "path"; import * as fs from "fs"; -import { Project, SyntaxKind } from "ts-morph"; - -let esbuildInitialized = false; +import { ensureEsbuild, cleanupEsbuildOutput } from "../esbuild-utils"; /** Marker to identify start of user code in esbuild output */ const TBAF_CODE_MARKER = "/* __TBAF_CODE_START__ */"; -/** - * Initialize esbuild (must be called once before bundling). - */ -async function ensureEsbuild() { - if (!esbuildInitialized) { - await esbuild.initialize({}); - esbuildInitialized = true; - } -} - /** * Bundle a TBAF file and its imports into a single TypeScript string. * @@ -39,7 +27,6 @@ export async function bundle(filePath: string, sourceText: string): Promise { - // External if it's in node_modules or is a bare import (no ./ or ../) if (args.path.includes("node_modules")) { return { path: args.path, external: true }; } @@ -68,7 +53,7 @@ export async function bundle(filePath: string, sourceText: string): Promise { const resolved = path.resolve(args.resolveDir, args.path); return { path: resolved, namespace: "tbaf" }; }); - // Load .tbaf files as TypeScript build.onLoad({ filter: /.*/, namespace: "tbaf" }, (args) => { const contents = fs.readFileSync(args.path, "utf-8"); return { contents, loader: "ts" }; }); - // Also handle .ts imports that might exist build.onResolve({ filter: /\.ts$/ }, (args) => { const resolved = path.resolve(args.resolveDir, args.path); if (fs.existsSync(resolved)) { @@ -102,77 +84,8 @@ export async function bundle(filePath: string, sourceText: string): Promise 0) { - let output = result.outputFiles[0].text; - - // Strip everything before our marker (esbuild runtime helpers like __defProp, __name) - const markerIndex = output.indexOf(TBAF_CODE_MARKER); - if (markerIndex !== -1) { - output = output.substring(markerIndex + TBAF_CODE_MARKER.length).trimStart(); - } - - // Clean up esbuild's import aliasing and name collision renaming - output = cleanupEsbuildOutput(output); - - return output; + return cleanupEsbuildOutput(result.outputFiles[0].text, TBAF_CODE_MARKER); } throw new Error("esbuild produced no output"); } - -/** - * Clean up esbuild output by handling import aliases and collision renaming. - * esbuild renames identifiers when there are name collisions (e.g., See → See2). - */ -function cleanupEsbuildOutput(bundledCode: string): string { - const project = new Project({ useInMemoryFileSystem: true }); - const sourceFile = project.createSourceFile("esbuild-output.ts", bundledCode); - - // 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) { - // import { original as alias } → alias should become original - 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]) { - // Look for alias + digits pattern in identifiers - for (const id of allIdentifiers) { - if (id.startsWith(alias) && id !== alias && /^\d+$/.test(id.slice(alias.length))) { - if (!aliasMap.has(id)) { - // Add mapping for the collision-renamed identifier - aliasMap.set(id, alias); - // Remove the original alias mapping - 'alias' is the real function name - aliasMap.delete(alias); - } - } - } - } - - // 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(); -} diff --git a/server/src/tssl.ts b/server/src/tssl.ts index 25b8877..dcd1b1b 100644 --- a/server/src/tssl.ts +++ b/server/src/tssl.ts @@ -8,6 +8,7 @@ import { } from 'ts-morph'; import * as esbuild from 'esbuild-wasm'; import { fileURLToPath } from "url"; +import { ensureEsbuild, cleanupEsbuildOutput } from "./esbuild-utils"; export const EXT_TSSL = ".tssl"; @@ -70,13 +71,6 @@ interface MainFileData { includes: string[]; } -let esbuildInitialized = false; -async function initEsbuild() { - if (esbuildInitialized) return; - // In Node.js, esbuild-wasm auto-detects the wasm file location - await esbuild.initialize({}); - esbuildInitialized = true; -} /** * How many lines to look backwards when searching for esbuild source comments. @@ -124,7 +118,7 @@ export async function compile(uri: string, text: string): Promise { const bundleResult = await bundle(filePath, text); // Strip ESM module boilerplate from esbuild output - const bundledCode = cleanupEsbuildOutput(bundleResult.code, project); + const bundledCode = cleanupEsbuildOutput(bundleResult.code, TSSL_CODE_MARKER); // Create source file in memory from cleaned bundled code const sourceFile = project.createSourceFile("bundled.ts", bundledCode, { overwrite: true }); @@ -408,7 +402,7 @@ async function bundle(filePath: string, text: string): Promise { const preserveCode = `\n// Preserve functions\nif ((globalThis as any).__preserve__) { console.log(${preserveFunctions.join(', ')}); }`; const sourceWithMarker = TSSL_CODE_MARKER + "\n" + text + preserveCode; - await initEsbuild(); + await ensureEsbuild(); const result = await esbuild.build({ stdin: { contents: sourceWithMarker, @@ -448,71 +442,6 @@ async function bundle(filePath: string, text: string): Promise { throw new Error('esbuild produced no output'); } -/** - * Clean up esbuild output by stripping everything before our marker, - * then handling import aliases using AST and removing import statements. - */ -function cleanupEsbuildOutput(bundledCode: string, project: Project): string { - // Strip everything before our marker (esbuild runtime helpers, etc.) - const markerIndex = bundledCode.indexOf(TSSL_CODE_MARKER); - if (markerIndex !== -1) { - bundledCode = bundledCode.substring(markerIndex + TSSL_CODE_MARKER.length).trimStart(); - } - - // Parse the bundled code - const sourceFile = project.createSourceFile("esbuild-output.ts", bundledCode, { overwrite: true }); - - // Build alias map from import statements - const aliasMap = new Map(); - for (const importDecl of sourceFile.getImportDeclarations()) { - for (const named of importDecl.getNamedImports()) { - const alias = named.getAliasNode(); - if (alias) { - // import { original as alias } → alias should become original - 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 { critter_inven_obj as critter_inven_obj2 } causes bundled - // critter_inven_obj2 to become critter_inven_obj22 - // In this case: rename critter_inven_obj22→critter_inven_obj2, and DON'T - // rename critter_inven_obj2→critter_inven_obj (that would cause collision) - const allIdentifiers = new Set(); - sourceFile.getDescendantsOfKind(SyntaxKind.Identifier).forEach(id => { - allIdentifiers.add(id.getText()); - }); - for (const [alias] of [...aliasMap]) { - // Look for alias + digits pattern in identifiers - for (const id of allIdentifiers) { - if (id.startsWith(alias) && id !== alias && /^\d+$/.test(id.slice(alias.length))) { - if (!aliasMap.has(id)) { - // Add mapping for the collision-renamed identifier - aliasMap.set(id, alias); - // Remove the original alias mapping - 'alias' is the real function name - aliasMap.delete(alias); - } - } - } - } - - // 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(); -} - interface SourceSection { source: string; defines: string[]; From 929a82dcb6e8efe37c8f912f2b495189cafa58d0 Mon Sep 17 00:00:00 2001 From: Magus Date: Wed, 31 Dec 2025 03:00:32 +0700 Subject: [PATCH 09/12] tbaf+tssl: raise errors; tbaf - chain compile when possible --- server/src/compile.ts | 15 +++++++++- server/src/tbaf/index.ts | 53 +++++++++++++++--------------------- server/src/tbaf/transform.ts | 16 +++++++++-- server/src/tssl.ts | 18 ++++++------ 4 files changed, 58 insertions(+), 44 deletions(-) diff --git a/server/src/compile.ts b/server/src/compile.ts index 5ceb916..54cef07 100644 --- a/server/src/compile.ts +++ b/server/src/compile.ts @@ -73,7 +73,20 @@ export async function compile(uri: string, langId: string, interactive = false, if (langId == "typescript") { if (uri.toLowerCase().endsWith(".tbaf")) { - tbaf.compile(uri, text); + 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 { diff --git a/server/src/tbaf/index.ts b/server/src/tbaf/index.ts index fb1bf55..c71eab1 100644 --- a/server/src/tbaf/index.ts +++ b/server/src/tbaf/index.ts @@ -8,7 +8,6 @@ import * as fs from "fs"; import * as path from "path"; import { Project } from "ts-morph"; import { conlog, uriToPath } from "../common"; -import { connection } from "../server"; import { bundle } from "./bundle"; import { emitBAF } from "./emit"; import { BAFScript, isOrGroup } from "./ir"; @@ -21,50 +20,42 @@ export const EXT_TBAF = ".tbaf"; * * @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 { +export async function compile(uri: string, text: string): Promise { const filePath = uriToPath(uri); const ext = path.extname(filePath).toLowerCase(); if (ext !== EXT_TBAF) { - const msg = `${uri} is not a .tbaf file, cannot process!`; - conlog(msg); - connection.window.showErrorMessage(msg); - return; + throw new Error(`${uri} is not a .tbaf file`); } - try { - // 1. Bundle imports - const bundled = await bundle(filePath, text); + // 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); + // 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); + // 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; + // Use original file path for the header comment + ir.sourceFile = filePath; - // 4. Apply BAF-specific fixups to IR - applyBAFFixups(ir); + // 4. Apply BAF-specific fixups to IR + applyBAFFixups(ir); - // 5. Emit BAF text - const baf = emitBAF(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"); + // 6. Write output + const bafPath = filePath.replace(/\.tbaf$/i, ".baf"); + fs.writeFileSync(bafPath, baf, "utf-8"); - conlog(`Transpiled to ${bafPath}`); - connection.window.showInformationMessage(`Transpiled to ${path.basename(bafPath)}`); - } catch (error) { - const msg = error instanceof Error ? error.message : String(error); - conlog(`TBAF compile error: ${msg}`); - connection.window.showErrorMessage(`TBAF error: ${msg}`); - } + conlog(`Transpiled to ${bafPath}`); + return bafPath; } /** diff --git a/server/src/tbaf/transform.ts b/server/src/tbaf/transform.ts index e707783..389a818 100644 --- a/server/src/tbaf/transform.ts +++ b/server/src/tbaf/transform.ts @@ -531,7 +531,14 @@ export class TBAFTransformer { let current = initValue; let iterations = 0; - while (this.evaluateLoopCondition(condition.getText(), loopVar, current) && iterations < TBAFTransformer.MAX_LOOP_ITERATIONS) { + 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; @@ -829,8 +836,11 @@ export class TBAFTransformer { try { const fn = new Function(`return (${substituted});`); return fn(); - } catch { - return false; + } catch (e) { + throw new Error( + `Cannot evaluate loop condition "${condition}" with ${loopVar}=${value}. ` + + `Substituted: "${substituted}". Error: ${e instanceof Error ? e.message : e}` + ); } } diff --git a/server/src/tssl.ts b/server/src/tssl.ts index dcd1b1b..789a466 100644 --- a/server/src/tssl.ts +++ b/server/src/tssl.ts @@ -9,10 +9,10 @@ import { import * as esbuild from 'esbuild-wasm'; import { fileURLToPath } from "url"; import { ensureEsbuild, cleanupEsbuildOutput } from "./esbuild-utils"; +import { conlog } from "./common"; +import { connection } from "./server"; export const EXT_TSSL = ".tssl"; - -// TODO: use conlog instead of console.log (requires refactoring conlog to not depend on server.ts) const uriToPath = (uri: string) => uri.startsWith('file://') ? fileURLToPath(uri) : uri; /** Marker to identify start of user code in esbuild output */ @@ -113,7 +113,7 @@ export async function compile(uri: string, text: string): Promise { // Extract JSDoc from main source file before bundling (esbuild strips them) const mainSource = project.addSourceFileAtPath(filePath); extractJsDocs(mainSource, ctx); - console.log(`Extracted JSDoc for ${ctx.functionJsDocs.size} functions from main file`); + conlog(`Extracted JSDoc for ${ctx.functionJsDocs.size} functions from main file`); const bundleResult = await bundle(filePath, text); @@ -125,7 +125,7 @@ export async function compile(uri: string, text: string): Promise { // Extract inline functions from files that were actually bundled ctx.inlineFunctions = extractInlineFunctionsFromFiles(project, bundleResult.inputFiles); - console.log(`Found ${ctx.inlineFunctions.size} inline functions`); + conlog(`Found ${ctx.inlineFunctions.size} inline functions`); // Save to SSL file, same directory const sslPath = path.join(parsed.dir, `${parsed.name}.ssl`); @@ -432,7 +432,7 @@ async function bundle(filePath: string, text: string): Promise { }); if (result.outputFiles && result.outputFiles.length > 0) { - console.log(`Bundling complete!`); + 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')) @@ -459,7 +459,7 @@ interface SourceSection { * @param ctx Transpilation context */ function exportSSL(sourceFile: SourceFile, sslPath: string, sourceName: string, mainFileData: MainFileData, ctx: TsslContext): void { - console.log(`Starting conversion of: ${sourceName}`); + 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); @@ -534,7 +534,7 @@ function exportSSL(sourceFile: SourceFile, sslPath: string, sourceName: string, // Write the content to the specified file fs.writeFileSync(sslPath, output, 'utf-8'); - console.log(`Content saved to ${sslPath}`); + conlog(`Content saved to ${sslPath}`); } /** @@ -823,6 +823,7 @@ function handleDoStatement(stmt: Node, indent: string, ctx: TsslContext): string 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`; @@ -900,8 +901,7 @@ function processFunctionBody(bodyNode: Node, indent: string = "", ctx: TsslConte result += `${indent}break;\n`; break; default: - result += `${indent}/* TSSL: unhandled statement type ${stmt.getKindName()} */\n`; - result += `${indent}${stmt.getText().trim()}\n`; + throw new Error(`Unhandled statement type: ${stmt.getKindName()}. Code: ${stmt.getText().substring(0, 100)}`); break; } } From 3672d5e634558f489ac17cfbae0ecce1b592d87d Mon Sep 17 00:00:00 2001 From: Magus Date: Wed, 31 Dec 2025 03:29:51 +0700 Subject: [PATCH 10/12] Tbaf: allow limited trigger inversion --- server/src/tbaf/cnf.ts | 126 +++++++++++++++++++++++++++++++++++ server/src/tbaf/transform.ts | 59 ++++++++++------ 2 files changed, 163 insertions(+), 22 deletions(-) create mode 100644 server/src/tbaf/cnf.ts 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/transform.ts b/server/src/tbaf/transform.ts index 389a818..c3864ef 100644 --- a/server/src/tbaf/transform.ts +++ b/server/src/tbaf/transform.ts @@ -24,6 +24,7 @@ import { SyntaxKind, } from "ts-morph"; import { BAFAction, BAFBlock, BAFCondition, BAFOrGroup, BAFScript, BAFTopCondition } from "./ir"; +import { dnfToCnf } from "./cnf"; /** Context for variable substitution */ type VarsContext = Map; @@ -628,25 +629,21 @@ export class TBAFTransformer { const opKind = expr.getOperatorToken().getKind(); if (opKind === SyntaxKind.AmpersandAmpersandToken) { - // !(a && b) → !a || !b - creates an OR group + // !(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()); - // Flatten into single OR group - const allConditions: BAFCondition[] = []; - for (const c of [...leftConds, ...rightConds]) { - if ("conditions" in c) { - // Already an OR group - this means nested AND inside OR, which is invalid CNF - throw new Error( - `Cannot invert condition for BAF: result would not be valid CNF.\n` + - `Expression: ${expr.getText()}` - ); - } else { - allConditions.push(c); - } + // 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]] }]; } - return [{ conditions: allConditions }]; + // Otherwise, use DNF→CNF conversion + return dnfToCnf([leftConds, rightConds]); } if (opKind === SyntaxKind.BarBarToken) { @@ -700,18 +697,36 @@ export class TBAFTransformer { } /** - * Negate all conditions in a list, toggling their negated flags. + * 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[] { - return conditions.map(c => { + // Build DNF terms: each term is a conjunction (negated Ci) + const terms: BAFTopCondition[][] = []; + + for (const c of conditions) { if ("conditions" in c) { - // OR group - negate all its conditions - return { - conditions: c.conditions.map(inner => ({ ...inner, negated: !inner.negated })), - }; + // 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 }]); } - return { ...c, negated: !c.negated }; - }); + } + + // Convert DNF (OR of terms) to CNF + return dnfToCnf(terms); } /** From 5278b6653c48e15d0978650febcb00f97a1fc69c Mon Sep 17 00:00:00 2001 From: Magus Date: Wed, 31 Dec 2025 18:28:55 +0700 Subject: [PATCH 11/12] Let tssl console log --- server/src/tssl.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/src/tssl.ts b/server/src/tssl.ts index 789a466..8329c2e 100644 --- a/server/src/tssl.ts +++ b/server/src/tssl.ts @@ -9,8 +9,9 @@ import { import * as esbuild from 'esbuild-wasm'; import { fileURLToPath } from "url"; import { ensureEsbuild, cleanupEsbuildOutput } from "./esbuild-utils"; -import { conlog } from "./common"; -import { connection } from "./server"; + +// 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; From fff9733a86f09dc2faa08eec39a2146752034a66 Mon Sep 17 00:00:00 2001 From: Magus Date: Thu, 1 Jan 2026 03:03:30 +0700 Subject: [PATCH 12/12] =?UTF-8?q?1.=20esbuild-utils.ts:=20=20=20-=20Added?= =?UTF-8?q?=20noSideEffectsPlugin()=20-=20shared=20plugin=20that=20marks?= =?UTF-8?q?=20all=20modules=20as=20side-effect-free=20for=20tree-shaking?= =?UTF-8?q?=20=20=20-=20Added=20constant=20renaming=20fix=20(DIK=5FF4=20?= =?UTF-8?q?=E2=86=92=20DIK=5FF42=20issue)=20using=20originalConstants=20pa?= =?UTF-8?q?rameter=202.=20tssl.ts:=20=20=20-=20Uses=20noSideEffectsPlugin(?= =?UTF-8?q?)=20for=20tree-shaking=20=20=20-=20Passes=20original=20constant?= =?UTF-8?q?s=20to=20cleanupEsbuildOutput()=20to=20fix=20esbuild=20renaming?= =?UTF-8?q?=203.=20tbaf/bundle.ts:=20=20=20-=20Uses=20shared=20noSideEffec?= =?UTF-8?q?tsPlugin()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/src/esbuild-utils.ts | 92 +++++++++++++++++++++++++++++++++++-- server/src/tbaf/bundle.ts | 3 +- server/src/tssl.ts | 66 +++++++++++++++++++++----- 3 files changed, 145 insertions(+), 16 deletions(-) diff --git a/server/src/esbuild-utils.ts b/server/src/esbuild-utils.ts index 2a901fd..6f51dd4 100644 --- a/server/src/esbuild-utils.ts +++ b/server/src/esbuild-utils.ts @@ -24,14 +24,16 @@ export async function ensureEsbuild(): Promise { * 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. Renames identifiers back to originals - * 5. Removes import declarations + * 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): string { +export function cleanupEsbuildOutput(code: string, marker: string, originalConstants?: Map): string { // Strip everything before marker const markerIndex = code.indexOf(marker); if (markerIndex !== -1) { @@ -61,6 +63,7 @@ export function cleanupEsbuildOutput(code: string, marker: string): string { 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))) { @@ -72,6 +75,64 @@ export function cleanupEsbuildOutput(code: string, marker: string): string { } } + // 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); @@ -87,6 +148,31 @@ export function cleanupEsbuildOutput(code: string, marker: string): string { 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. * diff --git a/server/src/tbaf/bundle.ts b/server/src/tbaf/bundle.ts index 60fcf0c..ddba2de 100644 --- a/server/src/tbaf/bundle.ts +++ b/server/src/tbaf/bundle.ts @@ -7,7 +7,7 @@ import * as esbuild from "esbuild-wasm"; import * as path from "path"; import * as fs from "fs"; -import { ensureEsbuild, cleanupEsbuildOutput } from "../esbuild-utils"; +import { ensureEsbuild, cleanupEsbuildOutput, noSideEffectsPlugin } from "../esbuild-utils"; /** Marker to identify start of user code in esbuild output */ const TBAF_CODE_MARKER = "/* __TBAF_CODE_START__ */"; @@ -80,6 +80,7 @@ export async function bundle(filePath: string, sourceText: string): Promise { const bundleResult = await bundle(filePath, text); // Strip ESM module boilerplate from esbuild output - const bundledCode = cleanupEsbuildOutput(bundleResult.code, TSSL_CODE_MARKER); + 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 }); @@ -420,16 +426,19 @@ async function bundle(filePath: string, text: string): Promise { keepNames: false, target: 'es2022', platform: 'neutral', - // Mark .d.ts imports as external - they're engine builtins - plugins: [{ - name: 'external-declarations', - setup(build) { - build.onResolve({ filter: /\.d(\.ts)?$/ }, args => ({ - path: args.path, - external: true - })); - } - }] + 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) { @@ -625,6 +634,9 @@ function processInput(source: SourceFile, mainFileData: MainFileData, ctx: TsslC // 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(); @@ -928,6 +940,18 @@ function processCallExpression(callExpr: Node, ctx: TsslContext): string { // 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)); @@ -985,6 +1009,10 @@ function convertVarOrConstToVariable(stmt: Node, ctx: TsslContext): string { 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); @@ -1173,6 +1201,20 @@ function convertOperatorsAST(node: Node, ctx?: TsslContext): string { 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)