diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b0e98e..2cd103b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: - name: Install Node.js uses: actions/setup-node@v4.2.0 with: - node-version: 16.x + node-version: 22.x - name: Install Godot (Ubuntu) if: matrix.os == 'ubuntu-latest' diff --git a/.vscode-test.js b/.vscode-test.js index d2759de..82a92de 100644 --- a/.vscode-test.js +++ b/.vscode-test.js @@ -2,7 +2,7 @@ const { defineConfig } = require('@vscode/test-cli'); module.exports = defineConfig( { - // version: '1.84.0', + // version: '1.96.4', label: 'unitTests', files: 'out/**/*.test.js', launchArgs: ['--disable-extensions'], diff --git a/.vscode/launch.json b/.vscode/launch.json index 4d288b0..8d914d3 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,7 +5,6 @@ { "version": "0.2.0", "configurations": [ - { "name": "Run Extension", "type": "extensionHost", @@ -48,5 +47,27 @@ "VSCODE_DEBUG_MODE": "true" } }, + { + "name": "Run Extension with local workspace file", + "type": "extensionHost", + "request": "launch", + "runtimeExecutable": "${execPath}", + "args": [ + "--profile=temp", + "--extensionDevelopmentPath=${workspaceFolder}", + "${workspaceFolder}/workspace.code-workspace" + ], + "outFiles": [ + "${workspaceFolder}/out/**/*.js" + ], + "skipFiles": [ + "**/extensionHostProcess.js", + "/**/*.js" + ], + "preLaunchTask": "npm: watch", + "env": { + "VSCODE_DEBUG_MODE": "true" + } + }, ] } diff --git a/package-lock.json b/package-lock.json index be3c355..827b7c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,24 +25,27 @@ }, "devDependencies": { "@types/chai": "^4.3.11", + "@types/chai-as-promised": "^8.0.1", "@types/chai-subset": "^1.3.5", "@types/marked": "^4.0.8", "@types/mocha": "^10.0.6", - "@types/node": "^18.15.0", + "@types/node": "^18.19.75", "@types/prismjs": "^1.16.8", "@types/vscode": "^1.96.0", "@types/ws": "^8.5.4", "@typescript-eslint/eslint-plugin": "^5.57.1", "@typescript-eslint/eslint-plugin-tslint": "^5.57.1", "@typescript-eslint/parser": "^5.57.1", - "@vscode/test-cli": "^0.0.4", + "@vscode/test-cli": "^0.0.10", "@vscode/test-electron": "^2.3.8", "@vscode/vsce": "^2.29.0", - "chai": "^4.3.10", + "chai": "^4.5.0", + "chai-as-promised": "^8.0.1", "chai-subset": "^1.6.0", "esbuild": "^0.17.15", "eslint": "^8.37.0", - "mocha": "^10.2.0", + "mocha": "^10.8.2", + "sinon": "^19.0.2", "ts-node": "^10.9.1", "tsconfig-paths": "^4.2.0", "tslint": "^5.20.1", @@ -381,6 +384,12 @@ "js-tokens": "^4.0.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -960,6 +969,15 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", @@ -1030,6 +1048,50 @@ "node": ">=14" } }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/commons/node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", + "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "lodash.get": "^4.4.2", + "type-detect": "^4.1.0" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz", + "integrity": "sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==", + "dev": true + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -1069,6 +1131,15 @@ "integrity": "sha512-qQR1dr2rGIHYlJulmr8Ioq3De0Le9E4MJ5AiaeAETJJpndT1uUNHsGFK3L/UIu+rbkQSdj8J/w2bCsBZc/Y5fQ==", "dev": true }, + "node_modules/@types/chai-as-promised": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@types/chai-as-promised/-/chai-as-promised-8.0.1.tgz", + "integrity": "sha512-dAlDhLjJlABwAVYObo9TPWYTRg9NaQM5CXeaeJYcYAkvzUf0JRLIiog88ao2Wqy/20WUnhbbUZcgvngEbJ3YXQ==", + "dev": true, + "dependencies": { + "@types/chai": "*" + } + }, "node_modules/@types/chai-subset": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.5.tgz", @@ -1078,6 +1149,12 @@ "@types/chai": "*" } }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true + }, "node_modules/@types/json-schema": { "version": "7.0.13", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.13.tgz", @@ -1097,10 +1174,13 @@ "dev": true }, "node_modules/@types/node": { - "version": "18.18.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-18.18.3.tgz", - "integrity": "sha512-0OVfGupTl3NBFr8+iXpfZ8NR7jfFO+P1Q+IO/q0wbo02wYkP5gy36phojeYWpLQ6WAMjl+VfmqUk2YbUfp0irA==", - "dev": true + "version": "18.19.75", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.75.tgz", + "integrity": "sha512-UIksWtThob6ZVSyxcOqCLOUNg/dyO1Qvx4McgeuhrEtHTLFTf7BBhEazaE4K806FGTPtzd/2sE90qn4fVr7cyw==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } }, "node_modules/@types/prismjs": { "version": "1.16.8", @@ -1441,13 +1521,15 @@ "integrity": "sha512-2J27dysaXmvnfuhFGhfeuxfHRXunqNPxtBoR3koiTOA9rdxWNDTa1zIFLCFMSHJ9MPTPKFcBeblsyaCJCIlQxg==" }, "node_modules/@vscode/test-cli": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/@vscode/test-cli/-/test-cli-0.0.4.tgz", - "integrity": "sha512-Tx0tfbxeSb2Xlo+jpd+GJrNLgKQHobhRHrYvOipZRZQYWZ82sKiK02VY09UjU1Czc/YnZnqyAnjUfaVGl3h09w==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@vscode/test-cli/-/test-cli-0.0.10.tgz", + "integrity": "sha512-B0mMH4ia+MOOtwNiLi79XhA+MLmUItIC8FckEuKrVAVriIuSWjt7vv4+bF8qVFiNFe4QRfzPaIZk39FZGWEwHA==", "dev": true, "dependencies": { "@types/mocha": "^10.0.2", + "c8": "^9.1.0", "chokidar": "^3.5.3", + "enhanced-resolve": "^5.15.0", "glob": "^10.3.10", "minimatch": "^9.0.3", "mocha": "^10.2.0", @@ -1456,6 +1538,9 @@ }, "bin": { "vscode-test": "out/bin.mjs" + }, + "engines": { + "node": ">=18" } }, "node_modules/@vscode/test-cli/node_modules/ansi-regex": { @@ -2162,6 +2247,116 @@ "node": ">=0.10.0" } }, + "node_modules/c8": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-9.1.0.tgz", + "integrity": "sha512-mBWcT5iqNir1zIkzSPyI3NCR9EZCVI3WUD+AVO17MVWTSFNyUueXE82qTeampNtTr+ilN/5Ua3j24LgbCKjDVg==", + "dev": true, + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^6.0.0", + "v8-to-istanbul": "^9.0.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": ">=14.14.0" + } + }, + "node_modules/c8/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/c8/node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/c8/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/c8/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/c8/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/c8/node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/c8/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/call-bind": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", @@ -2220,6 +2415,27 @@ "node": ">=4" } }, + "node_modules/chai-as-promised": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-8.0.1.tgz", + "integrity": "sha512-OIEJtOL8xxJSH8JJWbIoRjybbzR52iFuDHuF8eb+nTPD6tgXLjRqsgnUGqQfFODxYvq5QdirT0pN9dZ0+Gz6rA==", + "dev": true, + "dependencies": { + "check-error": "^2.0.0" + }, + "peerDependencies": { + "chai": ">= 2.1.2 < 6" + } + }, + "node_modules/chai-as-promised/node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "engines": { + "node": ">= 16" + } + }, "node_modules/chai-subset": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/chai-subset/-/chai-subset-1.6.0.tgz", @@ -2463,6 +2679,12 @@ "dev": true, "optional": true }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -2771,6 +2993,19 @@ "once": "^1.4.0" } }, + "node_modules/enhanced-resolve": { + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", + "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/entities": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", @@ -3569,6 +3804,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -3660,6 +3901,12 @@ "node": ">=10" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true + }, "node_modules/htmlparser2": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", @@ -3914,6 +4161,63 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jackspeak": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", @@ -4054,6 +4358,12 @@ "setimmediate": "^1.0.5" } }, + "node_modules/just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true + }, "node_modules/jwa": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", @@ -4151,6 +4461,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "dev": true + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -4305,6 +4622,33 @@ "node": ">=10" } }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -4672,6 +5016,19 @@ "resolved": "https://registry.npmjs.org/net/-/net-1.0.2.tgz", "integrity": "sha1-0XV+yaf7I3HYPPR1XOPifhCCk4g=" }, + "node_modules/nise": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/nise/-/nise-6.1.1.tgz", + "integrity": "sha512-aMSAzLVY7LyeM60gvBS423nBmIPP+Wy7St7hsb+8/fc1HmeoHJfLO8CKse4u3BtOZvQLJghYPI2i/1WZrEj5/g==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.1", + "@sinonjs/text-encoding": "^0.7.3", + "just-extend": "^6.2.0", + "path-to-regexp": "^8.1.0" + } + }, "node_modules/node-abi": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-2.30.1.tgz", @@ -4928,6 +5285,15 @@ "node": "14 || >=16.14" } }, + "node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "dev": true, + "engines": { + "node": ">=16" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -5381,6 +5747,54 @@ "simple-concat": "^1.0.0" } }, + "node_modules/sinon": { + "version": "19.0.2", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-19.0.2.tgz", + "integrity": "sha512-euuToqM+PjO4UgXeLETsfQiuoyPXlqFezr6YZDFwHR3t4qaX0fZUe1MfPMznTL5f8BWrVS89KduLdMUsxFCO6g==", + "dev": true, + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.2", + "@sinonjs/samsam": "^8.0.1", + "diff": "^7.0.0", + "nise": "^6.1.1", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/diff": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/sinon/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/sinon/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -5560,6 +5974,15 @@ "node": ">=4" } }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/tar-fs": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", @@ -5616,6 +6039,20 @@ "node": ">=0.10" } }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -5847,6 +6284,12 @@ "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==", "dev": true }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -5883,6 +6326,30 @@ "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", "dev": true }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/v8-to-istanbul/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/vscode-jsonrpc": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-6.0.0.tgz", diff --git a/package.json b/package.json index cdaa5ff..1a0d32e 100644 --- a/package.json +++ b/package.json @@ -871,24 +871,27 @@ }, "devDependencies": { "@types/chai": "^4.3.11", + "@types/chai-as-promised": "^8.0.1", "@types/chai-subset": "^1.3.5", "@types/marked": "^4.0.8", "@types/mocha": "^10.0.6", - "@types/node": "^18.15.0", + "@types/node": "^18.19.75", "@types/prismjs": "^1.16.8", "@types/vscode": "^1.96.0", "@types/ws": "^8.5.4", "@typescript-eslint/eslint-plugin": "^5.57.1", "@typescript-eslint/eslint-plugin-tslint": "^5.57.1", "@typescript-eslint/parser": "^5.57.1", - "@vscode/test-cli": "^0.0.4", + "@vscode/test-cli": "^0.0.10", "@vscode/test-electron": "^2.3.8", "@vscode/vsce": "^2.29.0", - "chai": "^4.3.10", + "chai": "^4.5.0", + "chai-as-promised": "^8.0.1", "chai-subset": "^1.6.0", "esbuild": "^0.17.15", "eslint": "^8.37.0", - "mocha": "^10.2.0", + "mocha": "^10.8.2", + "sinon": "^19.0.2", "ts-node": "^10.9.1", "tsconfig-paths": "^4.2.0", "tslint": "^5.20.1", diff --git a/src/debugger/debug_runtime.ts b/src/debugger/debug_runtime.ts index 7ac94fd..c78b0c1 100644 --- a/src/debugger/debug_runtime.ts +++ b/src/debugger/debug_runtime.ts @@ -46,7 +46,7 @@ export interface GodotVariable { scope_path?: string; sub_values?: GodotVariable[]; value: any; - type?: bigint; + type?: number; id?: bigint; } diff --git a/src/debugger/debugger.ts b/src/debugger/debugger.ts index 9c77957..1d6904a 100644 --- a/src/debugger/debugger.ts +++ b/src/debugger/debugger.ts @@ -25,6 +25,9 @@ import { GodotDebugSession as Godot4DebugSession } from "./godot4/debug_session" import { register_command, set_context, createLogger, get_project_version } from "../utils"; import { SceneTreeProvider, SceneNode } from "./scene_tree_provider"; import { InspectorProvider, RemoteProperty } from "./inspector_provider"; +import { GodotVariable, RawObject } from "./debug_runtime"; +import { GodotObject, GodotObjectPromise } from "./godot4/variables/godot_object_promise"; +import { InvalidatedEvent } from "@vscode/debugadapter"; const log = createLogger("debugger", { output: "Godot Debugger" }); @@ -256,38 +259,34 @@ export class GodotDebugger implements DebugAdapterDescriptorFactory, DebugConfig } } - public inspect_node(element: SceneNode | RemoteProperty) { - this.session?.controller.request_inspect_object(BigInt(element.object_id)); - this.session?.inspect_callbacks.set( - BigInt(element.object_id), - (class_name, variable) => { - this.inspectorProvider.fill_tree( - element.label, - class_name, - element.object_id, - variable - ); + public async inspect_node(element: SceneNode | RemoteProperty) { + await this.fill_provider_tree(element.label, BigInt(element.object_id)); + } + + private create_godot_variable(godot_object: GodotObject): GodotVariable { + return { + value: { + type_name: function() { return godot_object.type; }, + stringify_value: function() { return `<${godot_object.godot_id}>`; }, + sub_values: function() {return godot_object.sub_values; }, }, - ); + } as GodotVariable; } - public refresh_scene_tree() { - this.session?.controller.request_scene_tree(); - } - - public refresh_inspector() { - if (this.inspectorProvider.has_tree()) { - const name = this.inspectorProvider.get_top_name(); - const id = this.inspectorProvider.get_top_id(); - - this.session?.controller.request_inspect_object(BigInt(id)); + private async fill_provider_tree(label: string, godot_id: bigint, force_refresh = false) { + if (this.session instanceof Godot4DebugSession) { + const godot_object = await this.session.variables_manager.get_godot_object(BigInt(godot_id), force_refresh); + const va = this.create_godot_variable(godot_object); + this.inspectorProvider.fill_tree(label, godot_object.type, Number(godot_object.godot_id), va); + } else { + this.session?.controller.request_inspect_object(BigInt(godot_id)); this.session?.inspect_callbacks.set( - BigInt(id), + BigInt(godot_id), (class_name, variable) => { this.inspectorProvider.fill_tree( - name, + label, class_name, - id, + Number(godot_id), variable ); }, @@ -295,82 +294,83 @@ export class GodotDebugger implements DebugAdapterDescriptorFactory, DebugConfig } } - public edit_value(property: RemoteProperty) { + public refresh_scene_tree() { + this.session?.controller.request_scene_tree(); + } + + public async refresh_inspector() { + if (this.inspectorProvider.has_tree()) { + const label = this.inspectorProvider.get_top_name(); + const id = this.inspectorProvider.get_top_id(); + await this.fill_provider_tree(label, BigInt(id), /*force_refresh*/ true); + } + } + + public async edit_value(property: RemoteProperty) { const previous_value = property.value; const type = typeof previous_value; const is_float = type === "number" && !Number.isInteger(previous_value); - window - .showInputBox({ value: `${property.description}` }) - .then((value) => { - let new_parsed_value: any; - switch (type) { - case "string": - new_parsed_value = value; - break; - case "number": - if (is_float) { - new_parsed_value = Number.parseFloat(value); - if (Number.isNaN(new_parsed_value)) { - return; - } - } else { - new_parsed_value = Number.parseInt(value); - if (Number.isNaN(new_parsed_value)) { - return; - } - } - break; - case "boolean": - if ( - value.toLowerCase() === "true" || - value.toLowerCase() === "false" - ) { - new_parsed_value = value.toLowerCase() === "true"; - } else if (value === "0" || value === "1") { - new_parsed_value = value === "1"; - } else { - return; - } - } - if (property.changes_parent) { - const parents = [property.parent]; - let idx = 0; - while (parents[idx].changes_parent) { - parents.push(parents[idx++].parent); + const value = await window.showInputBox({ value: `${property.description}` }); + let new_parsed_value: any; + switch (type) { + case "string": + new_parsed_value = value; + break; + case "number": + if (is_float) { + new_parsed_value = Number.parseFloat(value); + if (Number.isNaN(new_parsed_value)) { + return; } - const changed_value = this.inspectorProvider.get_changed_value( - parents, - property, - new_parsed_value - ); - this.session?.controller.set_object_property( - BigInt(property.object_id), - parents[idx].label, - changed_value, - ); } else { - this.session?.controller.set_object_property( - BigInt(property.object_id), - property.label, - new_parsed_value, - ); + new_parsed_value = Number.parseInt(value); + if (Number.isNaN(new_parsed_value)) { + return; + } } + break; + case "boolean": + if ( + value.toLowerCase() === "true" || + value.toLowerCase() === "false" + ) { + new_parsed_value = value.toLowerCase() === "true"; + } else if (value === "0" || value === "1") { + new_parsed_value = value === "1"; + } else { + return; + } + } + if (property.changes_parent) { + const parents = [property.parent]; + let idx = 0; + while (parents[idx].changes_parent) { + parents.push(parents[idx++].parent); + } + const changed_value = this.inspectorProvider.get_changed_value( + parents, + property, + new_parsed_value + ); + this.session?.controller.set_object_property( + BigInt(property.object_id), + parents[idx].label, + changed_value, + ); + } else { + this.session?.controller.set_object_property( + BigInt(property.object_id), + property.label, + new_parsed_value, + ); + } - const name = this.inspectorProvider.get_top_name(); - const id = this.inspectorProvider.get_top_id(); + const label = this.inspectorProvider.get_top_name(); + const godot_id = BigInt(this.inspectorProvider.get_top_id()); - this.session?.controller.request_inspect_object(BigInt(id)); - this.session?.inspect_callbacks.set( - BigInt(id), - (class_name, variable) => { - this.inspectorProvider.fill_tree( - name, - class_name, - id, - variable - ); - }, - ); - }); + await this.fill_provider_tree(label, godot_id, /*force_refresh*/ true); + // const res = await debug.activeDebugSession?.customRequest("refreshVariables"); // refresh vscode.debug variables + this.session.sendEvent(new InvalidatedEvent(["variables"])); + console.log("foo"); } } diff --git a/src/debugger/godot4/debug_session.ts b/src/debugger/godot4/debug_session.ts index 0e71156..b1b7f94 100644 --- a/src/debugger/godot4/debug_session.ts +++ b/src/debugger/godot4/debug_session.ts @@ -9,36 +9,25 @@ import { import { DebugProtocol } from "@vscode/debugprotocol"; import { Subject } from "await-notify"; import * as fs from "node:fs"; -import { debug } from "vscode"; import { createLogger } from "../../utils"; -import { GodotDebugData, GodotStackVars, GodotVariable } from "../debug_runtime"; +import { GodotDebugData } from "../debug_runtime"; import { AttachRequestArguments, LaunchRequestArguments } from "../debugger"; import { SceneTreeProvider } from "../scene_tree_provider"; -import { is_variable_built_in_type, parse_variable } from "./helpers"; import { ServerController } from "./server_controller"; -import { ObjectId, RawObject } from "./variables/variants"; +import { VariablesManager } from "./variables/variables_manager"; const log = createLogger("debugger.session", { output: "Godot Debugger" }); -interface Variable { - variable: GodotVariable; - index: number; - object_id: number; -} - export class GodotDebugSession extends LoggingDebugSession { - private all_scopes: GodotVariable[]; public controller = new ServerController(this); public debug_data = new GodotDebugData(this); public sceneTree: SceneTreeProvider; private exception = false; - private got_scope: Subject = new Subject(); - private ongoing_inspections: bigint[] = []; - private previous_inspections: bigint[] = []; private configuration_done: Subject = new Subject(); private mode: "launch" | "attach" | "" = ""; - public inspect_callbacks: Map void> = new Map(); + + public variables_manager: VariablesManager; public constructor() { super(); @@ -126,34 +115,6 @@ export class GodotDebugSession extends LoggingDebugSession { } } - protected async evaluateRequest(response: DebugProtocol.EvaluateResponse, args: DebugProtocol.EvaluateArguments) { - log.info("evaluateRequest", args); - await debug.activeDebugSession.customRequest("scopes", { frameId: 0 }); - - if (this.all_scopes) { - try { - const variable = this.get_variable(args.expression, null, null, null); - const parsed_variable = parse_variable(variable.variable); - response.body = { - result: parsed_variable.value, - variablesReference: !is_variable_built_in_type(variable.variable) ? variable.index : 0, - }; - } catch (error) { - response.success = false; - response.message = error.toString(); - } - } - - if (!response.body) { - response.body = { - result: "null", - variablesReference: 0, - }; - } - - this.sendResponse(response); - } - protected nextRequest(response: DebugProtocol.NextResponse, args: DebugProtocol.NextArguments) { log.info("nextRequest", args); if (!this.exception) { @@ -170,21 +131,6 @@ export class GodotDebugSession extends LoggingDebugSession { } } - protected async scopesRequest(response: DebugProtocol.ScopesResponse, args: DebugProtocol.ScopesArguments) { - log.info("scopesRequest", args); - this.controller.request_stack_frame_vars(args.frameId); - await this.got_scope.wait(2000); - - response.body = { - scopes: [ - { name: "Locals", variablesReference: 1, expensive: false }, - { name: "Members", variablesReference: 2, expensive: false }, - { name: "Globals", variablesReference: 3, expensive: false }, - ], - }; - this.sendResponse(response); - } - protected setBreakPointsRequest( response: DebugProtocol.SetBreakpointsResponse, args: DebugProtocol.SetBreakpointsArguments, @@ -225,25 +171,6 @@ export class GodotDebugSession extends LoggingDebugSession { } } - protected stackTraceRequest(response: DebugProtocol.StackTraceResponse, args: DebugProtocol.StackTraceArguments) { - log.info("stackTraceRequest", args); - if (this.debug_data.last_frame) { - response.body = { - totalFrames: this.debug_data.last_frames.length, - stackFrames: this.debug_data.last_frames.map((sf) => { - return { - id: sf.id, - name: sf.function, - line: sf.line, - column: 1, - source: new Source(sf.file, `${this.debug_data.projectPath}/${sf.file.replace("res://", "")}`), - }; - }), - }; - } - this.sendResponse(response); - } - protected stepInRequest(response: DebugProtocol.StepInResponse, args: DebugProtocol.StepInArguments) { log.info("stepInRequest", args); if (!this.exception) { @@ -272,299 +199,97 @@ export class GodotDebugSession extends LoggingDebugSession { protected threadsRequest(response: DebugProtocol.ThreadsResponse) { log.info("threadsRequest"); response.body = { threads: [new Thread(0, "thread_1")] }; + log.info("threadsRequest response", response); this.sendResponse(response); } - protected async variablesRequest( - response: DebugProtocol.VariablesResponse, - args: DebugProtocol.VariablesArguments, - ) { - log.info("variablesRequest", args); - if (!this.all_scopes) { + protected stackTraceRequest(response: DebugProtocol.StackTraceResponse, args: DebugProtocol.StackTraceArguments) { + log.info("stackTraceRequest", args); + if (this.debug_data.last_frame) { response.body = { - variables: [], + totalFrames: this.debug_data.last_frames.length, + stackFrames: this.debug_data.last_frames.map((sf) => { + return { + id: sf.id, + name: sf.function, + line: sf.line, + column: 1, + source: new Source(sf.file, `${this.debug_data.projectPath}/${sf.file.replace("res://", "")}`), + }; + }), }; - this.sendResponse(response); - return; } - const reference = this.all_scopes[args.variablesReference]; - let variables: DebugProtocol.Variable[]; + log.info("stackTraceRequest response", response); + this.sendResponse(response); + } - if (!reference.sub_values) { - variables = []; - } else { - variables = reference.sub_values.map((va) => { - const sva = this.all_scopes.find( - (sva) => sva && sva.scope_path === va.scope_path && sva.name === va.name, - ); - if (sva) { - return parse_variable( - sva, - this.all_scopes.findIndex( - (va_idx) => - va_idx && - va_idx.scope_path === `${reference.scope_path}.${reference.name}` && - va_idx.name === va.name, - ), - ); - } - }); - } + protected async scopesRequest(response: DebugProtocol.ScopesResponse, args: DebugProtocol.ScopesArguments) { + log.info("scopesRequest", args); + // this.variables_manager.variablesFrameId = args.frameId; + + // TODO: create scopes dynamically for a given frame + const vscode_scope_ids = this.variables_manager.get_or_create_frame_scopes(args.frameId); + const scopes_with_references = [ + {name: "Locals", variablesReference: vscode_scope_ids.Locals, expensive: false}, + {name: "Members", variablesReference: vscode_scope_ids.Members, expensive: false}, + {name: "Globals", variablesReference: vscode_scope_ids.Globals, expensive: false}, + ]; response.body = { - variables: variables, + scopes: scopes_with_references + // scopes: [ + // { name: "Locals", variablesReference: 1, expensive: false }, + // { name: "Members", variablesReference: 2, expensive: false }, + // { name: "Globals", variablesReference: 3, expensive: false }, + // ], }; + log.info("scopesRequest response", response); + this.sendResponse(response); + } + + protected async variablesRequest(response: DebugProtocol.VariablesResponse, args: DebugProtocol.VariablesArguments) { + log.info("variablesRequest", args); + try { + const variables = await this.variables_manager.get_vscode_object(args.variablesReference); + + response.body = { + variables: variables, + }; + } catch (error) { + log.error("variablesRequest", error); + response.success = false; + response.message = error.toString(); + } + + log.info("variablesRequest response", response); + this.sendResponse(response); + } + + protected async evaluateRequest(response: DebugProtocol.EvaluateResponse, args: DebugProtocol.EvaluateArguments) { + log.info("evaluateRequest", args); + + try { + const parsed_variable = await this.variables_manager.get_vscode_variable_by_name(args.expression, args.frameId); + response.body = { + result: parsed_variable.value, + variablesReference: parsed_variable.variablesReference + }; + } catch (error) { + response.success = false; + response.message = error.toString(); + response.body = { + result: "null", + variablesReference: 0, + }; + } + + log.info("evaluateRequest response", response); this.sendResponse(response); } public set_exception(exception: boolean) { this.exception = true; } - - public set_scopes(stackVars: GodotStackVars) { - this.all_scopes = [ - undefined, - { - name: "local", - value: undefined, - sub_values: stackVars.locals, - scope_path: "@", - }, - { - name: "member", - value: undefined, - sub_values: stackVars.members, - scope_path: "@", - }, - { - name: "global", - value: undefined, - sub_values: stackVars.globals, - scope_path: "@", - }, - ]; - - for (const va of stackVars.locals) { - va.scope_path = "@.local"; - this.append_variable(va); - } - - for (const va of stackVars.members) { - va.scope_path = "@.member"; - this.append_variable(va); - } - - for (const va of stackVars.globals) { - va.scope_path = "@.global"; - this.append_variable(va); - } - - this.add_to_inspections(); - - if (this.ongoing_inspections.length === 0 && stackVars.remaining == 0) { - // in case if stackVars are empty, the this.ongoing_inspections will be empty also - // godot 4.3 generates empty stackVars with remaining > 0 on a breakpoint stop - // godot will continue sending `stack_frame_vars` until all `stackVars.remaining` are sent - // at this moment `stack_frame_vars` will call `set_scopes` again with cumulated stackVars - // TODO: godot won't send the recursive variable, see related https://github.com/godotengine/godot/issues/76019 - // in that case the vscode extension fails to call this.got_scope.notify(); - // hence the extension needs to be refactored to handle missing `stack_frame_vars` messages - this.previous_inspections = []; - this.got_scope.notify(); - } - } - - public set_inspection(id: bigint, rawObject: RawObject, sub_values: GodotVariable[]) { - const variables = this.all_scopes.filter((va) => va && va.value instanceof ObjectId && va.value.id === id); - - for (const va of variables) { - const index = this.all_scopes.findIndex((va_id) => va_id === va); - if (index < 0) { - continue; - } - const old = this.all_scopes.splice(index, 1); - // GodotVariable instance will be different for different variables, even if the referenced object id is the same: - const replacement = {value: rawObject, sub_values: sub_values } as GodotVariable; - replacement.name = old[0].name; - replacement.scope_path = old[0].scope_path; - this.append_variable(replacement, index); - } - - const ongoing_inspections_index = this.ongoing_inspections.findIndex((va_id) => va_id === id); - if (ongoing_inspections_index >= 0) { - this.ongoing_inspections.splice(ongoing_inspections_index, 1); - } - - - this.previous_inspections.push(id); - - // this.add_to_inspections(); - - if (this.ongoing_inspections.length === 0) { - // the `ongoing_inspections` is not empty, until all scopes are fully resolved - // once last inspection is resolved: Notify that we got full scope - this.previous_inspections = []; - this.got_scope.notify(); - } - } - - private add_to_inspections() { - const scopes_to_check = this.all_scopes.filter((va) => va && va.value instanceof ObjectId); - for (const va of scopes_to_check) { - if ( - !this.ongoing_inspections.includes(va.value.id) && - !this.previous_inspections.includes(va.value.id) - ) { - this.controller.request_inspect_object(va.value.id); - this.ongoing_inspections.push(va.value.id); - } - } - } - - protected get_variable( - expression: string, - root: GodotVariable = null, - index = 0, - object_id: number = null, - ): Variable { - let result: Variable = { - variable: null, - index: null, - object_id: null, - }; - - if (!root) { - if (!expression.includes("self")) { - expression = "self." + expression; - } - - root = this.all_scopes.find((x) => x && x.name === "self"); - object_id = this.all_scopes.find((x) => x && x.name === "id" && x.scope_path === "@.member.self").value; - } - - const items = expression.split("."); - let propertyName = items[index + 1]; - let path = items - .slice(0, index + 1) - .join(".") - .split("self.") - .join("") - .split("self") - .join("") - .split("[") - .join(".") - .split("]") - .join(""); - - if (items.length === 1 && items[0] === "self") { - propertyName = "self"; - } - - // Detect index/key - let key = (propertyName.match(/(?<=\[).*(?=\])/) || [null])[0]; - if (key) { - key = key.replace(/['"]+/g, ""); - propertyName = propertyName - .split(/(?<=\[).*(?=\])/) - .join("") - .split("[]") - .join(""); - if (path) path += "."; - path += propertyName; - propertyName = key; - } - - function sanitizeName(name: string) { - return name.split("Members/").join("").split("Locals/").join(""); - } - - function sanitizeScopePath(scope_path: string) { - return scope_path - .split("@.member.self.") - .join("") - .split("@.member.self") - .join("") - .split("@.member.") - .join("") - .split("@.member") - .join("") - .split("@.local.") - .join("") - .split("@.local") - .join("") - .split("Locals/") - .join("") - .split("Members/") - .join("") - .split("@") - .join(""); - } - - const sanitized_all_scopes = this.all_scopes - .filter((x) => x) - .map((x) => ({ - sanitized: { - name: sanitizeName(x.name), - scope_path: sanitizeScopePath(x.scope_path), - }, - real: x, - })); - - result.variable = sanitized_all_scopes.find( - (x) => x.sanitized.name === propertyName && x.sanitized.scope_path === path, - )?.real; - if (!result.variable) { - throw new Error(`Could not find: ${propertyName}`); - } - - if (root.value.entries) { - if (result.variable.name === "self") { - result.object_id = this.all_scopes.find( - (x) => x && x.name === "id" && x.scope_path === "@.member.self", - ).value; - } else if (key) { - const collection = path.split(".")[path.split(".").length - 1]; - const collection_items = Array.from(root.value.entries()).find( - (x) => x && x[0].split("Members/").join("").split("Locals/").join("") === collection, - )[1]; - result.object_id = collection_items.get ? collection_items.get(key)?.id : collection_items[key]?.id; - } else { - const item = Array.from(root.value.entries()).find( - (x) => x && x[0].split("Members/").join("").split("Locals/").join("") === propertyName, - ); - result.object_id = item?.[1].id; - } - } - - if (!result.object_id) { - result.object_id = object_id; - } - - result.index = this.all_scopes.findIndex( - (x) => x && x.name === result.variable.name && x.scope_path === result.variable.scope_path, - ); - - if (items.length > 2 && index < items.length - 2) { - result = this.get_variable(items.join("."), result.variable, index + 1, result.object_id); - } - - return result; - } - - private append_variable(variable: GodotVariable, index?: number) { - if (index) { - this.all_scopes.splice(index, 0, variable); - } else { - this.all_scopes.push(variable); - } - const base_path = `${variable.scope_path}.${variable.name}`; - if (variable.sub_values) { - variable.sub_values.forEach((va, i) => { - va.scope_path = base_path; - this.append_variable(va, index ? index + i + 1 : undefined); - }); - } - } } diff --git a/src/debugger/godot4/helpers.ts b/src/debugger/godot4/helpers.ts index dea7cd7..1b98795 100644 --- a/src/debugger/godot4/helpers.ts +++ b/src/debugger/godot4/helpers.ts @@ -1,5 +1,6 @@ import { GodotVariable, } from "../debug_runtime"; import { SceneNode } from "../scene_tree_provider"; +import { ObjectId } from "./variables/variants"; export function parse_next_scene_node(params: any[], ofs: { offset: number } = { offset: 0 }): SceneNode { const childCount: number = params[ofs.offset++]; @@ -31,12 +32,7 @@ export function split_buffers(buffer: Buffer) { return buffers; } -export function is_variable_built_in_type(va: GodotVariable) { - var type = typeof va.value; - return ["number", "bigint", "boolean", "string"].some(x => x == type); -} - -export function get_sub_values(value: any) { +export function get_sub_values(value: any): GodotVariable[] { let subValues: GodotVariable[] = undefined; if (value) { @@ -45,19 +41,12 @@ export function get_sub_values(value: any) { return { name: `${i}`, value: va } as GodotVariable; }); } else if (value instanceof Map) { - subValues = Array.from(value.keys()).map((va) => { - if (typeof va["stringify_value"] === "function") { - return { - name: `${va.type_name()}${va.stringify_value()}`, - value: value.get(va), - } as GodotVariable; - } else { - return { - name: `${va}`, - value: value.get(va), - } as GodotVariable; - } - }); + subValues = []; + for (const [key, val] of value.entries()) { + const name = typeof key["stringify_value"] === "function" ? `${key.type_name()}${key.stringify_value()}` : `${key}`; + const godot_id = val instanceof ObjectId ? val.id : undefined; + subValues.push({id: godot_id, name, value: val } as GodotVariable); + } } else if (typeof value["sub_values"] === "function") { subValues = value.sub_values()?.map((sva) => { return { name: sva.name, value: sva.value } as GodotVariable; @@ -70,55 +59,4 @@ export function get_sub_values(value: any) { } return subValues; -} - -export function parse_variable(va: GodotVariable, i?: number) { - const value = va.value; - let rendered_value = ""; - let reference = 0; - let array_size = 0; - let array_type = undefined; - - if (typeof value === "number") { - if (Number.isInteger(value)) { - rendered_value = `${value}`; - } else { - rendered_value = `${parseFloat(value.toFixed(5))}`; - } - } else if ( - typeof value === "bigint" || - typeof value === "boolean" || - typeof value === "string" - ) { - rendered_value = `${value}`; - } else if (typeof value === "undefined") { - rendered_value = "null"; - } else { - if (Array.isArray(value)) { - rendered_value = `Array[${value.length}]`; - array_size = value.length; - array_type = "indexed"; - reference = i ? i : 0; - } else if (value instanceof Map) { - rendered_value = value["class_name"] ?? `Dictionary[${value.size}]`; - array_size = value.size; - array_type = "named"; - reference = i ? i : 0; - } else { - try { - rendered_value = `${value.type_name()}${value.stringify_value()}`; - } catch (e) { - rendered_value = `${value}`; - } - reference = i ? i : 0; - } - } - - return { - name: va.name, - value: rendered_value, - variablesReference: reference, - array_size: array_size > 0 ? array_size : undefined, - filter: array_type, - }; -} +} \ No newline at end of file diff --git a/src/debugger/godot4/server_controller.ts b/src/debugger/godot4/server_controller.ts index 0fc2997..b131377 100644 --- a/src/debugger/godot4/server_controller.ts +++ b/src/debugger/godot4/server_controller.ts @@ -16,13 +16,14 @@ import { } from "../../utils"; import { prompt_for_godot_executable } from "../../utils/prompts"; import { killSubProcesses, subProcess } from "../../utils/subspawn"; -import { GodotStackFrame, GodotStackVars, GodotVariable } from "../debug_runtime"; +import { GodotStackFrame, GodotVariable } from "../debug_runtime"; import { AttachRequestArguments, LaunchRequestArguments, pinnedScene } from "../debugger"; import { GodotDebugSession } from "./debug_session"; import { get_sub_values, parse_next_scene_node, split_buffers } from "./helpers"; import { VariantDecoder } from "./variables/variant_decoder"; import { VariantEncoder } from "./variables/variant_encoder"; import { RawObject } from "./variables/variants"; +import { VariablesManager } from "./variables/variables_manager"; const log = createLogger("debugger.controller", { output: "Godot Debugger" }); const socketLog = createLogger("debugger.socket"); @@ -35,6 +36,33 @@ class Command { public threadId: number = 0; } +class GodotPartialStackVars { + Locals: GodotVariable[] = []; + Members: GodotVariable[] = []; + Globals: GodotVariable [] = []; + public remaining: number; + public stack_frame_id: number; + constructor(stack_frame_id: number) { + this.stack_frame_id = stack_frame_id; + } + + public reset(remaining: number) { + this.remaining = remaining; + this.Locals = []; + this.Members = []; + this.Globals = []; + } + + public append(name: string, godotScopeIndex: 0|1|2, type: number, value: any, sub_values?: GodotVariable[]) { + const scopeName = ["Locals", "Members", "Globals"][godotScopeIndex]; + const scope = this[scopeName]; + // const objectId = value instanceof ObjectId ? value : undefined; // won't work, unless the value is re-created through new ObjectId(godot_id) + const godot_id = type === 24 ? value.id : undefined; + scope.push({ id: godot_id, name, value, type, sub_values } as GodotVariable); + this.remaining--; + } +} + export class ServerController { private commandBuffer: Buffer[] = []; private encoder = new VariantEncoder(); @@ -46,7 +74,7 @@ export class ServerController { private socket?: net.Socket; private steppingOut = false; private didFirstOutput = false; - private partialStackVars = new GodotStackVars(); + private partialStackVars: GodotPartialStackVars; private connectedVersion = ""; public constructor(public session: GodotDebugSession) {} @@ -93,8 +121,16 @@ export class ServerController { this.send_command("get_stack_dump"); } - public request_stack_frame_vars(frame_id: number) { - this.send_command("get_stack_frame_vars", [frame_id]); + public request_stack_frame_vars(stack_frame_id: number) { + if (this.partialStackVars !== undefined) { + log.warn("Partial stack frames have been requested, while existing request hasn't been completed yet." + + `Remaining stack_frames: ${this.partialStackVars.remaining}` + + `Current stack_frame_id: ${this.partialStackVars.stack_frame_id}` + + `Requested stack_frame_id: ${stack_frame_id}` + ); + } + this.partialStackVars = new GodotPartialStackVars(stack_frame_id); + this.send_command("get_stack_frame_vars", [stack_frame_id]); } public set_object_property(objectId: bigint, label: string, newParsedValue) { @@ -259,7 +295,7 @@ export class ServerController { return; } - socketLog.debug("rx:", data[0]); + socketLog.debug("rx:", data[0], data[0][2]); const command = this.parse_message(data[0]); this.handle_command(command); } @@ -362,9 +398,11 @@ export class ServerController { this.set_exception(""); } this.request_stack_dump(); + this.session.variables_manager = new VariablesManager(this); break; } case "debug_exit": + this.session.variables_manager = undefined; break; case "message:click_ctrl": // TODO: what is this? @@ -381,14 +419,14 @@ export class ServerController { break; } case "scene:inspect_object": { - let id = BigInt(command.parameters[0]); + let godot_id = BigInt(command.parameters[0]); const className: string = command.parameters[1]; const properties: string[] = command.parameters[2]; // message:inspect_object returns the id as an unsigned 64 bit integer, but it is decoded as a signed 64 bit integer, // thus we need to convert it to its equivalent unsigned value here. - if (id < 0) { - id = id + BigInt(2) ** BigInt(64); + if (godot_id < 0) { + godot_id = godot_id + BigInt(2) ** BigInt(64); } const rawObject = new RawObject(className); @@ -396,14 +434,19 @@ export class ServerController { rawObject.set(prop[0], prop[5]); } const sub_values = get_sub_values(rawObject); - - const inspect_callback = this.session.inspect_callbacks.get(BigInt(id)); - if (inspect_callback !== undefined) { - const inspectedVariable = { name: "", value: rawObject, sub_values: sub_values } as GodotVariable; - inspect_callback(inspectedVariable.name, inspectedVariable); - this.session.inspect_callbacks.delete(BigInt(id)); + + // race condition here: + // 0. DebuggerStop1 happens + // 1. the DA may have sent the "inspect_object" message + // 2. the vscode hit "continue" + // 3. new breakpoint hit, DebuggerStop2 happens + // 4. the godot server will return response for `1.` with "scene:inspect_object" + // at this moment there is no way to tell if "scene:inspect_object" is for DebuggerStop1 or DebuggerStop2 + try { + this.session.variables_manager?.resolve_variable(godot_id, className, sub_values); + } catch (error) { + log.error("Race condition error error in scene:inspect_object", error); } - this.session.set_inspection(id, rawObject, sub_values); break; } case "stack_dump": { @@ -423,17 +466,53 @@ export class ServerController { break; } case "stack_frame_vars": { - this.partialStackVars.reset(command.parameters[0]); - this.session.set_scopes(this.partialStackVars); + /** first response to {@link request_stack_frame_vars} */ + if (this.partialStackVars !== undefined) { + log.warn("'stack_frame_vars' received again from godot engine before all partial 'stack_frame_var' are received"); + } + const remaining = command.parameters[0]; + // init this.partialStackVars, which will be filled with "stack_frame_var" responses data + this.partialStackVars.reset(remaining); break; } case "stack_frame_var": { - this.do_stack_frame_var( - command.parameters[0], - command.parameters[1], - command.parameters[2], - command.parameters[3], - ); + if (this.partialStackVars === undefined) { + log.error("Unexpected 'stack_frame_var' received. Should have received 'stack_frame_vars' first."); + return; + } + if (typeof command.parameters[0] !== "string") { + log.error("Unexpected parameter type for 'stack_frame_var'. Expected string for name, got " + typeof command.parameters[0]); + return; + } + if (typeof command.parameters[1] !== "number" || command.parameters[1] !== 0 && command.parameters[1] !== 1 && command.parameters[1] !== 2) { + log.error("Unexpected parameter type for 'stack_frame_var'. Expected number for scope, got " + typeof command.parameters[1]); + return; + } + if (typeof command.parameters[2] !== "number") { + log.error("Unexpected parameter type for 'stack_frame_var'. Expected number for type, got " + typeof command.parameters[2]); + return; + } + var name: string = command.parameters[0]; + var scope: 0 | 1 | 2 = command.parameters[1]; // 0 = locals, 1 = members, 2 = globals + var type: number = command.parameters[2]; + var value: any = command.parameters[3]; + var subValues: GodotVariable[] = get_sub_values(value); + this.partialStackVars.append(name, scope, type, value, subValues); + + if (this.partialStackVars.remaining === 0) { + const stackVars = this.partialStackVars; + this.partialStackVars = undefined; + log.info("All partial 'stack_frame_var' are received."); + // godot server doesn't send the frame_id for the stack_vars, assume the remembered stack_frame_id: + const frame_id = BigInt(stackVars.stack_frame_id); + const local_scopes_godot_id = -frame_id*3n-1n; + const member_scopes_godot_id = -frame_id*3n-2n; + const global_scopes_godot_id = -frame_id*3n-3n; + + this.session.variables_manager.resolve_variable(local_scopes_godot_id, "Locals", stackVars.Locals); + this.session.variables_manager.resolve_variable(member_scopes_godot_id, "Members", stackVars.Members); + this.session.variables_manager.resolve_variable(global_scopes_godot_id, "Globals", stackVars.Globals); + } break; } case "output": { @@ -616,7 +695,7 @@ export class ServerController { commandArray.push(this.threadId); } commandArray.push(parameters ?? []); - socketLog.debug("tx:", commandArray); + socketLog.debug("tx:", commandArray, commandArray[2]); const buffer = this.encoder.encode_variant(commandArray); this.commandBuffer.push(buffer); this.send_buffer(); @@ -632,26 +711,4 @@ export class ServerController { this.draining = !this.socket.write(command); } } - - private do_stack_frame_var( - name: string, - scope: 0 | 1 | 2, // 0 = locals, 1 = members, 2 = globals - type: bigint, - value: any, - ) { - if (this.partialStackVars.remaining === 0) { - throw new Error("More stack frame variables were sent than expected."); - } - - const sub_values = get_sub_values(value); - const variable = { name, value, type, sub_values } as GodotVariable; - - const scopeName = ["locals", "members", "globals"][scope]; - this.partialStackVars[scopeName].push(variable); - this.partialStackVars.remaining--; - - if (this.partialStackVars.remaining === 0) { - this.session.set_scopes(this.partialStackVars); - } - } } diff --git a/src/debugger/godot4/debugger_variables.test.ts b/src/debugger/godot4/variables/debugger_variables.test.ts similarity index 66% rename from src/debugger/godot4/debugger_variables.test.ts rename to src/debugger/godot4/variables/debugger_variables.test.ts index c2c2a10..e4652cf 100644 --- a/src/debugger/godot4/debugger_variables.test.ts +++ b/src/debugger/godot4/variables/debugger_variables.test.ts @@ -4,6 +4,12 @@ import * as vscode from "vscode"; import { DebugProtocol } from "@vscode/debugprotocol"; import chai from "chai"; import chaiSubset from "chai-subset"; +var chaiAsPromised = import("chai-as-promised"); +// const chaiAsPromised = await import("chai-as-promised"); // TODO: use after migration to ECMAScript modules + +chaiAsPromised.then((module) => { + chai.use(module.default); +}); import { promisify } from "util"; import { execFile } from "child_process"; @@ -83,19 +89,31 @@ async function waitForBreakpoint(breakpoint: vscode.SourceBreakpoint, timeoutMs: } enum VariableScope { - Locals = 1, - Members = 2, - Globals = 3 + Locals, + Members, + Globals } -async function getVariablesForScope(scope: VariableScope): Promise { +async function getVariablesForVSCodeID(vscode_id: number): Promise { // corresponds to file://./debug_session.ts protected async variablesRequest const variablesResponse = await vscode.debug.activeDebugSession?.customRequest("variables", { - variablesReference: scope + variablesReference: vscode_id }); return variablesResponse?.variables || []; } +async function getVariablesForScope(scope: VariableScope, stack_frame_id: number = 0): Promise { + const res_scopes = await vscode.debug.activeDebugSession.customRequest("scopes", {frameId: stack_frame_id}); + const scope_name = VariableScope[scope]; + const scope_res = res_scopes.scopes.find(s => s.name == scope_name); + if (scope_res === undefined) { + throw new Error(`No ${scope_name} scope found in responce from "scopes" request`); + } + const vscode_id = scope_res.variablesReference; + const variables = await getVariablesForVSCodeID(vscode_id); + return variables; +} + async function evaluateRequest(scope: VariableScope, expression: string, context = "watch", frameId = 0): Promise { // corresponds to file://./debug_session.ts protected async evaluateRequest const evaluateResponse: DebugProtocol.EvaluateResponse = await vscode.debug.activeDebugSession?.customRequest("evaluate", { @@ -118,6 +136,31 @@ function formatMessage(this: Mocha.Context, msg: string): string { var fmt: (msg: string) => string; // formatMessage bound to Mocha.Context + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Chai { + interface Assertion { + unique: Assertion; + } + } +} + +chai.Assertion.addProperty("unique", function() { + const actual = this._obj; // The object being tested + if (!Array.isArray(actual)) { + throw new chai.AssertionError("Expected value to be an array"); + } + const uniqueArray = [...new Set(actual)]; + this.assert( + actual.length === uniqueArray.length, + "expected #{this} to contain only unique elements", + "expected #{this} to not contain only unique elements", + uniqueArray, + actual + ); +}); + async function startDebugging(scene: "ScopeVars.tscn" | "ExtensiveVars.tscn" | "BuiltInTypes.tscn" = "ScopeVars.tscn"): Promise { const t0 = performance.now(); const debugConfig: vscode.DebugConfiguration = { @@ -138,7 +181,10 @@ async function startDebugging(scene: "ScopeVars.tscn" | "ExtensiveVars.tscn" | " suite("DAP Integration Tests - Variable Scopes", () => { // workspaceFolder should match `.vscode-test.js`::workspaceFolder - const workspaceFolder = path.resolve(__dirname, "../../../test_projects/test-dap-project-godot4"); + const workspaceFolder = vscode.workspace.workspaceFolders?.[0].uri.fsPath; + if (!workspaceFolder || !workspaceFolder.endsWith("test-dap-project-godot4")) { + throw new Error(`workspaceFolder should contain 'test-dap-project-godot4' project, got: ${workspaceFolder}`); + } suiteSetup(async function() { this.timeout(20000); // enough time to do `godot --import` @@ -149,11 +195,11 @@ suite("DAP Integration Tests - Variable Scopes", () => { // init the godot project by importing it in godot engine: const config = vscode.workspace.getConfiguration("godotTools"); + // config.update("editorPath.godot4", "godot4", vscode.ConfigurationTarget.Workspace); var godot4_path = config.get("editorPath.godot4"); // get the path for currently opened project in vscode test instance: - var project_path = vscode.workspace.workspaceFolders?.[0].uri.fsPath; - console.log("Executing", [godot4_path, "--headless", "--import", project_path]); - const exec_res = await execFileAsync(godot4_path, ["--headless", "--import", project_path], {shell: true, cwd: project_path}); + console.log("Executing", [godot4_path, "--headless", "--import", workspaceFolder]); + const exec_res = await execFileAsync(godot4_path, ["--headless", "--import", workspaceFolder], {shell: true, cwd: workspaceFolder}); if (exec_res.stderr !== "") { throw new Error(exec_res.stderr); } @@ -172,24 +218,25 @@ suite("DAP Integration Tests - Variable Scopes", () => { teardown(async function() { + this.timeout(3000); await sleep(1000); if (vscode.debug.activeDebugSession !== undefined) { console.log("Closing debug session"); await vscode.debug.stopDebugging(); + await sleep(1000); } console.log(`⬛ Test '${this.currentTest.title}' result: ${this.currentTest.state}, duration: ${performance.now() - this.testStart}ms`); }); + // test("sample test", async function() { + // expect(true).to.equal(true); + // expect([1,2,3]).to.be.unique; + // expect([1,1]).not.to.be.unique; + // }); - test("sample test", async function() { - // await sleep(1000); - await startDebugging("ScopeVars.tscn"); - }); - - test("should return correct scopes", async function() { const breakpointLocations = await getBreakpointLocations(path.join(workspaceFolder, "ScopeVars.gd")); - const breakpoint = new vscode.SourceBreakpoint(breakpointLocations["breakpoint::ScopeVars::_ready"]); + const breakpoint = new vscode.SourceBreakpoint(breakpointLocations["breakpoint::ScopeVars::ClassFoo::test_function"]); vscode.debug.addBreakpoints([breakpoint]); await startDebugging("ScopeVars.tscn"); @@ -200,23 +247,40 @@ suite("DAP Integration Tests - Variable Scopes", () => { await sleep(2000); // corresponds to file://./debug_session.ts async scopesRequest - const res_scopes = await vscode.debug.activeDebugSession.customRequest("scopes", {frameId: 1}); + const stack_scopes_map: Map = new Map(); + for (var stack_frame_id = 0; stack_frame_id < 3; stack_frame_id++) { + const res_scopes = await vscode.debug.activeDebugSession.customRequest("scopes", {frameId: stack_frame_id}); + expect(res_scopes).to.exist; + expect(res_scopes.scopes).to.exist; + expect(res_scopes.scopes.length).to.equal(3, "Expected 3 scopes"); + expect(res_scopes.scopes[0].name).to.equal(VariableScope[VariableScope.Locals], "Expected Locals scope"); + expect(res_scopes.scopes[1].name).to.equal(VariableScope[VariableScope.Members], "Expected Members scope"); + expect(res_scopes.scopes[2].name).to.equal(VariableScope[VariableScope.Globals], "Expected Globals scope"); + const vscode_ids = res_scopes.scopes.map(s => s.variablesReference); + expect(vscode_ids, "VSCode IDs should be unique for each scope").to.be.unique; + stack_scopes_map[stack_frame_id] = { + "Locals": vscode_ids[0], + "Members": vscode_ids[1], + "Globals": vscode_ids[2] + }; + } - expect(res_scopes).to.exist; - expect(res_scopes.scopes).to.exist; + const all_scopes_vscode_ids = Array.from(stack_scopes_map.values()).flatMap(s => Object.values(s)); + expect(all_scopes_vscode_ids, "All scopes should be unique").to.be.unique; - const scopes = res_scopes.scopes; - expect(scopes.length).to.equal(3, "Expected 3 scopes"); - expect(scopes[0].name).to.equal(VariableScope[VariableScope.Locals], "Expected Locals scope"); - expect(scopes[0].variablesReference).to.equal(VariableScope.Locals, "Expected Locals variablesReference"); - expect(scopes[1].name).to.equal(VariableScope[VariableScope.Members], "Expected Members scope"); - expect(scopes[1].variablesReference).to.equal(VariableScope.Members, "Expected Members variablesReference"); - expect(scopes[2].name).to.equal(VariableScope[VariableScope.Globals], "Expected Globals scope"); - expect(scopes[2].variablesReference).to.equal(VariableScope.Globals, "Expected Globals variablesReference"); + const vars_frame0_locals = await getVariablesForVSCodeID(stack_scopes_map[0].Locals); + expect(vars_frame0_locals).to.containSubset([{name: "str_var", value: "ScopeVars::ClassFoo::test_function::local::str_var"}]); - await sleep(1000); - await vscode.debug.stopDebugging(); - })?.timeout(5000); + const vars_frame1_locals = await getVariablesForVSCodeID(stack_scopes_map[1].Locals); + expect(vars_frame1_locals).to.containSubset([{name: "str_var", value: "ScopeVars::test::local::str_var"}]); + + const vars_frame2_locals = await getVariablesForVSCodeID(stack_scopes_map[2].Locals); + expect(vars_frame2_locals).to.containSubset([{name: "str_var", value: "ScopeVars::_ready::local::str_var"}]); + })?.timeout(10000); test("should return global variables", async function() { const breakpointLocations = await getBreakpointLocations(path.join(workspaceFolder, "ScopeVars.gd")); @@ -232,12 +296,10 @@ suite("DAP Integration Tests - Variable Scopes", () => { const variables = await getVariablesForScope(VariableScope.Globals); expect(variables).to.containSubset([{name: "GlobalScript"}]); - - await sleep(1000); - await vscode.debug.stopDebugging(); - })?.timeout(7000); + })?.timeout(10000); - test("should return local variables", async function() { + test("should return all local variables", async function() { + /** {@link file://./../../../../test_projects/test-dap-project-godot4/ScopeVars.gd"} */ const breakpointLocations = await getBreakpointLocations(path.join(workspaceFolder, "ScopeVars.gd")); const breakpoint = new vscode.SourceBreakpoint(breakpointLocations["breakpoint::ScopeVars::_ready"]); vscode.debug.addBreakpoints([breakpoint]); @@ -251,14 +313,12 @@ suite("DAP Integration Tests - Variable Scopes", () => { const variables = await getVariablesForScope(VariableScope.Locals); expect(variables.length).to.equal(2); - expect(variables).to.containSubset([{name: "local1"}]); - expect(variables).to.containSubset([{name: "local2"}]); - - await sleep(1000); - await vscode.debug.stopDebugging(); - })?.timeout(5000); + expect(variables).to.containSubset([{name: "str_var"}]); + expect(variables).to.containSubset([{name: "self_var"}]); + })?.timeout(10000); - test("should return member variables", async function() { + test("should return all member variables", async function() { + /** {@link file://./../../../../test_projects/test-dap-project-godot4/ScopeVars.gd"} */ const breakpointLocations = await getBreakpointLocations(path.join(workspaceFolder, "ScopeVars.gd")); const breakpoint = new vscode.SourceBreakpoint(breakpointLocations["breakpoint::ScopeVars::_ready"]); vscode.debug.addBreakpoints([breakpoint]); @@ -271,13 +331,12 @@ suite("DAP Integration Tests - Variable Scopes", () => { await sleep(2000); const variables = await getVariablesForScope(VariableScope.Members); - expect(variables.length).to.equal(2); + expect(variables.length).to.equal(4); expect(variables).to.containSubset([{name: "self"}]); expect(variables).to.containSubset([{name: "member1"}]); - - await sleep(1000); - await vscode.debug.stopDebugging(); - })?.timeout(5000); + expect(variables).to.containSubset([{name: "str_var", value: "ScopeVars::member::str_var"}]); + expect(variables).to.containSubset([{name: "str_var_member_only", value: "ScopeVars::member::str_var_member_only"}]); + })?.timeout(10000); test("should retrieve all built-in types correctly", async function() { const breakpointLocations = await getBreakpointLocations(path.join(workspaceFolder, "BuiltInTypes.gd")); @@ -302,15 +361,12 @@ suite("DAP Integration Tests - Variable Scopes", () => { expect(variables).to.containSubset([{ name: "vector3", value: "Vector3(1, 2, 3)" }]); expect(variables).to.containSubset([{ name: "rect2", value: "Rect2((0, 0) - (100, 50))" }]); expect(variables).to.containSubset([{ name: "quaternion", value: "Quat(0, 0, 0, 1)" }]); - // expect(variables).to.containSubset([{ name: "simple_array", value: "[1, 2, 3]" }]); - expect(variables).to.containSubset([{ name: "simple_array", value: "Array[3]" }]); + expect(variables).to.containSubset([{ name: "simple_array", value: "(3) [1, 2, 3]" }]); // expect(variables).to.containSubset([{ name: "nested_dict.nested_key", value: `"Nested Value"` }]); // expect(variables).to.containSubset([{ name: "nested_dict.sub_dict.sub_key", value: "99" }]); - expect(variables).to.containSubset([{ name: "nested_dict", value: "Dictionary[2]" }]); - // expect(variables).to.containSubset([{ name: "byte_array", value: "[0, 1, 2, 255]" }]); - expect(variables).to.containSubset([{ name: "byte_array", value: "Array[4]" }]); - // expect(variables).to.containSubset([{ name: "int32_array", value: "[100, 200, 300]" }]); - expect(variables).to.containSubset([{ name: "int32_array", value: "Array[3]" }]); + expect(variables).to.containSubset([{ name: "nested_dict", value: "Dictionary(2)" }]); + expect(variables).to.containSubset([{ name: "byte_array", value: "(4) [0, 1, 2, 255]" }]); + expect(variables).to.containSubset([{ name: "int32_array", value: "(3) [100, 200, 300]" }]); expect(variables).to.containSubset([{ name: "color_var", value: "Color(1, 0, 0, 1)" }]); expect(variables).to.containSubset([{ name: "aabb_var", value: "AABB((0, 0, 0), (1, 1, 1))" }]); expect(variables).to.containSubset([{ name: "plane_var", value: "Plane(0, 1, 0, -5)" }]); @@ -318,10 +374,7 @@ suite("DAP Integration Tests - Variable Scopes", () => { expect(variables).to.containSubset([{ name: "signal_var" }]); const signal_var = variables.find(v => v.name === "signal_var"); expect(signal_var.value).to.match(/Signal\(member_signal\, <\d+>\)/, "Should be in format of 'Signal(member_signal, <28236055815>)'"); - - await sleep(1000); - await vscode.debug.stopDebugging(); - })?.timeout(5000); + })?.timeout(10000); test("should retrieve all complex variables correctly", async function() { const breakpointLocations = await getBreakpointLocations(path.join(workspaceFolder, "ExtensiveVars.gd")); @@ -337,23 +390,28 @@ suite("DAP Integration Tests - Variable Scopes", () => { const memberVariables = await getVariablesForScope(VariableScope.Members); - expect(memberVariables.length).to.equal(3); + expect(memberVariables.length).to.equal(3, "Incorrect member variables count"); expect(memberVariables).to.containSubset([{name: "self"}]); expect(memberVariables).to.containSubset([{name: "self_var"}]); + expect(memberVariables).to.containSubset([{name: "label"}]); const self = memberVariables.find(v => v.name === "self"); const self_var = memberVariables.find(v => v.name === "self_var"); expect(self.value).to.deep.equal(self_var.value); const localVariables = await getVariablesForScope(VariableScope.Locals); - expect(localVariables.length).to.equal(4); - expect(localVariables).to.containSubset([ - { name: "local_label", value: "Label" }, - { name: "local_self_var_through_label", value: "Node2D" }, - { name: "local_classA", value: "RefCounted" }, - { name: "local_classB", value: "RefCounted" } - ]); - - await sleep(1000); - await vscode.debug.stopDebugging(); + const expectedLocalVariables = [ + { name: "local_label", value: /Label<\d+>/ }, + { name: "local_self_var_through_label", value: /Node2D<\d+>/ }, + { name: "local_classA", value: /RefCounted<\d+>/ }, + { name: "local_classB", value: /RefCounted<\d+>/ }, + { name: "str_var", value: /^ExtensiveVars::_ready::local::str_var$/ }, + ]; + expect(localVariables.length).to.equal(expectedLocalVariables.length, "Incorrect local variables count"); + expect(localVariables).to.containSubset(expectedLocalVariables.map(v => ({ name: v.name }))); + for (const expectedLocalVariable of expectedLocalVariables) { + const localVariable = localVariables.find(v => v.name === expectedLocalVariable.name); + expect(localVariable).to.exist; + expect(localVariable.value).to.match(expectedLocalVariable.value, `Variable '${expectedLocalVariable.name}' has incorrect value'`); + } })?.timeout(15000); }); diff --git a/src/debugger/godot4/variables/godot_id_to_vscode_id_mapper.test.ts b/src/debugger/godot4/variables/godot_id_to_vscode_id_mapper.test.ts new file mode 100644 index 0000000..9b0be13 --- /dev/null +++ b/src/debugger/godot4/variables/godot_id_to_vscode_id_mapper.test.ts @@ -0,0 +1,58 @@ +import { expect } from "chai"; +import { GodotIdWithPath, GodotIdToVscodeIdMapper } from "./godot_id_to_vscode_id_mapper"; + +suite("GodotIdToVscodeIdMapper", () => { + test("create_vscode_id assigns unique ID", () => { + const mapper = new GodotIdToVscodeIdMapper(); + const godotId = new GodotIdWithPath(BigInt(1), ["path1"]); + const vscodeId = mapper.create_vscode_id(godotId); + expect(vscodeId).to.equal(1); + }); + + test("create_vscode_id throws error on duplicate", () => { + const mapper = new GodotIdToVscodeIdMapper(); + const godotId = new GodotIdWithPath(BigInt(1), ["path1"]); + mapper.create_vscode_id(godotId); + expect(() => mapper.create_vscode_id(godotId)).to.throw("Duplicate godot_id: 1:path1"); + }); + + test("get_godot_id_with_path returns correct object", () => { + const mapper = new GodotIdToVscodeIdMapper(); + const godotId = new GodotIdWithPath(BigInt(2), ["path2"]); + const vscodeId = mapper.create_vscode_id(godotId); + expect(mapper.get_godot_id_with_path(vscodeId)).to.deep.equal(godotId); + }); + + test("get_godot_id_with_path throws error if not found", () => { + const mapper = new GodotIdToVscodeIdMapper(); + expect(() => mapper.get_godot_id_with_path(999)).to.throw("Unknown vscode_id: 999"); + }); + + test("get_vscode_id retrieves correct ID", () => { + const mapper = new GodotIdToVscodeIdMapper(); + const godotId = new GodotIdWithPath(BigInt(3), ["path3"]); + const vscodeId = mapper.create_vscode_id(godotId); + expect(mapper.get_vscode_id(godotId)).to.equal(vscodeId); + }); + + test("get_vscode_id throws error if not found", () => { + const mapper = new GodotIdToVscodeIdMapper(); + const godotId = new GodotIdWithPath(BigInt(4), ["path4"]); + expect(() => mapper.get_vscode_id(godotId)).to.throw("Unknown godot_id_with_path: 4:path4"); + }); + + test("get_or_create_vscode_id creates new ID if not found", () => { + const mapper = new GodotIdToVscodeIdMapper(); + const godotId = new GodotIdWithPath(BigInt(5), ["path5"]); + const vscodeId = mapper.get_or_create_vscode_id(godotId); + expect(vscodeId).to.equal(1); + }); + + test("get_or_create_vscode_id retrieves existing ID if already created", () => { + const mapper = new GodotIdToVscodeIdMapper(); + const godotId = new GodotIdWithPath(BigInt(6), ["path6"]); + const vscodeId1 = mapper.get_or_create_vscode_id(godotId); + const vscodeId2 = mapper.get_or_create_vscode_id(godotId); + expect(vscodeId1).to.equal(vscodeId2); + }); +}); diff --git a/src/debugger/godot4/variables/godot_id_to_vscode_id_mapper.ts b/src/debugger/godot4/variables/godot_id_to_vscode_id_mapper.ts new file mode 100644 index 0000000..6fd4c0e --- /dev/null +++ b/src/debugger/godot4/variables/godot_id_to_vscode_id_mapper.ts @@ -0,0 +1,67 @@ +export class GodotIdWithPath { + constructor(public godot_id: bigint, public path: string[] = []) { + } + + toString(): string { + return `${this.godot_id.toString()}:${this.path.join("/")}`; + } +} + +type GodotIdWithPathString = string; + +export class GodotIdToVscodeIdMapper { + // Maps `godot_id` to `vscode_id` and back. + // Each `vscode_id` corresponds to expandable variable in vscode UI. + // Each `godot_id` corresponds to object in godot server. + // `vscode_id` maps 1:1 with [`godot_id`, path_to_variable_inside_godot_object]. + // For example, if godot_object with id 12345 looks like: { SomeDict: { SomeField: [1,2,3] } }, + // then `vscode_id` for the 'SomeField' will map to [12345, ["SomeDict", "SomeField"]] in order to allow expansion of SomeField in the vscode UI. + // Note: `vscode_id` is a number and `godot_id` is a bigint. + + private godot_to_vscode: Map; // use GodotIdWithPathString, since JS Map treats GodotIdWithPath only by reference + private vscode_to_godot: Map; + private next_vscode_id: number; + + constructor() { + this.godot_to_vscode = new Map(); + this.vscode_to_godot = new Map(); + this.next_vscode_id = 1; + } + + // Creates `vscode_id` for a given `godot_id` and path + create_vscode_id(godot_id_with_path: GodotIdWithPath): number { + const godot_id_with_path_str = godot_id_with_path.toString(); + if (this.godot_to_vscode.has(godot_id_with_path_str)) { + throw new Error(`Duplicate godot_id: ${godot_id_with_path_str}`); + } + + const vscode_id = this.next_vscode_id++; + this.godot_to_vscode.set(godot_id_with_path_str, vscode_id); + this.vscode_to_godot.set(vscode_id, godot_id_with_path); + return vscode_id; + } + + get_godot_id_with_path(vscode_id: number): GodotIdWithPath { + const godot_id_with_path = this.vscode_to_godot.get(vscode_id); + if (godot_id_with_path === undefined) { + throw new Error(`Unknown vscode_id: ${vscode_id}`); + } + return godot_id_with_path; + } + + get_vscode_id(godot_id_with_path: GodotIdWithPath, fail_if_not_found = true): number | undefined { + const vscode_id = this.godot_to_vscode.get(godot_id_with_path.toString()); + if (fail_if_not_found && vscode_id === undefined) { + throw new Error(`Unknown godot_id_with_path: ${godot_id_with_path}`); + } + return vscode_id; + } + + get_or_create_vscode_id(godot_id_with_path: GodotIdWithPath): number { + let vscode_id = this.get_vscode_id(godot_id_with_path, false); + if (vscode_id === undefined) { + vscode_id = this.create_vscode_id(godot_id_with_path); + } + return vscode_id; + } +} \ No newline at end of file diff --git a/src/debugger/godot4/variables/godot_object_promise.test.ts b/src/debugger/godot4/variables/godot_object_promise.test.ts new file mode 100644 index 0000000..685bf6e --- /dev/null +++ b/src/debugger/godot4/variables/godot_object_promise.test.ts @@ -0,0 +1,79 @@ +import sinon from "sinon"; +import chai from "chai"; +import { GodotObject, GodotObjectPromise } from "./godot_object_promise"; +// import chaiAsPromised from "chai-as-promised"; +// eslint-disable-next-line @typescript-eslint/no-var-requires +var chaiAsPromised = import("chai-as-promised"); +// const chaiAsPromised = await import("chai-as-promised"); // TODO: use after migration to ECMAScript modules + +chaiAsPromised.then((module) => { + chai.use(module.default); +}); +const { expect } = chai; + + +suite("GodotObjectPromise", () => { + let clock; + + setup(() => { + clock = sinon.useFakeTimers(); // Use Sinon to control time + }); + + teardown(() => { + clock.restore(); // Restore the real timers after each test + }); + + test("resolves successfully with a valid GodotObject", async () => { + const godotObject: GodotObject = { + godot_id: BigInt(1), + type: "TestType", + sub_values: [] + }; + + const promise = new GodotObjectPromise(); + setTimeout(() => promise.resolve(godotObject), 10); + clock.tick(10); // Fast-forward time + await expect(promise.promise).to.eventually.equal(godotObject); + }); + + test("rejects with an error when explicitly called", async () => { + const promise = new GodotObjectPromise(); + const error = new Error("Test rejection"); + setTimeout(() => promise.reject(error), 10); + clock.tick(10); // Fast-forward time + await expect(promise.promise).to.be.rejectedWith("Test rejection"); + }); + + test("rejects due to timeout", async () => { + const promise = new GodotObjectPromise(50); + clock.tick(50); // Fast-forward time + await expect(promise.promise).to.be.rejectedWith("GodotObjectPromise timed out"); + }); + + test("does not reject if resolved before timeout", async () => { + const godotObject: GodotObject = { + godot_id: BigInt(2), + type: "AnotherTestType", + sub_values: [] + }; + + const promise = new GodotObjectPromise(100); + setTimeout(() => promise.resolve(godotObject), 10); + clock.tick(10); // Fast-forward time + await expect(promise.promise).to.eventually.equal(godotObject); + }); + + test("clears timeout when resolved", async () => { + const promise = new GodotObjectPromise(1000); + promise.resolve({ godot_id: BigInt(3), type: "ResolvedType", sub_values: [] }); + clock.tick(1000); // Fast-forward time + await expect(promise.promise).to.eventually.be.fulfilled; + }); + + test("clears timeout when rejected", async () => { + const promise = new GodotObjectPromise(1000); + promise.reject(new Error("Rejected")); + clock.tick(1000); // Fast-forward time + await expect(promise.promise).to.be.rejectedWith("Rejected"); + }); +}); diff --git a/src/debugger/godot4/variables/godot_object_promise.ts b/src/debugger/godot4/variables/godot_object_promise.ts new file mode 100644 index 0000000..c751dc9 --- /dev/null +++ b/src/debugger/godot4/variables/godot_object_promise.ts @@ -0,0 +1,52 @@ +import { GodotVariable } from "../../debug_runtime"; + +export interface GodotObject { + godot_id: bigint; + type: string; + sub_values: GodotVariable[]; +} + +/** + * A promise that resolves to a {@link GodotObject}. + * + * This promise is used to handle the asynchronous nature of requesting a Godot object. + * It is used as a placeholder until the actual object is received. + * + * When the object is received from the server, the promise is resolved with the object. + * If the object is not received within a certain time, the promise is rejected with an error. + */ +export class GodotObjectPromise { + private _resolve!: (value: GodotObject | PromiseLike) => void; + private _reject!: (reason?: any) => void; + public promise: Promise; + private timeoutId?: NodeJS.Timeout; + + constructor(timeoutMs?: number) { + this.promise = new Promise((resolve_arg, reject_arg) => { + this._resolve = resolve_arg; + this._reject = reject_arg; + + if (timeoutMs !== undefined) { + this.timeoutId = setTimeout(() => { + reject_arg(new Error("GodotObjectPromise timed out")); + }, timeoutMs); + } + }); + } + + async resolve(value: GodotObject) { + if (this.timeoutId) { + clearTimeout(this.timeoutId); + this.timeoutId = undefined; + } + await this._resolve(value); + } + + async reject(reason: Error) { + if (this.timeoutId) { + clearTimeout(this.timeoutId); + this.timeoutId = undefined; + } + await this._reject(reason); + } +} \ No newline at end of file diff --git a/src/debugger/godot4/variables/variables_manager.ts b/src/debugger/godot4/variables/variables_manager.ts new file mode 100644 index 0000000..4af4a0b --- /dev/null +++ b/src/debugger/godot4/variables/variables_manager.ts @@ -0,0 +1,240 @@ +import { DebugProtocol } from "@vscode/debugprotocol"; +import { ServerController } from "../server_controller"; +import { GodotObject, GodotObjectPromise } from "./godot_object_promise"; +import { GodotVariable } from "../../debug_runtime"; +import { ObjectId } from "./variants"; +import { GodotIdToVscodeIdMapper, GodotIdWithPath } from "./godot_id_to_vscode_id_mapper"; + +export interface VsCodeScopeIDs { + Locals: number; + Members: number; + Globals: number; +} + +export class VariablesManager { + constructor(public controller: ServerController) { + } + + public godot_object_promises: Map= new Map(); + public godot_id_to_vscode_id_mapper = new GodotIdToVscodeIdMapper(); + + // variablesFrameId: number; + + private frame_id_to_scopes_map: Map = new Map(); + + /** + * Returns Locals, Members, and Globals vscode_ids + * @param stack_frame_id the id of the stack frame + * @returns an object with Locals, Members, and Globals vscode_ids + */ + public get_or_create_frame_scopes(stack_frame_id: number): VsCodeScopeIDs { + var scopes = this.frame_id_to_scopes_map.get(stack_frame_id); + if (scopes === undefined) { + const frame_id = BigInt(stack_frame_id); + scopes = {} as VsCodeScopeIDs; + scopes.Locals = this.godot_id_to_vscode_id_mapper.get_or_create_vscode_id(new GodotIdWithPath(-frame_id*3n-1n, [])); + scopes.Members = this.godot_id_to_vscode_id_mapper.get_or_create_vscode_id(new GodotIdWithPath(-frame_id*3n-2n, [])); + scopes.Globals = this.godot_id_to_vscode_id_mapper.get_or_create_vscode_id(new GodotIdWithPath(-frame_id*3n-3n, [])); + this.frame_id_to_scopes_map.set(stack_frame_id, scopes); + } + + return scopes; + } + + /** + * Retrieves a Godot object from the cache or godot debug server + * @param godot_id the id of the object + * @returns a promise that resolves to the requested object + */ + public async get_godot_object(godot_id: bigint, force_refresh = false) { + if (force_refresh) { + // delete the object + this.godot_object_promises.delete(godot_id); + + // check if member scopes also need to be refreshed: + for (const [stack_frame_id, scopes] of this.frame_id_to_scopes_map) { + const members_godot_id = this.godot_id_to_vscode_id_mapper.get_godot_id_with_path(scopes.Members); + const scopes_object = await this.get_godot_object(members_godot_id.godot_id); + const self = scopes_object.sub_values.find((sv) => sv.name === "self"); + if (self !== undefined && self.value instanceof ObjectId) { + if (self.value.id === godot_id) { + this.godot_object_promises.delete(members_godot_id.godot_id); // force refresh the member scope + } + } + } + } + var variable_promise = this.godot_object_promises.get(godot_id); + if (variable_promise === undefined) { + // variable not found, request one + if (godot_id < 0) { + // special case for scopes, which have godot_id below 0. see @this.get_or_create_frame_scopes + // all 3 scopes for current stackFrameId are retrieved at the same time, aka [-1,-2-,3], [-4,-5,-6], etc.. + // init corresponding promises + const requested_stack_frame_id = (-godot_id-1n)/3n; + // this.variablesFrameId will be undefined when the debugger just stopped at breakpoint: + // evaluateRequest is called before scopesRequest + const local_scopes_godot_id = -requested_stack_frame_id*3n-1n; + const member_scopes_godot_id = -requested_stack_frame_id*3n-2n; + const global_scopes_godot_id = -requested_stack_frame_id*3n-3n; + this.godot_object_promises.set(local_scopes_godot_id, new GodotObjectPromise()); + this.godot_object_promises.set(member_scopes_godot_id, new GodotObjectPromise()); + this.godot_object_promises.set(global_scopes_godot_id, new GodotObjectPromise()); + variable_promise = this.godot_object_promises.get(godot_id); + // request stack vars from godot server, which will resolve variable promises 1,2 & 3 + // see file://../server_controller.ts 'case "stack_frame_vars":' + this.controller.request_stack_frame_vars(Number(requested_stack_frame_id)); + } else { + this.godot_id_to_vscode_id_mapper.get_or_create_vscode_id(new GodotIdWithPath(godot_id, [])); + variable_promise = new GodotObjectPromise(); + this.godot_object_promises.set(godot_id, variable_promise); + // request the object from godot server. Once godot server responds, the controller will resolve the variable_promise + this.controller.request_inspect_object(godot_id); + } + } + const godot_object = await variable_promise.promise; + + return godot_object; + } + + public async get_vscode_object(vscode_id: number): Promise { + const godot_id_with_path = this.godot_id_to_vscode_id_mapper.get_godot_id_with_path(vscode_id); + if (godot_id_with_path === undefined) { + throw new Error(`Unknown variablesReference ${vscode_id}`); + } + const godot_object = await this.get_godot_object(godot_id_with_path.godot_id); + if (godot_object === undefined) { + throw new Error(`Cannot retrieve path '${godot_id_with_path.toString()}'. Godot object with id ${godot_id_with_path.godot_id} not found.`); + } + + let sub_values: GodotVariable[] = godot_object.sub_values; + + // if the path is specified, walk the godot_object using it to access the requested variable: + for (const [idx, path] of godot_id_with_path.path.entries()) { + const sub_val = sub_values.find((sv) => sv.name === path); + if (sub_val === undefined) { + throw new Error(`Cannot retrieve path '${godot_id_with_path.toString()}'. Following subpath not found: '${godot_id_with_path.path.slice(0, idx+1).join("/")}'.`); + } + sub_values = sub_val.sub_values; + } + + const variables: DebugProtocol.Variable[] = []; + for (const va of sub_values) { + const godot_id_with_path_sub = va.id !== undefined ? new GodotIdWithPath(va.id, []) : undefined; + const vscode_id = godot_id_with_path_sub !== undefined ? this.godot_id_to_vscode_id_mapper.get_or_create_vscode_id(godot_id_with_path_sub) : 0; + const variable: DebugProtocol.Variable = await this.parse_variable(va, vscode_id, godot_id_with_path.godot_id, godot_id_with_path.path, this.godot_id_to_vscode_id_mapper); + variables.push(variable); + } + + return variables; + } + + public async get_vscode_variable_by_name(variable_name: string, stack_frame_id: number): Promise { + let variable: GodotVariable; + + const variable_names = variable_name.split("."); + + for (var i = 0; i < variable_names.length; i++) { + if (i === 0) { + // find the first part of variable_name in scopes. Locals first, then Members, then Globals + const vscode_scope_ids = this.get_or_create_frame_scopes(stack_frame_id); + const vscode_ids = [vscode_scope_ids.Locals, vscode_scope_ids.Members, vscode_scope_ids.Globals]; + const godot_ids = vscode_ids.map(vscode_id => this.godot_id_to_vscode_id_mapper.get_godot_id_with_path(vscode_id)) + .map(godot_id_with_path => godot_id_with_path.godot_id); + for (var godot_id of godot_ids) { + // check each scope for requested variable + const scope = await this.get_godot_object(godot_id); + variable = scope.sub_values.find((sv) => sv.name === variable_names[0]); + if (variable !== undefined) { + break; + } + } + } else { + // just look up the subpath using the current variable + if (variable.value instanceof ObjectId) { + const godot_object = await this.get_godot_object(variable.value.id); + variable = godot_object.sub_values.find((sv) => sv.name === variable_names[i]); + } else { + variable = variable.sub_values.find((sv) => sv.name === variable_names[i]); + } + } + if (variable === undefined) { + throw new Error(`Cannot retrieve path '${variable_name}'. Following subpath not found: '${variable_names.slice(0, i+1).join(".")}'`); + } + } + + const parsed_variable = await this.parse_variable(variable, undefined, godot_id, [], this.godot_id_to_vscode_id_mapper); + if (parsed_variable.variablesReference === undefined) { + const objectId = variable.value instanceof ObjectId ? variable.value : undefined; + const vscode_id = objectId !== undefined ? this.godot_id_to_vscode_id_mapper.get_or_create_vscode_id(new GodotIdWithPath(objectId.id, [])) : 0; + parsed_variable.variablesReference = vscode_id; + } + + return parsed_variable; + } + + private async parse_variable(va: GodotVariable, vscode_id?: number, parent_godot_id?: bigint, relative_path?: string[], mapper?: GodotIdToVscodeIdMapper): Promise { + const value = va.value; + let rendered_value = ""; + let reference = 0; + + if (typeof value === "number") { + if (Number.isInteger(value)) { + rendered_value = `${value}`; + } else { + rendered_value = `${parseFloat(value.toFixed(5))}`; + } + } else if ( + typeof value === "bigint" || + typeof value === "boolean" || + typeof value === "string" + ) { + rendered_value = `${value}`; + } else if (typeof value === "undefined") { + rendered_value = "null"; + } else { + if (Array.isArray(value)) { + rendered_value = `(${value.length}) [${value.slice(0, 10).join(", ")}]`; + reference = mapper.get_or_create_vscode_id(new GodotIdWithPath(parent_godot_id, [...relative_path, va.name])); + } else if (value instanceof Map) { + rendered_value = value["class_name"] ?? `Dictionary(${value.size})`; + reference = mapper.get_or_create_vscode_id(new GodotIdWithPath(parent_godot_id, [...relative_path, va.name])); + } else if (value instanceof ObjectId) { + if (value.id === undefined) { + throw new Error("Invalid godot object: instanceof ObjectId but id is undefined"); + } + // Godot returns only ID for the object. + // In order to retrieve the class name, we need to request the object + const godot_object = await this.get_godot_object(value.id); + rendered_value = `${godot_object.type}${value.stringify_value()}`; + // rendered_value = `${value.type_name()}${value.stringify_value()}`; + reference = vscode_id; + } + else { + try { + rendered_value = `${value.type_name()}${value.stringify_value()}`; + } catch (e) { + rendered_value = `${value}`; + } + reference = mapper.get_or_create_vscode_id(new GodotIdWithPath(parent_godot_id, [...relative_path, va.name])); + // reference = vsode_id ? vsode_id : 0; + } + } + + const variable: DebugProtocol.Variable = { + name: va.name, + value: rendered_value, + variablesReference: reference + }; + + return variable; + } + + public resolve_variable(godot_id: bigint, className: string, sub_values: GodotVariable[]) { + const variable_promise = this.godot_object_promises.get(godot_id); + if (variable_promise === undefined) { + throw new Error(`Received 'inspect_object' for godot_id ${godot_id} but no variable promise to resolve found`); + } + + variable_promise.resolve({godot_id: godot_id, type: className, sub_values: sub_values} as GodotObject); + } +} \ No newline at end of file diff --git a/src/debugger/godot4/variables/variants.ts b/src/debugger/godot4/variables/variants.ts index 1cb3da1..39fea63 100644 --- a/src/debugger/godot4/variables/variants.ts +++ b/src/debugger/godot4/variables/variants.ts @@ -283,7 +283,7 @@ export class ObjectId implements GDObject { } public type_name(): string { - return "Object"; + return "ObjectId"; } } diff --git a/src/debugger/inspector_provider.ts b/src/debugger/inspector_provider.ts index 5125bc9..c85ce57 100644 --- a/src/debugger/inspector_provider.ts +++ b/src/debugger/inspector_provider.ts @@ -24,15 +24,15 @@ export class InspectorProvider implements TreeDataProvider { this._on_did_change_tree_data.fire(undefined); } - public getChildren(element?: RemoteProperty): ProviderResult { + public getChildren(element?: RemoteProperty): RemoteProperty[] { if (!this.tree) { - return Promise.resolve([]); + return []; } if (!element) { - return Promise.resolve([this.tree]); + return [this.tree]; } else { - return Promise.resolve(element.properties); + return element.properties; } } diff --git a/src/debugger/scene_tree_provider.ts b/src/debugger/scene_tree_provider.ts index 330127e..d150615 100644 --- a/src/debugger/scene_tree_provider.ts +++ b/src/debugger/scene_tree_provider.ts @@ -28,15 +28,15 @@ export class SceneTreeProvider implements TreeDataProvider { this._on_did_change_tree_data.fire(undefined); } - public getChildren(element?: SceneNode): ProviderResult { + public getChildren(element?: SceneNode): SceneNode[] { if (!this.tree) { - return Promise.resolve([]); + return []; } if (!element) { - return Promise.resolve([this.tree]); + return [this.tree]; } else { - return Promise.resolve(element.children); + return element.children; } } diff --git a/test_projects/test-dap-project-godot4/BuiltInTypes.gd b/test_projects/test-dap-project-godot4/BuiltInTypes.gd index 47b2124..0a492dc 100644 --- a/test_projects/test-dap-project-godot4/BuiltInTypes.gd +++ b/test_projects/test-dap-project-godot4/BuiltInTypes.gd @@ -17,8 +17,11 @@ func _ready() -> void: var simple_array = [1, 2, 3] var nested_dict = { "nested_key": "Nested Value", - "sub_dict": {"sub_key": 99} - } + "sub_dict": {"sub_key": 99}, + } + var mixed_dict = { + "nested_array": [1,2, {"nested_dict": [3,4,5]}] + } var byte_array = PackedByteArray([0, 1, 2, 255]) var int32_array = PackedInt32Array([100, 200, 300]) var color_var = Color(1, 0, 0, 1) # Red color diff --git a/test_projects/test-dap-project-godot4/BuiltInTypes.gd.uid b/test_projects/test-dap-project-godot4/BuiltInTypes.gd.uid new file mode 100644 index 0000000..5d6283e --- /dev/null +++ b/test_projects/test-dap-project-godot4/BuiltInTypes.gd.uid @@ -0,0 +1 @@ +uid://bl7k8rh4vgbma diff --git a/test_projects/test-dap-project-godot4/ExtensiveVars.gd b/test_projects/test-dap-project-godot4/ExtensiveVars.gd index 59e8e7e..d06e796 100644 --- a/test_projects/test-dap-project-godot4/ExtensiveVars.gd +++ b/test_projects/test-dap-project-godot4/ExtensiveVars.gd @@ -1,11 +1,22 @@ extends Node2D +class_name ExtensiveVars + var self_var := self @onready var label: ExtensiveVars_Label = $Label +# var editor_description := "ExtensiveVars::member::text overrides" +# var rotation = 2 + class ClassA: var member_classB var member_self := self + var str_var := "ExtensiveVars::ClassA::member::str_var" + func test_function(delta: float) -> void: + var str_var := "ExtensiveVars::ClassA::test_function::local::str_var" + var local_self := self.member_self; + print("breakpoint::ExtensiveVars::ClassA::test_function") + class ClassB: var member_classA @@ -19,6 +30,8 @@ func _ready() -> void: local_classA.member_classB = local_classB local_classB.member_classA = local_classA + var str_var := "ExtensiveVars::_ready::local::str_var" + # Circular reference. # Note: that causes the godot engine to omit this variable, since stack_frame_var cannot be completed and sent # https://github.com/godotengine/godot/issues/76019 @@ -28,11 +41,21 @@ func _ready() -> void: print("breakpoint::ExtensiveVars::_ready") func _process(delta: float) -> void: + var str_var := "ExtensiveVars::_process::local::str_var" + test(delta) + +func test(delta: float): + var str_var := "ExtensiveVars::test::local::str_var" var local_label := label var local_self_var_through_label := label.parent_var + + var large_dict = {} + for i in range(1000): + large_dict["variable" + str(i)] = "Some very long value, which will be in the dictionary" - var local_classA = ClassA.new() - var local_classB = ClassB.new() - local_classA.member_classB = local_classB - local_classB.member_classA = local_classA + var local_classA2 = ClassA.new() + var local_classB2 = ClassB.new() + local_classA2.member_classB = local_classB2 + local_classB2.member_classA = local_classA2 + local_classA2.test_function(delta); pass diff --git a/test_projects/test-dap-project-godot4/ExtensiveVars.gd.uid b/test_projects/test-dap-project-godot4/ExtensiveVars.gd.uid new file mode 100644 index 0000000..058e0da --- /dev/null +++ b/test_projects/test-dap-project-godot4/ExtensiveVars.gd.uid @@ -0,0 +1 @@ +uid://jj6y8lb0lkij diff --git a/test_projects/test-dap-project-godot4/ExtensiveVars_Label.gd.uid b/test_projects/test-dap-project-godot4/ExtensiveVars_Label.gd.uid new file mode 100644 index 0000000..3899672 --- /dev/null +++ b/test_projects/test-dap-project-godot4/ExtensiveVars_Label.gd.uid @@ -0,0 +1 @@ +uid://ca1f5tmqgm6hu diff --git a/test_projects/test-dap-project-godot4/GlobalScript.gd.uid b/test_projects/test-dap-project-godot4/GlobalScript.gd.uid new file mode 100644 index 0000000..f089764 --- /dev/null +++ b/test_projects/test-dap-project-godot4/GlobalScript.gd.uid @@ -0,0 +1 @@ +uid://c4ypojhmiyhhf diff --git a/test_projects/test-dap-project-godot4/Node1.gd.uid b/test_projects/test-dap-project-godot4/Node1.gd.uid new file mode 100644 index 0000000..3e900d1 --- /dev/null +++ b/test_projects/test-dap-project-godot4/Node1.gd.uid @@ -0,0 +1 @@ +uid://bxlldk7s267hd diff --git a/test_projects/test-dap-project-godot4/NodeVars.gd b/test_projects/test-dap-project-godot4/NodeVars.gd index 64cb3c3..0b350ea 100644 --- a/test_projects/test-dap-project-godot4/NodeVars.gd +++ b/test_projects/test-dap-project-godot4/NodeVars.gd @@ -5,5 +5,6 @@ extends Node2D # Called when the node enters the scene tree for the first time. func _ready() -> void: + var local_node_1 = node_1; print("breakpoint::NodeVars::_ready") pass diff --git a/test_projects/test-dap-project-godot4/NodeVars.gd.uid b/test_projects/test-dap-project-godot4/NodeVars.gd.uid new file mode 100644 index 0000000..c52ebef --- /dev/null +++ b/test_projects/test-dap-project-godot4/NodeVars.gd.uid @@ -0,0 +1 @@ +uid://ciokiqoyaox13 diff --git a/test_projects/test-dap-project-godot4/ScopeVars.gd b/test_projects/test-dap-project-godot4/ScopeVars.gd index c2c7142..0d4a2fd 100644 --- a/test_projects/test-dap-project-godot4/ScopeVars.gd +++ b/test_projects/test-dap-project-godot4/ScopeVars.gd @@ -2,7 +2,25 @@ extends Node var member1 := TestClassA.new() +var str_var := "ScopeVars::member::str_var" +var str_var_member_only := "ScopeVars::member::str_var_member_only" + +class ClassFoo: + var member_ClassFoo + var str_var := "ScopeVars::ClassFoo::member::str_var" + var str_var_member_only := "ScopeVars::ClassFoo::member::str_var_member_only" + func test_function(delta: float) -> void: + var str_var := "ScopeVars::ClassFoo::test_function::local::str_var" + print("breakpoint::ScopeVars::ClassFoo::test_function") + + func _ready() -> void: - var local1 := TestClassA.new() - var local2 = GlobalScript.globalMember + var str_var := "ScopeVars::_ready::local::str_var" + var self_var := self print("breakpoint::ScopeVars::_ready") + test(0.123); + +func test(val: float): + var str_var := "ScopeVars::test::local::str_var" + var foo := ClassFoo.new() + foo.test_function(val) \ No newline at end of file diff --git a/test_projects/test-dap-project-godot4/ScopeVars.gd.uid b/test_projects/test-dap-project-godot4/ScopeVars.gd.uid new file mode 100644 index 0000000..db3c5a0 --- /dev/null +++ b/test_projects/test-dap-project-godot4/ScopeVars.gd.uid @@ -0,0 +1 @@ +uid://cbgugy44s0uia diff --git a/test_projects/test-dap-project-godot4/TestClassA.gd.uid b/test_projects/test-dap-project-godot4/TestClassA.gd.uid new file mode 100644 index 0000000..4f10c06 --- /dev/null +++ b/test_projects/test-dap-project-godot4/TestClassA.gd.uid @@ -0,0 +1 @@ +uid://ct5jeingo4ge