Compare commits

..

41 Commits
2.3.0 ... 2.5.1

Author SHA1 Message Date
Hugo Locurcio
d9ea6245d4 Bump to version 2.5.1 2025-03-15 20:58:11 +01:00
David Kincaid
e38db288b7 Add ability to suppress LSP error messages (#823) 2025-03-15 15:01:35 -04:00
Hugo Locurcio
0da21f23a3 Bump to version 2.5.0 2025-03-11 00:18:18 +01:00
David Kincaid
a04f58c82d Scene Preview Improvements (relative path drag/drop) (#815)
* Improve scene preview lock behavior
* Add convert_uri_to_resource_path utility function
* Implement relative nodepath dropping
* Prevent a possible error when trying to refresh a scene that doesn't exist
* Fix wrong command name being called (scenePreview.openMainScene has actually never worked)
2025-03-10 04:50:00 -04:00
David Kincaid
03606fdb3a Various LSP Client Improvements (#816)
* Fix outgoing LSP messages not actually being discarded
* Resend init message if reconnecting (fixes #818)
* Added wrong project disconnect feature
* Update vscode-languageclient from ^7.0.0 to ^9.0.1
2025-03-10 04:49:05 -04:00
David Kincaid
f4ae73c9a0 Add automatic project formatting (#814)
* Add biome as a dev dependency and add "npm format" script

* Align new debugger code with project style
2025-03-04 19:54:47 -05:00
David Kincaid
1dcbd651df Add snippet/placeholder behavior to Scene Preview file drops (#813) 2025-03-04 19:11:27 -05:00
dependabot[bot]
54f68f15ea Bump esbuild from 0.17.19 to 0.25.0 (#790)
Bumps [esbuild](https://github.com/evanw/esbuild) from 0.17.19 to 0.25.0.
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2023.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.17.19...v0.25.0)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-04 19:10:58 -05:00
David Kincaid
0203cec293 Formatting and Highlighting Fixes (#812)
Fixes #805 and #810
2025-03-04 18:56:21 -05:00
Mikhail Zolotukhin
34de1b64f0 Fix scene preview with nodes, that have spaces in names (#806)
Fixes #688
2025-03-04 15:39:58 -05:00
David Kincaid
e528384ea5 Add godotTools.openEditorSettings command (#802)
Add godotTools.openEditorSettingscommand
2025-02-27 12:27:27 -05:00
dependabot[bot]
c5c7aa2ced Bump actions/upload-artifact from 4.6.0 to 4.6.1 (#803)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.0 to 4.6.1.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4.6.0...v4.6.1)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-24 13:01:52 -05:00
Arron
0a3c319879 Add region blocks as a language scope for themes (#800) 2025-02-22 13:46:32 -05:00
David Kincaid
3f7a944e96 More Highlighting and Formatting Improvements (#783)
* Fix **= operator being formatted incorrectly

* Fix variables in get_node()-style function calls not being highlighted

* Move super from builtin_classes to keywords

* Fix uppercase builtin classes being highlighted as constants

* Fix setter and getter highlighting/formatting

* Fix variable as default parameter not highlighted in function declaration
2025-02-22 12:51:44 -05:00
Sabs, like "Sobs
51ef0ef0c0 Add Autosave details and Server Port note to README (#771)
* Add Autosave details and Server Port note to README
2025-02-22 12:50:21 -05:00
Saint
b29fbb75a0 Add print_rich() support to Debug Console (#792)
* add bbcode support to debug console
* fix output line for Debug Console
* Update debug console output. Add Godot3.6 support.
2025-02-22 12:36:11 -05:00
Jesse Ward
b986ce0e51 Resolves godotengine/godot-vscode-plugin#796 (#797) 2025-02-22 12:31:57 -05:00
Eric Cosky
035211276d Fixes for attached debugging and related improvements. (#784)
'launch' and 'attach' modes are working with these changes. The root problems were related to the version of the attached project not being detected properly and file paths not being correctly calculated when attached. The networking code that had version-dependent behavior is now a bit more robust and won't break if minor versions were to ever exceed 1 digit.
When using 'attach' mode, the version info wasn't available at all, causing most (all?) network messages to be ignored.
2025-02-22 12:28:38 -05:00
MichaelXt
53f48ede63 DebugAdapter variables overhaul (#793)
- Redesigned the representation of godot objects to match internal structure of godot server
- Lazy evaluation for the godot objects
- Stack frames now can be switched with variables updated
2025-02-22 12:17:55 -05:00
Hugo Locurcio
7844979c90 Bump to version 2.4.0 2025-02-22 00:07:17 +01:00
Saint
9297920d73 Add setting to enable/disable documentation minimap (#786) 2025-02-13 11:47:23 -05:00
btarg
8059ba89c2 Add some useful GDScript snippets for Godot 4 (#794) 2025-02-13 10:57:29 -05:00
MichaelXt
2490d0cdad Implement Godot-in-the-loop test suite and fix debugger errors (#788)
Fixes for get variables issues
1. Same reference but different variable override fix, which resulted in variables being lost
** Now different GodotVariable instances are used for different variables with same reference
** const replacement = {value: rawObject, sub_values: sub_values } as GodotVariable;
2. 'Signal' type handling crash and string representation fix
** value.sub_values()?.map
3. Empty scopes return fix
** if (this.ongoing_inspections.length === 0  && stackVars.remaining == 0)
4. Various splice from findIndex fixes (where findIndex can return -1)
5. Added variables tests
**  updated vscode types to version 1.96 to use `onDidChangeActiveStackItem` for breakpoint hit detection in tests
1 & 3 should fix https://github.com/godotengine/godot-vscode-plugin/issues/779
2025-02-10 16:56:13 -05:00
dependabot[bot]
7c70ac2753 Bump actions/setup-node from 4.1.0 to 4.2.0 (#785)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4.1.0 to 4.2.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4.1.0...v4.2.0)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-27 12:40:23 -05:00
MichaelXt
e7b9530a7f Fix debugger watch window freeze caused by missing responses (#781) 2025-01-26 13:33:50 -05:00
dependabot[bot]
002cfa18a3 Bump actions/upload-artifact from 4.5.0 to 4.6.0 (#774)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.5.0 to 4.6.0.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4.5.0...v4.6.0)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-01-10 11:03:54 -05:00
David Kincaid
489db36e85 Add debugger support for Typed Dictionaries (#764)
Add decoding support for Typed Dictionaries
2025-01-03 21:40:31 -05:00
KIM JISU
996a7aefe6 Add newline when dropping nodes into editor (#754)
Co-authored-by: k-expon <kimexpon@pm.me>
2025-01-03 21:02:58 -05:00
dependabot[bot]
2e9117870d Bump actions/upload-artifact from 4.4.3 to 4.5.0 (#766)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.4.3 to 4.5.0.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4.4.3...v4.5.0)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-18 11:38:21 -05:00
David Kincaid
aee83dd2a4 Update float syntax rules and formatting (#756)
* Update float syntax rules and formatting

* Add missing export_group annotation

* Moved abstract from an annotation to a keyword

* Add an example of the other type of string interpolation.
2024-12-18 10:45:05 -05:00
David Kincaid
6ddf05d4a1 Fix VBox and HBox docs not opening (#755) 2024-11-18 11:25:30 -05:00
David Kincaid
f648c37353 Various Formatter Improvements (#746)
* add new style of formatter snapshot tests
* add many new test cases
* fix several open issues( #728, #624, #657, #717, #734, likely more)
2024-11-18 11:16:16 -05:00
David Kincaid
709fa1bbad Implement warnings and errors in Debug Console (#749) 2024-11-18 11:11:30 -05:00
David Kincaid
694feea1bc Overhaul LSP Client (#752)
* Simplify LSP Client internals
* Streamline control flow between Client, IO, and Buffer classes
* Create canonical, obvious place to implement filters on incoming and outgoing LSP messages
* Remove legacy WS LSP support
2024-11-18 10:53:59 -05:00
dependabot[bot]
fd637d0641 Bump actions/setup-node from 4.0.4 to 4.1.0 (#741)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4.0.4 to 4.1.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4.0.4...v4.1.0)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-24 12:31:37 -04:00
Hugo Locurcio
c33982d38e Remove OS, GDScript and Object from the list of builtins in syntax highlighting (#739) 2024-10-21 09:03:45 -04:00
ProgramGamer
43bb36ca30 Fixed the textmate grammar erroneously tagging enum members and const variables as language constants (#737) 2024-10-19 14:40:23 -04:00
Hugo Locurcio
96510971f4 Add @static_unload annotation and Godot 4.3 Variant types to syntax highlighting (#738) 2024-10-19 14:39:34 -04:00
Mikael Hermansson
0a632d62b5 Fix typed arrays of scripts not being decoded properly (#731) 2024-10-11 11:03:34 -04:00
dependabot[bot]
4404b76006 Bump actions/upload-artifact from 4.4.2 to 4.4.3 (#733)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.4.2 to 4.4.3.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4.4.2...v4.4.3)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-11 16:38:38 +02:00
dependabot[bot]
1c32bbb1cb Bump actions/upload-artifact from 4.4.0 to 4.4.2 (#732)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.4.0 to 4.4.2.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4.4.0...v4.4.2)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-10-09 11:24:47 -04:00
98 changed files with 5108 additions and 2358 deletions

View File

@@ -29,7 +29,7 @@ body:
Use the **Help > About** menu to see your current version.
Specify the Git commit hash if using a development or non-official build.
If you use a custom build, please test if your issue is reproducible in official builds too.
placeholder: "1.93.1"
placeholder: "1.98.1"
validations:
required: true
@@ -40,7 +40,7 @@ body:
Open the **Extensions** side panel and click on the **godot-tools** extension to see your current version.
Specify the Git commit hash if using a development or non-official build.
If you use a custom build, please test if your issue is reproducible in official builds too.
placeholder: "2.3.0"
placeholder: "2.5.1"
validations:
required: true

View File

@@ -29,7 +29,7 @@ body:
Use the **Help > About** menu to see your current version.
Specify the Git commit hash if using a development or non-official build.
If you use a custom build, please test if your issue is reproducible in official builds too.
placeholder: "1.93.1"
placeholder: "1.98.1"
validations:
required: true
@@ -40,7 +40,7 @@ body:
Open the **Extensions** side panel and click on the **godot-tools** extension to see your current version.
Specify the Git commit hash if using a development or non-official build.
If you use a custom build, please test if your issue is reproducible in official builds too.
placeholder: "2.3.0"
placeholder: "2.5.1"
validations:
required: true

View File

@@ -5,6 +5,7 @@ jobs:
test:
name: Test
strategy:
fail-fast: false
matrix:
os: [macos-latest, ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
@@ -13,13 +14,39 @@ jobs:
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4.0.4
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'
run: |
wget https://github.com/godotengine/godot/releases/download/4.3-stable/Godot_v4.3-stable_linux.x86_64.zip
unzip Godot_v4.3-stable_linux.x86_64.zip
sudo mv Godot_v4.3-stable_linux.x86_64 /usr/local/bin/godot
chmod +x /usr/local/bin/godot
- name: Install Godot (macOS)
if: matrix.os == 'macos-latest'
run: |
curl -L -o Godot.zip https://github.com/godotengine/godot/releases/download/4.3-stable/Godot_v4.3-stable_macos.universal.zip
unzip Godot.zip
sudo mv Godot.app /Applications/Godot.app
sudo ln -s /Applications/Godot.app/Contents/MacOS/Godot /usr/local/bin/godot
- name: Install Godot (Windows)
if: matrix.os == 'windows-latest'
run: |
Invoke-WebRequest -Uri "https://github.com/godotengine/godot/releases/download/4.3-stable/Godot_v4.3-stable_win64.exe.zip" -OutFile "Godot.zip"
Expand-Archive -Path "Godot.zip" -DestinationPath "C:\Godot43"
"C:\Godot43\Godot_v4.3-stable_win64.exe %*" | Out-File -Encoding ascii -FilePath ([Environment]::SystemDirectory+"\godot.cmd")
- name: Install dependencies
run: npm install
- name: Godot init project
run: godot --import test_projects/test-dap-project-godot4/project.godot --headless
- name: Run headless test
uses: coactions/setup-xvfb@v1
with:
@@ -34,7 +61,7 @@ jobs:
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4.0.4
uses: actions/setup-node@v4.2.0
with:
node-version: 16.x
@@ -48,7 +75,7 @@ jobs:
ls -l godot-tools.vsix
- name: Upload extension VSIX
uses: actions/upload-artifact@v4.4.0
uses: actions/upload-artifact@v4.6.1
with:
name: godot-tools
path: godot-tools.vsix

2
.gitignore vendored
View File

@@ -4,3 +4,5 @@ node_modules
.vscode-test
workspace.code-workspace
.history
.godot
*.tmp

View File

@@ -2,8 +2,10 @@ 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'],
workspaceFolder: './test_projects/test-dap-project-godot4',
}
);

5
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"recommendations": [
"ms-vscode.extension-test-runner"
]
}

34
.vscode/launch.json vendored
View File

@@ -16,9 +16,13 @@
"outFiles": [
"${workspaceFolder}/out/**/*.js"
],
"skipFiles": [
"**/extensionHostProcess.js",
"<node_internals>/**/*.js"
],
"preLaunchTask": "npm: watch",
"env": {
"VSCODE_DEBUG_MODE": true
"VSCODE_DEBUG_MODE": "true"
}
},
{
@@ -26,6 +30,28 @@
"type": "extensionHost",
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": [
"--profile=temp",
"--extensionDevelopmentPath=${workspaceFolder}",
"${workspaceFolder}/test_projects/test-dap-project-godot4"
],
"outFiles": [
"${workspaceFolder}/out/**/*.js"
],
"skipFiles": [
"**/extensionHostProcess.js",
"<node_internals>/**/*.js"
],
"preLaunchTask": "npm: watch",
"env": {
"VSCODE_DEBUG_MODE": "true"
}
},
{
"name": "Run Extension with local workspace file",
"type": "extensionHost",
"request": "launch",
"runtimeExecutable": "${execPath}",
"args": [
"--profile=temp",
"--extensionDevelopmentPath=${workspaceFolder}",
@@ -34,9 +60,13 @@
"outFiles": [
"${workspaceFolder}/out/**/*.js"
],
"skipFiles": [
"**/extensionHostProcess.js",
"<node_internals>/**/*.js"
],
"preLaunchTask": "npm: watch",
"env": {
"VSCODE_DEBUG_MODE": true
"VSCODE_DEBUG_MODE": "true"
}
},
]

48
.vscode/test_files.code-snippets vendored Normal file
View File

@@ -0,0 +1,48 @@
{
"# --- IN ---": {
"scope": "gdscript",
"prefix": "#IN",
"body": [
"# --- IN ---"
],
"description": "Snapshot Test #IN block"
},
"# --- OUT ---": {
"scope": "gdscript",
"prefix": "#OUT",
"body": [
"# --- OUT ---"
],
"description": "Snapshot Test #OUT block"
},
"# --- END ---": {
"scope": "gdscript",
"prefix": "#END",
"body": [
"# --- END ---"
],
"description": "Snapshot Test #END block"
},
"# --- CONFIG ---": {
"scope": "gdscript",
"prefix": [
"#CO",
"#CONFIG"
],
"body": [
"# --- CONFIG ---"
],
"description": "Snapshot Test #CONFIG block"
},
"# --- CONFIG ALL ---": {
"scope": "gdscript",
"prefix": [
"#CA",
"#CONFIG ALL"
],
"body": [
"# --- CONFIG ALL ---"
],
"description": "Snapshot Test #CONFIG ALL block"
},
}

View File

@@ -1,5 +1,46 @@
# Changelog
### 2.5.1
- [Fix "Request textDocument/documentSymbol failed" error when opening a GDScript file](https://github.com/godotengine/godot-vscode-plugin/pull/823)
### 2.5.0
- [**Add `print_rich()` support to debug console**](https://github.com/godotengine/godot-vscode-plugin/pull/792)
- [Improve Scene Preview drag-and-drop behavior](https://github.com/godotengine/godot-vscode-plugin/pull/815)
- [Add snippet/placeholder behavior to Scene Preview file drops](https://github.com/godotengine/godot-vscode-plugin/pull/813)
- [Overhaul the DebugAdapter variables in DAP](https://github.com/godotengine/godot-vscode-plugin/pull/793)
- [Fix opening a Godot project in Visual Studio Code before the editor resulting in bad file requests](https://github.com/godotengine/godot-vscode-plugin/pull/816)
- [Fix some GDScript syntax highlighting and formatting issues](https://github.com/godotengine/godot-vscode-plugin/pull/783)
- [Fix attached debugging](https://github.com/godotengine/godot-vscode-plugin/pull/784)
- [Fix multi-packet reponses breaking things when starting or ending in a multi-byte UTF-8 sequence](https://github.com/godotengine/godot-vscode-plugin/pull/797)
### 2.4.0
- [**Implement warnings and errors in debug console**](https://github.com/godotengine/godot-vscode-plugin/pull/749)
- The items are expandable/collapsible, and the links on the right side of the panel work for any file inside the user's project
- [**Improve GDScript formatter**](https://github.com/godotengine/godot-vscode-plugin/pull/746)
- Add new style of formatter snapshot tests
- Add many new test cases
- Fix several issues ([#728](https://github.com/godotengine/godot-vscode-plugin/pull/728), [#624](https://github.com/godotengine/godot-vscode-plugin/pull/624), [#657](https://github.com/godotengine/godot-vscode-plugin/pull/657), [#717](https://github.com/godotengine/godot-vscode-plugin/pull/717), [#734](https://github.com/godotengine/godot-vscode-plugin/pull/734), likely more)
- [**Add debugger support for typed Dictionaries**](https://github.com/godotengine/godot-vscode-plugin/pull/764)
- [Add some useful GDScript snippets for Godot 4](https://github.com/godotengine/godot-vscode-plugin/pull/794)
- [Add setting to enable/disable documentation minimap](https://github.com/godotengine/godot-vscode-plugin/pull/786)
- [Add newline when dropping nodes into editor](https://github.com/godotengine/godot-vscode-plugin/pull/754)
- [Add `@static_unload` annotation and Godot 4.3 Variant types to syntax highlighting](https://github.com/godotengine/godot-vscode-plugin/pull/738)
- [Overhaul LSP client](https://github.com/godotengine/godot-vscode-plugin/pull/752)
- Simplify LSP client internals
- Streamline control flow between Client, IO, and Buffer classes
- Create canonical, obvious place to implement filters on incoming and outgoing LSP messages
- Remove legacy WebSockets-based LSP support
- [Update float syntax rules and formatting to better support complex cases](https://github.com/godotengine/godot-vscode-plugin/pull/756)
- [Implement Godot-in-the-loop test suite and fix debugger errors](https://github.com/godotengine/godot-vscode-plugin/pull/788)
- [Remove OS, GDScript and Object from the list of builtins in syntax highlighting](https://github.com/godotengine/godot-vscode-plugin/pull/739)
- [Fix typed arrays of scripts not being decoded properly](https://github.com/godotengine/godot-vscode-plugin/pull/731)
- [Fix debugger watch window freeze caused by missing responses](https://github.com/godotengine/godot-vscode-plugin/pull/781)
- [Fix the TextMate grammar erroneously tagging enum members and const variables as language constants](https://github.com/godotengine/godot-vscode-plugin/pull/737)
- [Fix VBoxContainer and HBoxContainer documentation not opening](https://github.com/godotengine/godot-vscode-plugin/pull/755)
### 2.3.0
- [Add documentation page scaling feature](https://github.com/godotengine/godot-vscode-plugin/pull/722)

View File

@@ -97,6 +97,12 @@ You can set VS Code as your default script editor for Godot by following these s
* On macOS, this executable is typically located at: `/Applications/Visual Studio Code.app/Contents/MacOS/Electron`
5. Fill **Exec Flags** with `{project} --goto {file}:{line}:{col}`
You can make Godot seamlessly reload VSCode-edited scripts by changing some additional settings. More details about each are available when hovering over the description in the Settings window:
- **Editor Settings > Text Editor > Behavior > Files > Auto Reload Scripts on External Change**
- **Editor Settings > Interface > Editor > Save on Focus Loss**
- **Editor Settings > Interface > Editor > Import Resources When Unfocused**
### VS Code
You can use the following settings to configure Godot Tools:
@@ -198,6 +204,8 @@ see [CONTRIBUTING.md](CONTRIBUTING.md)
- Make sure to open the project in the Godot editor first. If you opened
the editor after opening VS Code, you can click the **Retry** button
in the bottom-right corner in VS Code.
- Reset the LSP Server port to the default values in both Godot's Editor Settings and
in VSCode.
### Why isn't IntelliSense displaying script members?
@@ -205,3 +213,11 @@ see [CONTRIBUTING.md](CONTRIBUTING.md)
infer all variable types.
- To increase the number of results displayed, open the **Editor Settings**,
go to the **Language Server** section then check **Enable Smart Resolve**.
### Can Godot/VSCode load in my script changes automatically instead of showing a confirmation window?
Godot has some Editor Settings that can help you if your workflow involves changing files in both editors:
- **Editor Settings > Text Editor > Behavior > Files > Auto Reload Scripts on External Change**
- **Editor Settings > Interface > Editor > Save on Focus Loss**
- **Editor Settings > Interface > Editor > Import Resources When Unfocused**

View File

@@ -1,4 +1,7 @@
{
"vcs": {
"defaultBranch": "master"
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
@@ -15,7 +18,8 @@
"linter": {
"rules": {
"style": {
"noUselessElse": "off"
"noUselessElse": "off",
"useImportType": "off"
}
}
}

View File

@@ -6,14 +6,12 @@
"\t$3"
]
},
"Print messages to console": {
"prefix": "pr",
"body": [
"print($1)"
]
},
"_ready method of Node": {
"prefix": "ready",
"body": [
@@ -21,7 +19,6 @@
"\t${1:pass}"
]
},
"_init method of Object": {
"prefix": "init",
"body": [
@@ -29,195 +26,260 @@
"\t${1:pass}"
]
},
"_process method of Node": {
"prefix": "process",
"body": [
"func _process(delta):",
"\t${1:pass}"
]
"func _process(delta):",
"\t${1:pass}"
]
},
"_physics_process method of Node": {
"prefix": "physics",
"body": [
"func _physics_process(delta):",
"\t${1:pass}"
]
"func _physics_process(delta):",
"\t${1:pass}"
]
},
"_input method of Node": {
"prefix": "input",
"body": [
"func _input(event):",
"\t${1:pass}"
]
"func _input(event):",
"\t${1:pass}"
]
},
"_input_event method of Node": {
"_input_event method of Node": {
"prefix": "inpute",
"body": [
"func _input_event(event):",
"\t${1:pass}"
]
},
"_unhandled_input method of Node": {
"prefix": "uinput",
"body": [
"func _unhandled_input(event):",
"\t${1:pass}"
]
},
"_draw method of Node": {
"prefix": "draw",
"body": [
"func _draw():",
"\t${1:pass}"
]
},
"_gui_input method of Node": {
"prefix": "guii",
"body": [
"func _gui_input(event):",
"\t${1:pass}"
]
},
"func _input_event(event):",
"\t${1:pass}"
]
},
"_unhandled_input method of Node": {
"prefix": "uinput",
"body": [
"func _unhandled_input(event):",
"\t${1:pass}"
]
},
"_draw method of Node": {
"prefix": "draw",
"body": [
"func _draw():",
"\t${1:pass}"
]
},
"_gui_input method of Node": {
"prefix": "guii",
"body": [
"func _gui_input(event):",
"\t${1:pass}"
]
},
"for loop": {
"prefix": "for",
"body": [
"for $1 in $2:",
"\t${3:pass}"
]
"for $1 in $2:",
"\t${3:pass}"
]
},
"for range loop": {
"for range loop": {
"prefix": "for",
"body": [
"for $1 in range(${2:start}{$3:,end}):",
"\t${4:pass}"
]
"for $1 in range(${2:start}{$3:,end}):",
"\t${4:pass}"
]
},
"if elif else": {
"prefix": "if",
"if elif else": {
"prefix": "if",
"body": [
"if ${1:condition}:",
"\t${3:pass}",
"elif ${2:condition}:",
"\t${4:pass}",
"else:",
"\t${5:pass}"
]
},
"if else": {
"prefix": "if",
"if ${1:condition}:",
"\t${3:pass}",
"elif ${2:condition}:",
"\t${4:pass}",
"else:",
"\t${5:pass}"
]
},
"if else": {
"prefix": "if",
"body": [
"if ${1:condition}:",
"\t${2:pass}",
"else:",
"\t${3:pass}"
]
},
"if": {
"prefix": "if",
"if ${1:condition}:",
"\t${2:pass}",
"else:",
"\t${3:pass}"
]
},
"if": {
"prefix": "if",
"body": [
"if ${1:condition}:",
"\t${2:pass}"
]
},
"while": {
"prefix": "while",
"if ${1:condition}:",
"\t${2:pass}"
]
},
"while": {
"prefix": "while",
"body": [
"while ${1:condition}:",
"\t${2:pass}"
]
},
"function define": {
"prefix": "func",
"while ${1:condition}:",
"\t${2:pass}"
]
},
"function define": {
"prefix": "func",
"body": [
"func ${1:method}(${2:args}):",
"\t${3:pass}"
]
},
"match": {
"prefix": "match",
"body": [
"match ${1:expression}:\n\t${2:pattern}:\n\t\t${3}\n\t_:\n\t\t${0:default}"
]
},
"signal declaration": {
"prefix": "signal",
"func ${1:method}(${2:args}):",
"\t${3:pass}"
]
},
"match": {
"prefix": "match",
"body": [
"signal ${1:signalname}(${2:args})"
]
},
"export variables": {
"prefix": "export",
"match ${1:expression}:\n\t${2:pattern}:\n\t\t${3}\n\t_:\n\t\t${0:default}"
]
},
"signal declaration": {
"prefix": "signal",
"body": [
"export(${1:type}${2:,other_configs}) var ${3:name}${4: = default}${5: setget }"
]
},
"define variables": {
"prefix": "var",
"signal ${1:signalname}(${2:args})"
]
},
"export variables": {
"prefix": "export",
"body": [
"var ${1:name}${2: = default}${3: setget }"
]
},
"define onready variables": {
"prefix": "onready",
"@export(${1:type}${2:,other_configs}) var ${3:name}${4: = default}${5: setget }"
]
},
"define variables": {
"prefix": "var",
"body": [
"onready var ${1:name} = get_node($2)"
]
},
"Is instance of a class or script": {
"prefix": "is",
"body": [
"${1:instance} is ${2:class}"
]
},
"element in array": {
"prefix": "in",
"var ${1:name}${2: = default}${3: setget }"
]
},
"define onready variables": {
"prefix": "onready",
"body": [
"${1:element} in ${$2:array}"
]
},
"GDScript template": {
"prefix": "gdscript",
"onready var ${1:name} = get_node($2)"
]
},
"Is instance of a class or script": {
"prefix": "is",
"body": [
"extends ${1:BaseClass}",
"",
"# class member variables go here, for example:",
"# var a = 2",
"# var b = \"textvar\"",
"",
"func _ready():",
"\t# Called every time the node is added to the scene.",
"\t# Initialization here",
"\tpass",
""
]
},
"pass statement": {
"prefix": "pass",
"body": [
"pass"
]
}
}
"${1:instance} is ${2:class}"
]
},
"element in array": {
"prefix": "in",
"body": [
"${1:element} in ${$2:array}"
]
},
"GDScript template": {
"prefix": "gdscript",
"body": [
"extends ${1:BaseClass}",
"",
"# class member variables go here, for example:",
"# var a = 2",
"# var b = \"textvar\"",
"",
"func _ready():",
"\t# Called every time the node is added to the scene.",
"\t# Initialization here",
"\tpass",
""
]
},
"pass statement": {
"prefix": "pass",
"body": [
"pass"
]
},
"GDScript Void": {
"prefix": [
"void"
],
"body": [
"func ${1:function_name}($2) -> void:",
"\t${3:pass}"
],
"description": "Void function"
},
"GDScript Load Resource": {
"prefix": [
"loadres",
"ld"
],
"body": [
"load(\"res://${1:resource_path}\")$0"
],
"description": "Quickly load a resource with the 'res://' prefix"
},
"GDScript Preload Resource": {
"prefix": [
"preloadres",
"pl"
],
"body": [
"preload(\"res://${1:resource_path}\")$0"
],
"description": "Quickly preload a resource with the 'res://' prefix"
},
"GDScript Variable with Getter and Setter": {
"prefix": [
"gs",
"vargetset"
],
"body": [
"var ${1:variable_name}:",
"\tget:",
"\t\treturn ${1:variable_name}",
"\tset(value):",
"\t\t${1:variable_name} = value"
],
"description": "Creates a variable with getter and setter functions in GDScript"
},
"GDScript Variable with Getter and Setter (typed)": {
"prefix": [
"gst",
"vargetsettyped"
],
"body": [
"var ${1:variable_name}: ${2:String}:",
"\tget:",
"\t\treturn ${1:variable_name}",
"\tset(value):",
"\t\t${1:variable_name} = value"
],
"description": "Creates a typed variable with getter and setter functions in GDScript"
},
"GDScript export var": {
"prefix": [
"exportvar",
"xp"
],
"body": [
"export var ${1:variable_name}: ${2:String} = ${3:default_value}"
],
"description": "Creates an exported (typed) variable in GDScript"
},
"GDScript tween": {
"prefix": [
"tween",
"tw"
],
"body": [
"var tween := create_tween()"
],
"description": "Creates a tween object"
},
"GDScript wait": {
"prefix": [
"wait",
"timer"
],
"body": [
"await get_tree().create_timer($1).timeout"
],
"description": "Waits for a given amount of seconds"
}
}

View File

@@ -1,7 +1,3 @@
body {
margin-right: 200px;
}
a {
text-decoration: none;
}

1533
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@
"name": "godot-tools",
"displayName": "godot-tools",
"icon": "icon.png",
"version": "2.3.0",
"version": "2.5.1",
"description": "Tools for game development with Godot Engine and GDScript",
"repository": {
"type": "git",
@@ -15,7 +15,7 @@
"author": "The Godot Engine community",
"publisher": "geequlim",
"engines": {
"vscode": "^1.80.0"
"vscode": "^1.96.0"
},
"categories": [
"Programming Languages",
@@ -31,6 +31,7 @@
],
"main": "./out/extension.js",
"scripts": {
"format": "biome format --write --changed src",
"compile": "tsc -p ./",
"lint": "eslint ./src --quiet",
"watch": "tsc -watch -p ./",
@@ -60,6 +61,11 @@
"command": "godotTools.openEditor",
"title": "Open workspace with Godot editor"
},
{
"category": "Godot Tools",
"command": "godotTools.openEditorSettings",
"title": "Open EditorSettings File"
},
{
"category": "Godot Tools",
"command": "godotTools.startLanguageServer",
@@ -258,6 +264,11 @@
"maximum": 200,
"description": "Scale factor (%) to apply to the Godot documentation viewer."
},
"godotTools.documentation.displayMinimap": {
"type": "boolean",
"default": true,
"description": "Whether to display the minimap for the Godot documentation viewer."
},
"godotTools.editorPath.godot3": {
"type": "string",
"default": "godot3",
@@ -296,21 +307,6 @@
"default": false,
"description": "Whether extra space should be removed from function parameter lists"
},
"godotTools.lsp.serverProtocol": {
"type": [
"string"
],
"enum": [
"ws",
"tcp"
],
"default": "tcp",
"enumDescriptions": [
"Use the WebSocket protocol to connect to Godot 3.2 and Godot 3.2.1",
"Use the TCP protocol to connect to Godot 3.2.2 and newer versions"
],
"description": "The server protocol of the GDScript language server.\nYou must restart VSCode after changing this value."
},
"godotTools.lsp.serverHost": {
"type": "string",
"default": "127.0.0.1",
@@ -880,38 +876,45 @@
}
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@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.80.0",
"@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",
"esbuild": "^0.17.15",
"chai": "^4.5.0",
"chai-as-promised": "^8.0.1",
"chai-subset": "^1.6.0",
"esbuild": "^0.25.0",
"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",
"typescript": "^5.2.2"
},
"dependencies": {
"@vscode/debugadapter": "^1.64.0",
"@vscode/debugprotocol": "^1.64.0",
"@vscode/debugadapter": "^1.68.0",
"@vscode/debugprotocol": "^1.68.0",
"await-notify": "^1.0.1",
"bbcode-to-ansi": "^1.0.0",
"global": "^4.4.0",
"marked": "^4.0.11",
"net": "^1.0.2",
"prismjs": "^1.17.1",
"terminate": "^2.5.0",
"vscode-languageclient": "^7.0.0",
"vscode-languageclient": "^9.0.1",
"vscode-oniguruma": "^2.0.1",
"vscode-textmate": "^9.0.0",
"ws": "^8.17.1",

View File

@@ -1,6 +1,7 @@
import { SceneTreeProvider } from "./scene_tree_provider";
import path = require("path");
import * as path from "node:path";
import { createLogger } from "../utils";
import { SceneTreeProvider } from "./scene_tree_provider";
const log = createLogger("debugger.runtime");
@@ -24,9 +25,9 @@ export class GodotStackVars {
public locals: GodotVariable[] = [],
public members: GodotVariable[] = [],
public globals: GodotVariable[] = [],
) { }
) {}
public reset(count: number = 0) {
public reset(count = 0) {
this.locals = [];
this.members = [];
this.globals = [];
@@ -45,7 +46,7 @@ export interface GodotVariable {
scope_path?: string;
sub_values?: GodotVariable[];
value: any;
type?: bigint;
type?: number;
id?: bigint;
}
@@ -62,7 +63,7 @@ export class RawObject extends Map<any, any> {
}
export class ObjectId implements GDObject {
constructor(public id: bigint) { }
constructor(public id: bigint) {}
public stringify_value(): string {
return `<${this.id}>`;
@@ -85,7 +86,7 @@ export class GodotDebugData {
public last_frames: GodotStackFrame[] = [];
public projectPath: string;
public scene_tree?: SceneTreeProvider;
public stack_count: number = 0;
public stack_count = 0;
public stack_files: string[] = [];
public session;
@@ -126,19 +127,16 @@ export class GodotDebugData {
bps.splice(index, 1);
this.breakpoints.set(pathTo, bps);
const file = `res://${path.relative(this.projectPath, bp.file)}`;
this.session?.controller.remove_breakpoint(
file.replace(/\\/g, "/"),
bp.line,
);
this.session?.controller.remove_breakpoint(file.replace(/\\/g, "/"), bp.line);
}
}
}
public get_all_breakpoints(): GodotBreakpoint[] {
const output: GodotBreakpoint[] = [];
Array.from(this.breakpoints.values()).forEach((bp_array) => {
for (const bp_array of Array.from(this.breakpoints.values())) {
output.push(...bp_array);
});
}
return output;
}
@@ -150,14 +148,14 @@ export class GodotDebugData {
const breakpoints = this.get_all_breakpoints();
let output = "";
if (breakpoints.length > 0) {
output += " --breakpoints \"";
output += ' --breakpoints "';
breakpoints.forEach((bp, i) => {
output += `${this.get_breakpoint_path(bp.file)}:${bp.line}`;
if (i < breakpoints.length - 1) {
output += ",";
}
});
output += "\"";
output += '"';
}
return output;
}

View File

@@ -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" });
@@ -106,7 +109,7 @@ export class GodotDebugger implements DebugAdapterDescriptorFactory, DebugConfig
log.info(`Project version identified as ${projectVersion}`);
if (projectVersion.startsWith("4")) {
this.session = new Godot4DebugSession();
this.session = new Godot4DebugSession(projectVersion);
} else {
this.session = new Godot3DebugSession();
}
@@ -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 = parseFloat(value);
if (isNaN(new_parsed_value)) {
return;
}
} else {
new_parsed_value = parseInt(value);
if (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");
}
}

View File

@@ -1,26 +1,32 @@
import * as fs from "fs";
import {
LoggingDebugSession,
InitializedEvent,
Thread,
Source,
Breakpoint,
StoppedEvent,
InitializedEvent,
LoggingDebugSession,
Source,
TerminatedEvent,
Thread,
} from "@vscode/debugadapter";
import { DebugProtocol } from "@vscode/debugprotocol";
import { debug } from "vscode";
import { Subject } from "await-notify";
import { GodotDebugData, GodotVariable, GodotStackVars } from "../debug_runtime";
import { LaunchRequestArguments, AttachRequestArguments } from "../debugger";
import { SceneTreeProvider } from "../scene_tree_provider";
import { ObjectId } from "./variables/variants";
import { parse_variable, is_variable_built_in_type } from "./helpers";
import { ServerController } from "./server_controller";
import * as fs from "node:fs";
import { debug } from "vscode";
import { createLogger } from "../../utils";
import { GodotDebugData, GodotStackVars, GodotVariable } 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 } from "./variables/variants";
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);
@@ -32,10 +38,7 @@ export class GodotDebugSession extends LoggingDebugSession {
private previous_inspections: bigint[] = [];
private configuration_done: Subject = new Subject();
private mode: "launch" | "attach" | "" = "";
public inspect_callbacks: Map<
bigint,
(class_name: string, variable: GodotVariable) => void
> = new Map();
public inspect_callbacks: Map<bigint, (class_name: string, variable: GodotVariable) => void> = new Map();
public constructor() {
super();
@@ -50,7 +53,7 @@ export class GodotDebugSession extends LoggingDebugSession {
protected initializeRequest(
response: DebugProtocol.InitializeResponse,
args: DebugProtocol.InitializeRequestArguments
args: DebugProtocol.InitializeRequestArguments,
) {
response.body = response.body || {};
@@ -79,10 +82,7 @@ export class GodotDebugSession extends LoggingDebugSession {
this.sendEvent(new InitializedEvent());
}
protected async launchRequest(
response: DebugProtocol.LaunchResponse,
args: LaunchRequestArguments
) {
protected async launchRequest(response: DebugProtocol.LaunchResponse, args: LaunchRequestArguments) {
await this.configuration_done.wait(1000);
this.mode = "launch";
@@ -94,10 +94,7 @@ export class GodotDebugSession extends LoggingDebugSession {
this.sendResponse(response);
}
protected async attachRequest(
response: DebugProtocol.AttachResponse,
args: AttachRequestArguments
) {
protected async attachRequest(response: DebugProtocol.AttachResponse, args: AttachRequestArguments) {
await this.configuration_done.wait(1000);
this.mode = "attach";
@@ -110,16 +107,13 @@ export class GodotDebugSession extends LoggingDebugSession {
public configurationDoneRequest(
response: DebugProtocol.ConfigurationDoneResponse,
args: DebugProtocol.ConfigurationDoneArguments
args: DebugProtocol.ConfigurationDoneArguments,
) {
this.configuration_done.notify();
this.sendResponse(response);
}
protected continueRequest(
response: DebugProtocol.ContinueResponse,
args: DebugProtocol.ContinueArguments
) {
protected continueRequest(response: DebugProtocol.ContinueResponse, args: DebugProtocol.ContinueArguments) {
if (!this.exception) {
response.body = { allThreadsContinued: true };
this.controller.continue();
@@ -127,24 +121,20 @@ export class GodotDebugSession extends LoggingDebugSession {
}
}
protected async evaluateRequest(
response: DebugProtocol.EvaluateResponse,
args: DebugProtocol.EvaluateArguments
) {
protected async evaluateRequest(response: DebugProtocol.EvaluateResponse, args: DebugProtocol.EvaluateArguments) {
await debug.activeDebugSession.customRequest("scopes", { frameId: 0 });
if (this.all_scopes) {
var variable = this.get_variable(args.expression, null, null, null);
if (variable.error == null) {
var parsed_variable = parse_variable(variable.variable);
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
variablesReference: !is_variable_built_in_type(variable.variable) ? variable.index : 0,
};
} else {
} catch (error) {
response.success = false;
response.message = variable.error;
response.message = error.toString();
}
}
@@ -158,30 +148,21 @@ export class GodotDebugSession extends LoggingDebugSession {
this.sendResponse(response);
}
protected nextRequest(
response: DebugProtocol.NextResponse,
args: DebugProtocol.NextArguments
) {
protected nextRequest(response: DebugProtocol.NextResponse, args: DebugProtocol.NextArguments) {
if (!this.exception) {
this.controller.next();
this.sendResponse(response);
}
}
protected pauseRequest(
response: DebugProtocol.PauseResponse,
args: DebugProtocol.PauseArguments
) {
protected pauseRequest(response: DebugProtocol.PauseResponse, args: DebugProtocol.PauseArguments) {
if (!this.exception) {
this.controller.break();
this.sendResponse(response);
}
}
protected async scopesRequest(
response: DebugProtocol.ScopesResponse,
args: DebugProtocol.ScopesArguments
) {
protected async scopesRequest(response: DebugProtocol.ScopesResponse, args: DebugProtocol.ScopesArguments) {
this.controller.request_stack_frame_vars(args.frameId);
await this.got_scope.wait(2000);
@@ -197,7 +178,7 @@ export class GodotDebugSession extends LoggingDebugSession {
protected setBreakPointsRequest(
response: DebugProtocol.SetBreakpointsResponse,
args: DebugProtocol.SetBreakpointsArguments
args: DebugProtocol.SetBreakpointsArguments,
) {
const path = (args.source.path as string).replace(/\\/g, "/");
const client_lines = args.lines || [];
@@ -206,19 +187,19 @@ export class GodotDebugSession extends LoggingDebugSession {
let bps = this.debug_data.get_breakpoints(path);
const bp_lines = bps.map((bp) => bp.line);
bps.forEach((bp) => {
for (const bp of bps) {
if (client_lines.indexOf(bp.line) === -1) {
this.debug_data.remove_breakpoint(path, bp.line);
}
});
client_lines.forEach((l) => {
}
for (const l of client_lines) {
if (bp_lines.indexOf(l) === -1) {
const bp = args.breakpoints.find((bp_at_line) => (bp_at_line.line == l));
const bp = args.breakpoints.find((bp_at_line) => bp_at_line.line === l);
if (!bp.condition) {
this.debug_data.set_breakpoint(path, l);
}
}
});
}
bps = this.debug_data.get_breakpoints(path);
// Sort to ensure breakpoints aren't out-of-order, which would confuse VS Code.
@@ -226,12 +207,7 @@ export class GodotDebugSession extends LoggingDebugSession {
response.body = {
breakpoints: bps.map((bp) => {
return new Breakpoint(
true,
bp.line,
1,
new Source(bp.file.split("/").reverse()[0], bp.file)
);
return new Breakpoint(true, bp.line, 1, new Source(bp.file.split("/").reverse()[0], bp.file));
}),
};
@@ -239,10 +215,7 @@ export class GodotDebugSession extends LoggingDebugSession {
}
}
protected stackTraceRequest(
response: DebugProtocol.StackTraceResponse,
args: DebugProtocol.StackTraceArguments
) {
protected stackTraceRequest(response: DebugProtocol.StackTraceResponse, args: DebugProtocol.StackTraceArguments) {
if (this.debug_data.last_frame) {
response.body = {
totalFrames: this.debug_data.last_frames.length,
@@ -252,10 +225,7 @@ export class GodotDebugSession extends LoggingDebugSession {
name: sf.function,
line: sf.line,
column: 1,
source: new Source(
sf.file,
`${this.debug_data.projectPath}/${sf.file.replace("res://", "")}`
),
source: new Source(sf.file, `${this.debug_data.projectPath}/${sf.file.replace("res://", "")}`),
};
}),
};
@@ -263,30 +233,21 @@ export class GodotDebugSession extends LoggingDebugSession {
this.sendResponse(response);
}
protected stepInRequest(
response: DebugProtocol.StepInResponse,
args: DebugProtocol.StepInArguments
) {
protected stepInRequest(response: DebugProtocol.StepInResponse, args: DebugProtocol.StepInArguments) {
if (!this.exception) {
this.controller.step();
this.sendResponse(response);
}
}
protected stepOutRequest(
response: DebugProtocol.StepOutResponse,
args: DebugProtocol.StepOutArguments
) {
protected stepOutRequest(response: DebugProtocol.StepOutResponse, args: DebugProtocol.StepOutArguments) {
if (!this.exception) {
this.controller.step_out();
this.sendResponse(response);
}
}
protected terminateRequest(
response: DebugProtocol.TerminateResponse,
args: DebugProtocol.TerminateArguments
) {
protected terminateRequest(response: DebugProtocol.TerminateResponse, args: DebugProtocol.TerminateArguments) {
if (this.mode === "launch") {
this.controller.stop();
this.sendEvent(new TerminatedEvent());
@@ -301,11 +262,11 @@ export class GodotDebugSession extends LoggingDebugSession {
protected async variablesRequest(
response: DebugProtocol.VariablesResponse,
args: DebugProtocol.VariablesArguments
args: DebugProtocol.VariablesArguments,
) {
if (!this.all_scopes) {
response.body = {
variables: []
variables: [],
};
this.sendResponse(response);
return;
@@ -319,8 +280,7 @@ export class GodotDebugSession extends LoggingDebugSession {
} 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
(sva) => sva && sva.scope_path === va.scope_path && sva.name === va.name,
);
if (sva) {
return parse_variable(
@@ -329,8 +289,8 @@ export class GodotDebugSession extends LoggingDebugSession {
(va_idx) =>
va_idx &&
va_idx.scope_path === `${reference.scope_path}.${reference.name}` &&
va_idx.name === va.name
)
va_idx.name === va.name,
),
);
}
});
@@ -354,7 +314,7 @@ export class GodotDebugSession extends LoggingDebugSession {
name: "local",
value: undefined,
sub_values: stackVars.locals,
scope_path: "@"
scope_path: "@",
},
{
name: "member",
@@ -370,20 +330,20 @@ export class GodotDebugSession extends LoggingDebugSession {
},
];
stackVars.locals.forEach((va) => {
for (const va of stackVars.locals) {
va.scope_path = "@.local";
this.append_variable(va);
});
}
stackVars.members.forEach((va) => {
for (const va of stackVars.members) {
va.scope_path = "@.member";
this.append_variable(va);
});
}
stackVars.globals.forEach((va) => {
for (const va of stackVars.globals) {
va.scope_path = "@.global";
this.append_variable(va);
});
}
this.add_to_inspections();
@@ -394,21 +354,19 @@ export class GodotDebugSession extends LoggingDebugSession {
}
public set_inspection(id: bigint, replacement: GodotVariable) {
const variables = this.all_scopes.filter(
(va) => va && va.value instanceof ObjectId && va.value.id === id
);
const variables = this.all_scopes.filter((va) => va && va.value instanceof ObjectId && va.value.id === id);
variables.forEach((va) => {
for (const va of variables) {
const index = this.all_scopes.findIndex((va_id) => va_id === va);
const old = this.all_scopes.splice(index, 1);
replacement.name = old[0].name;
replacement.scope_path = old[0].scope_path;
this.append_variable(replacement, index);
});
}
this.ongoing_inspections.splice(
this.ongoing_inspections.findIndex((va_id) => va_id === id),
1
1,
);
this.previous_inspections.push(id);
@@ -422,7 +380,7 @@ export class GodotDebugSession extends LoggingDebugSession {
}
private add_to_inspections() {
this.all_scopes.forEach((va) => {
for (const va of this.all_scopes) {
if (va && va.value instanceof ObjectId) {
if (
!this.ongoing_inspections.includes(va.value.id) &&
@@ -432,38 +390,57 @@ export class GodotDebugSession extends LoggingDebugSession {
this.ongoing_inspections.push(va.value.id);
}
}
});
}
}
protected get_variable(expression: string, root: GodotVariable = null, index: number = 0, object_id: number = null): { variable: GodotVariable, index: number, object_id: number, error: string } {
var result: { variable: GodotVariable, index: number, object_id: number, error: string } = { variable: null, index: null, object_id: null, error: null };
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;
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;
}
var items = expression.split(".");
var propertyName = items[index + 1];
var path = items.slice(0, index + 1).join(".")
.split("self.").join("")
.split("self").join("")
.split("[").join(".")
.split("]").join("");
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") {
if (items.length === 1 && items[0] === "self") {
propertyName = "self";
}
// Detect index/key
var key = (propertyName.match(/(?<=\[).*(?=\])/) || [null])[0];
let key = (propertyName.match(/(?<=\[).*(?=\])/) || [null])[0];
if (key) {
key = key.replace(/['"]+/g, "");
propertyName = propertyName.split(/(?<=\[).*(?=\])/).join("").split("\[\]").join("");
propertyName = propertyName
.split(/(?<=\[).*(?=\])/)
.join("")
.split("[]")
.join("");
if (path) path += ".";
path += propertyName;
propertyName = key;
@@ -474,49 +451,60 @@ export class GodotDebugSession extends LoggingDebugSession {
}
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("");
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("");
}
var sanitized_all_scopes = this.all_scopes.filter(x => x).map(function (x) {
return {
const sanitized_all_scopes = this.all_scopes
.filter((x) => x)
.map((x) => ({
sanitized: {
name: sanitizeName(x.name),
scope_path: sanitizeScopePath(x.scope_path)
scope_path: sanitizeScopePath(x.scope_path),
},
real: x
};
});
real: x,
}));
result.variable = sanitized_all_scopes
.find(x => x.sanitized.name == propertyName && x.sanitized.scope_path == path)
?.real;
result.variable = sanitized_all_scopes.find(
(x) => x.sanitized.name === propertyName && x.sanitized.scope_path === path,
)?.real;
if (!result.variable) {
result.error = `Could not find: ${propertyName}`;
return result;
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;
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) {
var collection = path.split(".")[path.split(".").length - 1];
var 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;
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 {
result.object_id = Array.from(root.value.entries())
.find(x => x && x[0].split("Members/").join("").split("Locals/").join("") == propertyName)[1].id;
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;
}
}
@@ -524,7 +512,9 @@ export class GodotDebugSession extends LoggingDebugSession {
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);
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);

View File

@@ -1,20 +1,34 @@
import * as fs from "fs";
import net = require("net");
import { debug, window } from "vscode";
import { StoppedEvent, TerminatedEvent } from "@vscode/debugadapter";
import { VariantEncoder } from "./variables/variant_encoder";
import { VariantDecoder } from "./variables/variant_decoder";
import { RawObject } from "./variables/variants";
import { GodotStackFrame, GodotStackVars } from "../debug_runtime";
import { GodotDebugSession } from "./debug_session";
import { parse_next_scene_node, split_buffers, build_sub_values } from "./helpers";
import { get_configuration, get_free_port, createLogger, verify_godot_version, get_project_version } from "../../utils";
import { DebugProtocol } from "@vscode/debugprotocol";
import * as fs from "node:fs";
import * as net from "node:net";
import { debug, window } from "vscode";
import {
ansi,
convert_resource_path_to_uri,
createLogger,
get_configuration,
get_free_port,
get_project_version,
verify_godot_version,
VERIFY_RESULT,
} from "../../utils";
import { prompt_for_godot_executable } from "../../utils/prompts";
import { subProcess, killSubProcesses } from "../../utils/subspawn";
import { LaunchRequestArguments, AttachRequestArguments, pinnedScene } from "../debugger";
import { killSubProcesses, subProcess } from "../../utils/subspawn";
import { GodotStackFrame, GodotStackVars } from "../debug_runtime";
import { AttachRequestArguments, LaunchRequestArguments, pinnedScene } from "../debugger";
import { GodotDebugSession } from "./debug_session";
import { build_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 BBCodeToAnsi from 'bbcode-to-ansi';
const log = createLogger("debugger.controller", { output: "Godot Debugger" });
const socketLog = createLogger("debugger.socket");
//initialize bbcodeParser and set default output color to grey
const bbcodeParser = new BBCodeToAnsi("\u001b[38;2;211;211;211m");
class Command {
public command: string = "";
@@ -37,9 +51,7 @@ export class ServerController {
private didFirstOutput: boolean = false;
private connectedVersion = "";
public constructor(
public session: GodotDebugSession
) { }
public constructor(public session: GodotDebugSession) {}
public break() {
this.send_command("break");
@@ -87,12 +99,8 @@ export class ServerController {
this.send_command("get_stack_frame_vars", [frame_id]);
}
public set_object_property(objectId: bigint, label: string, newParsedValue: any) {
this.send_command("set_object_property", [
objectId,
label,
newParsedValue,
]);
public set_object_property(objectId: bigint, label: string, newParsedValue) {
this.send_command("set_object_property", [objectId, label, newParsedValue]);
}
public set_exception(exception: string) {
@@ -103,7 +111,7 @@ export class ServerController {
log.info("Starting game process");
let godotPath: string;
let result;
let result: VERIFY_RESULT;
if (args.editor_path) {
log.info("Using 'editor_path' variable from launch.json");
@@ -168,12 +176,12 @@ export class ServerController {
const address = args.address.replace("tcp://", "");
command += ` --remote-debug "${address}:${args.port}"`;
if (args.profiling) { command += " --profiling"; }
if (args.debug_collisions) { command += " --debug-collisions"; }
if (args.debug_paths) { command += " --debug-paths"; }
if (args.frame_delay) { command += ` --frame-delay ${args.frame_delay}`; }
if (args.time_scale) { command += ` --time-scale ${args.time_scale}`; }
if (args.fixed_fps) { command += ` --fixed-fps ${args.fixed_fps}`; }
if (args.profiling) command += " --profiling";
if (args.debug_collisions) command += " --debug-collisions";
if (args.debug_paths) command += " --debug-paths";
if (args.frame_delay) command += ` --frame-delay ${args.frame_delay}`;
if (args.time_scale) command += ` --time-scale ${args.time_scale}`;
if (args.fixed_fps) command += ` --fixed-fps ${args.fixed_fps}`;
if (args.scene && args.scene !== "main") {
log.info(`Custom scene argument provided: ${args.scene}`);
@@ -219,15 +227,15 @@ export class ServerController {
command += this.session.debug_data.get_breakpoint_string();
if (args.additional_options) {
command += " " + args.additional_options;
command += ` ${args.additional_options}`;
}
log.info(`Launching game process using command: '${command}'`);
const debugProcess = subProcess("debug", command, { shell: true, detached: true });
debugProcess.stdout.on("data", (data) => { });
debugProcess.stderr.on("data", (data) => { });
debugProcess.on("close", (code) => { });
debugProcess.stdout.on("data", (data) => {});
debugProcess.stderr.on("data", (data) => {});
debugProcess.on("close", (code) => {});
}
private stash: Buffer;
@@ -351,7 +359,7 @@ export class ServerController {
}
}
private handle_command(command: Command) {
private async handle_command(command: Command) {
switch (command.command) {
case "debug_enter": {
const reason: string = command.parameters[1];
@@ -379,7 +387,7 @@ export class ServerController {
case "message:inspect_object": {
let id = BigInt(command.parameters[0]);
const className: string = command.parameters[1];
const properties: any[] = command.parameters[2];
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.
@@ -388,16 +396,13 @@ export class ServerController {
}
const rawObject = new RawObject(className);
properties.forEach((prop) => {
for (const prop of properties) {
rawObject.set(prop[0], prop[5]);
});
}
const inspectedVariable = { name: "", value: rawObject };
build_sub_values(inspectedVariable);
if (this.session.inspect_callbacks.has(BigInt(id))) {
this.session.inspect_callbacks.get(BigInt(id))(
inspectedVariable.name,
inspectedVariable
);
this.session.inspect_callbacks.get(BigInt(id))(inspectedVariable.name, inspectedVariable);
this.session.inspect_callbacks.delete(BigInt(id));
}
this.session.set_inspection(id, inspectedVariable);
@@ -425,15 +430,99 @@ export class ServerController {
this.didFirstOutput = true;
// this.request_scene_tree();
}
command.parameters.forEach((line) => {
debug.activeDebugConsole.appendLine(line[0]);
});
for (const output of command.parameters){
output[0].split("\n").forEach(line => debug.activeDebugConsole.appendLine(bbcodeParser.parse(line)));
}
break;
}
case "error": {
if (!this.didFirstOutput) {
this.didFirstOutput = true;
}
this.handle_error(command);
break;
}
}
}
async handle_error(command: Command) {
const params = command.parameters[0];
const e = {
hr: params[0],
min: params[1],
sec: params[2],
msec: params[3],
func: params[4] as string,
file: params[5] as string,
line: params[6],
cond: params[7] as string,
msg: params[8] as string,
warning: params[9] as boolean,
stack: [],
};
const stackCount = command.parameters[1];
for (let i = 0; i < stackCount; i += 3) {
const file = command.parameters[i + 2];
const func = command.parameters[i + 3];
const line = command.parameters[i + 4];
const msg = `${file}:${line} @ ${func}()`;
const extras = {
source: { name: (await convert_resource_path_to_uri(file)).toString() },
line: line,
};
e.stack.push({ msg: msg, extras: extras });
}
const time = `${e.hr}:${e.min}:${e.sec}.${e.msec}`;
const location = `${e.file}:${e.line} @ ${e.func}()`;
const color = e.warning ? "yellow" : "red";
const lang = e.file.startsWith("res://") ? "GDScript" : "C++";
const extras = {
source: { name: (await convert_resource_path_to_uri(e.file)).toString() },
line: e.line,
group: "startCollapsed",
};
if (e.msg) {
this.stderr(`${ansi[color]}${time} | ${e.func}: ${e.msg}`, extras);
this.stderr(`${ansi.dim.white}<${lang} Error> ${ansi.white}${e.cond}`);
} else {
this.stderr(`${ansi[color]}${time} | ${e.func}: ${e.cond}`, extras);
}
this.stderr(`${ansi.dim.white}<${lang} Source> ${ansi.white}${location}`);
if (stackCount !== 0) {
this.stderr(`${ansi.dim.white}<Stack Trace>`, { group: "start" });
for (const frame of e.stack) {
this.stderr(`${ansi.white}${frame.msg}`, frame.extras);
}
this.stderr("", { group: "end" });
}
this.stderr("", { group: "end" });
}
stdout(output = "", extra = {}) {
this.session.sendEvent({
event: "output",
body: {
category: "stdout",
output: output + ansi.reset,
...extra,
},
} as DebugProtocol.OutputEvent);
}
stderr(output = "", extra = {}) {
this.session.sendEvent({
event: "output",
body: {
category: "stderr",
output: output + ansi.reset,
...extra,
},
} as DebugProtocol.OutputEvent);
}
public abort() {
log.info("Aborting debug controller");
this.session.sendEvent(new TerminatedEvent());
@@ -468,19 +557,14 @@ export class ServerController {
const line = stackFrames[0].line;
if (this.steppingOut) {
const breakpoint = this.session.debug_data
.get_breakpoints(file)
.find((bp) => bp.line === line);
const breakpoint = this.session.debug_data.get_breakpoints(file).find((bp) => bp.line === line);
if (!breakpoint) {
if (this.session.debug_data.stack_count > 1) {
continueStepping = this.session.debug_data.stack_count === stackCount;
} else {
const fileSame =
stackFrames[0].file === this.session.debug_data.last_frame.file;
const funcSame =
stackFrames[0].function === this.session.debug_data.last_frame.function;
const lineGreater =
stackFrames[0].line >= this.session.debug_data.last_frame.line;
const fileSame = stackFrames[0].file === this.session.debug_data.last_frame.file;
const funcSame = stackFrames[0].function === this.session.debug_data.last_frame.function;
const lineGreater = stackFrames[0].line >= this.session.debug_data.last_frame.line;
continueStepping = fileSame && funcSame && lineGreater;
}
@@ -506,9 +590,7 @@ export class ServerController {
this.session.sendEvent(new StoppedEvent("breakpoint", 0));
} else {
this.session.set_exception(true);
this.session.sendEvent(
new StoppedEvent("exception", 0, this.exception)
);
this.session.sendEvent(new StoppedEvent("exception", 0, this.exception));
}
}
@@ -535,8 +617,8 @@ export class ServerController {
const stackVars = new GodotStackVars();
let localsRemaining = parameters[0];
let membersRemaining = parameters[1 + (localsRemaining * 2)];
let globalsRemaining = parameters[2 + ((localsRemaining + membersRemaining) * 2)];
let membersRemaining = parameters[1 + localsRemaining * 2];
let globalsRemaining = parameters[2 + (localsRemaining + membersRemaining) * 2];
let i = 1;
while (localsRemaining--) {
@@ -551,7 +633,7 @@ export class ServerController {
stackVars.globals.push({ name: parameters[i++], value: parameters[i++] });
}
stackVars.forEach(item => build_sub_values(item));
stackVars.forEach((item) => build_sub_values(item));
this.session.set_scopes(stackVars);
}

View File

@@ -1,47 +1,41 @@
import * as fs from "fs";
import {
LoggingDebugSession,
InitializedEvent,
Thread,
Source,
Breakpoint,
StoppedEvent,
InitializedEvent,
LoggingDebugSession,
Source,
TerminatedEvent,
Thread,
} from "@vscode/debugadapter";
import { DebugProtocol } from "@vscode/debugprotocol";
import { debug } from "vscode";
import { Subject } from "await-notify";
import { GodotDebugData, GodotVariable, GodotStackVars } from "../debug_runtime";
import { LaunchRequestArguments, AttachRequestArguments } from "../debugger";
import { SceneTreeProvider } from "../scene_tree_provider";
import { ObjectId } from "./variables/variants";
import { parse_variable, is_variable_built_in_type } from "./helpers";
import { ServerController } from "./server_controller";
import * as fs from "node:fs";
import { createLogger } from "../../utils";
import { GodotDebugData } from "../debug_runtime";
import { AttachRequestArguments, LaunchRequestArguments } from "../debugger";
import { SceneTreeProvider } from "../scene_tree_provider";
import { ServerController } from "./server_controller";
import { VariablesManager } from "./variables/variables_manager";
const log = createLogger("debugger.session", { output: "Godot Debugger" });
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<
bigint,
(class_name: string, variable: GodotVariable) => void
> = new Map();
public constructor() {
public variables_manager: VariablesManager;
public constructor(projectVersion: string) {
super();
this.setDebuggerLinesStartAt1(false);
this.setDebuggerColumnsStartAt1(false);
this.controller.setProjectVersion(projectVersion);
}
public dispose() {
@@ -50,8 +44,9 @@ export class GodotDebugSession extends LoggingDebugSession {
protected initializeRequest(
response: DebugProtocol.InitializeResponse,
args: DebugProtocol.InitializeRequestArguments
args: DebugProtocol.InitializeRequestArguments,
) {
log.info("initializeRequest", args);
response.body = response.body || {};
response.body.supportsConfigurationDoneRequest = true;
@@ -79,10 +74,8 @@ export class GodotDebugSession extends LoggingDebugSession {
this.sendEvent(new InitializedEvent());
}
protected async launchRequest(
response: DebugProtocol.LaunchResponse,
args: LaunchRequestArguments
) {
protected async launchRequest(response: DebugProtocol.LaunchResponse, args: LaunchRequestArguments) {
log.info("launchRequest", args);
await this.configuration_done.wait(1000);
this.mode = "launch";
@@ -94,14 +87,13 @@ export class GodotDebugSession extends LoggingDebugSession {
this.sendResponse(response);
}
protected async attachRequest(
response: DebugProtocol.AttachResponse,
args: AttachRequestArguments
) {
protected async attachRequest(response: DebugProtocol.AttachResponse, args: AttachRequestArguments) {
log.info("attachRequest", args);
await this.configuration_done.wait(1000);
this.mode = "attach";
this.debug_data.projectPath = args.project;
this.exception = false;
await this.controller.attach(args);
@@ -110,16 +102,15 @@ export class GodotDebugSession extends LoggingDebugSession {
public configurationDoneRequest(
response: DebugProtocol.ConfigurationDoneResponse,
args: DebugProtocol.ConfigurationDoneArguments
args: DebugProtocol.ConfigurationDoneArguments,
) {
log.info("configurationDoneRequest", args);
this.configuration_done.notify();
this.sendResponse(response);
}
protected continueRequest(
response: DebugProtocol.ContinueResponse,
args: DebugProtocol.ContinueArguments
) {
protected continueRequest(response: DebugProtocol.ContinueResponse, args: DebugProtocol.ContinueArguments) {
log.info("continueRequest", args);
if (!this.exception) {
response.body = { allThreadsContinued: true };
this.controller.continue();
@@ -127,78 +118,27 @@ export class GodotDebugSession extends LoggingDebugSession {
}
}
protected async evaluateRequest(
response: DebugProtocol.EvaluateResponse,
args: DebugProtocol.EvaluateArguments
) {
await debug.activeDebugSession.customRequest("scopes", { frameId: 0 });
if (this.all_scopes) {
var variable = this.get_variable(args.expression, null, null, null);
if (variable.error == null) {
var parsed_variable = parse_variable(variable.variable);
response.body = {
result: parsed_variable.value,
variablesReference: !is_variable_built_in_type(variable.variable) ? variable.index : 0
};
} else {
response.success = false;
response.message = variable.error;
}
}
if (!response.body) {
response.body = {
result: "null",
variablesReference: 0,
};
}
this.sendResponse(response);
}
protected nextRequest(
response: DebugProtocol.NextResponse,
args: DebugProtocol.NextArguments
) {
protected nextRequest(response: DebugProtocol.NextResponse, args: DebugProtocol.NextArguments) {
log.info("nextRequest", args);
if (!this.exception) {
this.controller.next();
this.sendResponse(response);
}
}
protected pauseRequest(
response: DebugProtocol.PauseResponse,
args: DebugProtocol.PauseArguments
) {
protected pauseRequest(response: DebugProtocol.PauseResponse, args: DebugProtocol.PauseArguments) {
log.info("pauseRequest", args);
if (!this.exception) {
this.controller.break();
this.sendResponse(response);
}
}
protected async scopesRequest(
response: DebugProtocol.ScopesResponse,
args: DebugProtocol.ScopesArguments
) {
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
args: DebugProtocol.SetBreakpointsArguments,
) {
log.info("setBreakPointsRequest", args);
const path = (args.source.path as string).replace(/\\/g, "/");
const client_lines = args.lines || [];
@@ -206,19 +146,19 @@ export class GodotDebugSession extends LoggingDebugSession {
let bps = this.debug_data.get_breakpoints(path);
const bp_lines = bps.map((bp) => bp.line);
bps.forEach((bp) => {
for (const bp of bps) {
if (client_lines.indexOf(bp.line) === -1) {
this.debug_data.remove_breakpoint(path, bp.line);
}
});
client_lines.forEach((l) => {
}
for (const l of client_lines) {
if (bp_lines.indexOf(l) === -1) {
const bp = args.breakpoints.find((bp_at_line) => (bp_at_line.line == l));
const bp = args.breakpoints.find((bp_at_line) => bp_at_line.line === l);
if (!bp.condition) {
this.debug_data.set_breakpoint(path, l);
}
}
});
}
bps = this.debug_data.get_breakpoints(path);
// Sort to ensure breakpoints aren't out-of-order, which would confuse VS Code.
@@ -226,12 +166,7 @@ export class GodotDebugSession extends LoggingDebugSession {
response.body = {
breakpoints: bps.map((bp) => {
return new Breakpoint(
true,
bp.line,
1,
new Source(bp.file.split("/").reverse()[0], bp.file)
);
return new Breakpoint(true, bp.line, 1, new Source(bp.file.split("/").reverse()[0], bp.file));
}),
};
@@ -239,10 +174,40 @@ export class GodotDebugSession extends LoggingDebugSession {
}
}
protected stackTraceRequest(
response: DebugProtocol.StackTraceResponse,
args: DebugProtocol.StackTraceArguments
) {
protected stepInRequest(response: DebugProtocol.StepInResponse, args: DebugProtocol.StepInArguments) {
log.info("stepInRequest", args);
if (!this.exception) {
this.controller.step();
this.sendResponse(response);
}
}
protected stepOutRequest(response: DebugProtocol.StepOutResponse, args: DebugProtocol.StepOutArguments) {
log.info("stepOutRequest", args);
if (!this.exception) {
this.controller.step_out();
this.sendResponse(response);
}
}
protected terminateRequest(response: DebugProtocol.TerminateResponse, args: DebugProtocol.TerminateArguments) {
log.info("terminateRequest", args);
if (this.mode === "launch") {
this.controller.stop();
this.sendEvent(new TerminatedEvent());
}
this.sendResponse(response);
}
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 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,
@@ -252,300 +217,88 @@ export class GodotDebugSession extends LoggingDebugSession {
name: sf.function,
line: sf.line,
column: 1,
source: new Source(
sf.file,
`${this.debug_data.projectPath}/${sf.file.replace("res://", "")}`
),
source: new Source(sf.file, `${this.debug_data.projectPath}/${sf.file.replace("res://", "")}`),
};
}),
};
}
log.info("stackTraceRequest response", response);
this.sendResponse(response);
}
protected stepInRequest(
response: DebugProtocol.StepInResponse,
args: DebugProtocol.StepInArguments
) {
if (!this.exception) {
this.controller.step();
this.sendResponse(response);
}
}
protected async scopesRequest(response: DebugProtocol.ScopesResponse, args: DebugProtocol.ScopesArguments) {
log.info("scopesRequest", args);
// this.variables_manager.variablesFrameId = args.frameId;
protected stepOutRequest(
response: DebugProtocol.StepOutResponse,
args: DebugProtocol.StepOutArguments
) {
if (!this.exception) {
this.controller.step_out();
this.sendResponse(response);
}
}
// 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 },
];
protected terminateRequest(
response: DebugProtocol.TerminateResponse,
args: DebugProtocol.TerminateArguments
) {
if (this.mode === "launch") {
this.controller.stop();
this.sendEvent(new TerminatedEvent());
}
this.sendResponse(response);
}
response.body = {
scopes: scopes_with_references,
// scopes: [
// { name: "Locals", variablesReference: 1, expensive: false },
// { name: "Members", variablesReference: 2, expensive: false },
// { name: "Globals", variablesReference: 3, expensive: false },
// ],
};
protected threadsRequest(response: DebugProtocol.ThreadsResponse) {
response.body = { threads: [new Thread(0, "thread_1")] };
log.info("scopesRequest response", response);
this.sendResponse(response);
}
protected async variablesRequest(
response: DebugProtocol.VariablesResponse,
args: DebugProtocol.VariablesArguments
args: DebugProtocol.VariablesArguments,
) {
if (!this.all_scopes) {
log.info("variablesRequest", args);
try {
const variables = await this.variables_manager.get_vscode_object(args.variablesReference);
response.body = {
variables: []
variables: variables,
};
this.sendResponse(response);
return;
} catch (error) {
log.error("variablesRequest", error);
response.success = false;
response.message = error.toString();
}
const reference = this.all_scopes[args.variablesReference];
let variables: DebugProtocol.Variable[];
log.info("variablesRequest 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 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,
};
}
response.body = {
variables: variables,
};
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: "@",
},
];
stackVars.locals.forEach((va) => {
va.scope_path = "@.local";
this.append_variable(va);
});
stackVars.members.forEach((va) => {
va.scope_path = "@.member";
this.append_variable(va);
});
stackVars.globals.forEach((va) => {
va.scope_path = "@.global";
this.append_variable(va);
});
this.add_to_inspections();
if (this.ongoing_inspections.length === 0) {
this.previous_inspections = [];
this.got_scope.notify();
}
}
public set_inspection(id: bigint, replacement: GodotVariable) {
const variables = this.all_scopes.filter(
(va) => va && va.value instanceof ObjectId && va.value.id === id
);
variables.forEach((va) => {
const index = this.all_scopes.findIndex((va_id) => va_id === va);
const old = this.all_scopes.splice(index, 1);
replacement.name = old[0].name;
replacement.scope_path = old[0].scope_path;
this.append_variable(replacement, index);
});
this.ongoing_inspections.splice(
this.ongoing_inspections.findIndex((va_id) => va_id === id),
1
);
this.previous_inspections.push(id);
// this.add_to_inspections();
if (this.ongoing_inspections.length === 0) {
this.previous_inspections = [];
this.got_scope.notify();
}
}
private add_to_inspections() {
this.all_scopes.forEach((va) => {
if (va && va.value instanceof ObjectId) {
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: number = 0, object_id: number = null): { variable: GodotVariable, index: number, object_id: number, error: string } {
var result: { variable: GodotVariable, index: number, object_id: number, error: string } = { variable: null, index: null, object_id: null, error: 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;
}
var items = expression.split(".");
var propertyName = items[index + 1];
var 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
var 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("");
}
var sanitized_all_scopes = this.all_scopes.filter(x => x).map(function (x) {
return {
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) {
result.error = `Could not find: ${propertyName}`;
return result;
}
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) {
var collection = path.split(".")[path.split(".").length - 1];
var 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 entries = Array.from(root.value.entries());
const item = 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);
});
}
}
}

View File

@@ -1,5 +1,6 @@
import { GodotVariable, } from "../debug_runtime";
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,88 +32,34 @@ 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 build_sub_values(va: GodotVariable) {
const value = va.value;
export function get_sub_values(value: any): GodotVariable[] {
let subValues: GodotVariable[] = undefined;
if (value && Array.isArray(value)) {
subValues = value.map((va, i) => {
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;
}
});
} else if (value && typeof value["sub_values"] === "function") {
subValues = value.sub_values().map((sva) => {
return { name: sva.name, value: sva.value } as GodotVariable;
});
}
va.sub_values = subValues;
subValues?.forEach(build_sub_values);
}
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 (value) {
if (Array.isArray(value)) {
rendered_value = `Array[${value.length}]`;
array_size = value.length;
array_type = "indexed";
reference = i ? i : 0;
subValues = value.map((va, i) => {
return { name: `${i}`, value: va } as GodotVariable;
});
} 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 {
rendered_value = `${value.type_name()}${value.stringify_value()}`;
reference = i ? i : 0;
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;
});
}
}
return {
name: va.name,
value: rendered_value,
variablesReference: reference,
array_size: array_size > 0 ? array_size : undefined,
filter: array_type,
};
for (let i = 0; i < subValues?.length; i++) {
subValues[i].sub_values = get_sub_values(subValues[i].value);
}
return subValues;
}

View File

@@ -1,20 +1,35 @@
import * as fs from "fs";
import net = require("net");
import { debug, window } from "vscode";
import { StoppedEvent, TerminatedEvent } from "@vscode/debugadapter";
import { VariantEncoder } from "./variables/variant_encoder";
import { VariantDecoder } from "./variables/variant_decoder";
import { RawObject } from "./variables/variants";
import { GodotStackFrame, GodotVariable, GodotStackVars } from "../debug_runtime";
import { GodotDebugSession } from "./debug_session";
import { parse_next_scene_node, split_buffers, build_sub_values } from "./helpers";
import { get_configuration, get_free_port, createLogger, verify_godot_version, get_project_version } from "../../utils";
import { DebugProtocol } from "@vscode/debugprotocol";
import * as fs from "node:fs";
import * as net from "node:net";
import { debug, window } from "vscode";
import {
ansi,
convert_resource_path_to_uri,
createLogger,
get_configuration,
get_free_port,
get_project_version,
verify_godot_version,
VERIFY_RESULT,
} from "../../utils";
import { prompt_for_godot_executable } from "../../utils/prompts";
import { subProcess, killSubProcesses } from "../../utils/subspawn";
import { LaunchRequestArguments, AttachRequestArguments, pinnedScene } from "../debugger";
import { killSubProcesses, subProcess } from "../../utils/subspawn";
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";
import BBCodeToAnsi from "bbcode-to-ansi";
const log = createLogger("debugger.controller", { output: "Godot Debugger" });
const socketLog = createLogger("debugger.socket");
//initialize bbcodeParser and set default output color to grey
const bbcodeParser = new BBCodeToAnsi("\u001b[38;2;211;211;211m");
class Command {
public command: string = "";
@@ -24,6 +39,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();
@@ -34,13 +76,20 @@ export class ServerController {
private server?: net.Server;
private socket?: net.Socket;
private steppingOut = false;
private didFirstOutput: boolean = false;
private partialStackVars = new GodotStackVars();
private connectedVersion = "";
private didFirstOutput = false;
private partialStackVars: GodotPartialStackVars;
private projectVersionMajor: number;
private projectVersionMinor: number;
private projectVersionPoint: number;
public constructor(
public session: GodotDebugSession
) { }
public constructor(public session: GodotDebugSession) {}
public setProjectVersion(projectVersion: string) {
const versionParts = projectVersion.split(".").map(Number);
this.projectVersionMajor = versionParts[0] || 0;
this.projectVersionMinor = versionParts[1] || 0;
this.projectVersionPoint = versionParts[2] || 0;
}
public break() {
this.send_command("break");
@@ -84,16 +133,21 @@ 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: any) {
this.send_command("scene:set_object_property", [
objectId,
label,
newParsedValue,
]);
public set_object_property(objectId: bigint, label: string, newParsedValue) {
this.send_command("scene:set_object_property", [objectId, label, newParsedValue]);
}
public set_exception(exception: string) {
@@ -104,7 +158,7 @@ export class ServerController {
log.info("Starting game process");
let godotPath: string;
let result;
let result: VERIFY_RESULT;
if (args.editor_path) {
log.info("Using 'editor_path' variable from launch.json");
@@ -163,23 +217,23 @@ export class ServerController {
}
}
this.connectedVersion = result.version;
this.setProjectVersion(result.version);
let command = `"${godotPath}" --path "${args.project}"`;
const address = args.address.replace("tcp://", "");
command += ` --remote-debug "tcp://${address}:${args.port}"`;
if (args.profiling) { command += " --profiling"; }
if (args.single_threaded_scene) { command += " --single-threaded-scene"; }
if (args.debug_collisions) { command += " --debug-collisions"; }
if (args.debug_paths) { command += " --debug-paths"; }
if (args.debug_navigation) { command += " --debug-navigation"; }
if (args.debug_avoidance) { command += " --debug-avoidance"; }
if (args.debug_stringnames) { command += " --debug-stringnames"; }
if (args.frame_delay) { command += ` --frame-delay ${args.frame_delay}`; }
if (args.time_scale) { command += ` --time-scale ${args.time_scale}`; }
if (args.disable_vsync) { command += " --disable-vsync"; }
if (args.fixed_fps) { command += ` --fixed-fps ${args.fixed_fps}`; }
if (args.profiling) command += " --profiling";
if (args.single_threaded_scene) command += " --single-threaded-scene";
if (args.debug_collisions) command += " --debug-collisions";
if (args.debug_paths) command += " --debug-paths";
if (args.debug_navigation) command += " --debug-navigation";
if (args.debug_avoidance) command += " --debug-avoidance";
if (args.debug_stringnames) command += " --debug-stringnames";
if (args.frame_delay) command += ` --frame-delay ${args.frame_delay}`;
if (args.time_scale) command += ` --time-scale ${args.time_scale}`;
if (args.disable_vsync) command += " --disable-vsync";
if (args.fixed_fps) command += ` --fixed-fps ${args.fixed_fps}`;
if (args.scene && args.scene !== "main") {
log.info(`Custom scene argument provided: ${args.scene}`);
@@ -225,15 +279,15 @@ export class ServerController {
command += this.session.debug_data.get_breakpoint_string();
if (args.additional_options) {
command += " " + args.additional_options;
command += ` ${args.additional_options}`;
}
log.info(`Launching game process using command: '${command}'`);
const debugProcess = subProcess("debug", command, { shell: true, detached: true });
debugProcess.stdout.on("data", (data) => { });
debugProcess.stderr.on("data", (data) => { });
debugProcess.on("close", (code) => { });
debugProcess.stdout.on("data", (data) => {});
debugProcess.stderr.on("data", (data) => {});
debugProcess.on("close", (code) => {});
}
private stash: Buffer;
@@ -254,7 +308,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);
}
@@ -336,18 +390,18 @@ export class ServerController {
this.server.listen(args.port, args.address);
}
private parse_message(dataset: any[]) {
private parse_message(dataset: []) {
const command = new Command();
let i = 0;
command.command = dataset[i++];
if (this.connectedVersion[2] >= "2") {
if (this.projectVersionMinor >= 2) {
command.threadId = dataset[i++];
}
command.parameters = dataset[i++];
return command;
}
private handle_command(command: Command) {
private async handle_command(command: Command) {
switch (command.command) {
case "debug_enter": {
const reason: string = command.parameters[1];
@@ -357,9 +411,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?
@@ -376,30 +432,34 @@ 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: any[] = command.parameters[2];
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);
properties.forEach((prop) => {
for (const prop of properties) {
rawObject.set(prop[0], prop[5]);
});
const inspectedVariable = { name: "", value: rawObject };
build_sub_values(inspectedVariable);
if (this.session.inspect_callbacks.has(BigInt(id))) {
this.session.inspect_callbacks.get(BigInt(id))(
inspectedVariable.name,
inspectedVariable
);
this.session.inspect_callbacks.delete(BigInt(id));
}
this.session.set_inspection(id, inspectedVariable);
const sub_values = get_sub_values(rawObject);
// 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);
}
break;
}
case "stack_dump": {
@@ -419,17 +479,75 @@ 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": {
@@ -437,15 +555,103 @@ export class ServerController {
this.didFirstOutput = true;
// this.request_scene_tree();
}
const lines = command.parameters[0];
for (const line of lines) {
debug.activeDebugConsole.appendLine(line);
for (const output of command.parameters[0]) {
output.split("\n").forEach((line) => debug.activeDebugConsole.appendLine(bbcodeParser.parse(line)));
}
break;
}
case "error": {
if (!this.didFirstOutput) {
this.didFirstOutput = true;
}
this.handle_error(command);
break;
}
}
}
async handle_error(command: Command) {
const params = command.parameters;
const e = {
hr: params[0],
min: params[1],
sec: params[2],
msec: params[3],
file: params[4] as string,
func: params[5] as string,
line: params[6],
error: params[7] as string,
desc: params[8] as string,
warning: params[9] as boolean,
stack: [],
};
const stackCount = params[10] ?? 0;
for (let i = 0; i < stackCount; i += 3) {
const file = params[11 + i];
const func = params[12 + i];
const line = params[13 + i];
const msg = `${file.slice("res://".length)}:${line} @ ${func}()`;
const extras = {
source: { name: (await convert_resource_path_to_uri(file)).toString() },
line: line,
};
e.stack.push({ msg: msg, extras: extras });
}
const time = `${e.hr}:${e.min}:${e.sec}.${e.msec}`;
let file = e.file;
if (file.startsWith("res://")) {
file = file.slice("res://".length);
}
const location = `${file}:${e.line}`;
const color = e.warning ? "yellow" : "red";
const lang = e.file.startsWith("res://") ? "GDScript" : "C++";
const extras = {
source: { name: (await convert_resource_path_to_uri(e.file)).toString() },
line: e.line,
group: "startCollapsed",
};
if (e.desc) {
this.stderr(`${ansi[color]}${time} | ${e.desc}`, extras);
this.stderr(`${ansi.dim.white}<${lang} Error> ${ansi.white}${e.error}`);
} else {
this.stderr(`${ansi[color]}${time} | ${e.error}`, extras);
}
this.stderr(`${ansi.dim.white}<${lang} Source> ${ansi.white}${location}`);
if (stackCount !== 0) {
this.stderr(`${ansi.dim.white}<Stack Trace>`, { group: "start" });
for (const frame of e.stack) {
this.stderr(`${ansi.white}${frame.msg}`, frame.extras);
}
this.stderr("", { group: "end" });
}
this.stderr("", { group: "end" });
}
stdout(output = "", extra = {}) {
this.session.sendEvent({
event: "output",
body: {
category: "stdout",
output: output + ansi.reset,
...extra,
},
} as DebugProtocol.OutputEvent);
}
stderr(output = "", extra = {}) {
this.session.sendEvent({
event: "output",
body: {
category: "stderr",
output: output + ansi.reset,
...extra,
},
} as DebugProtocol.OutputEvent);
}
public abort() {
log.info("Aborting debug controller");
this.session.sendEvent(new TerminatedEvent());
@@ -480,19 +686,14 @@ export class ServerController {
const line = stackFrames[0].line;
if (this.steppingOut) {
const breakpoint = this.session.debug_data
.get_breakpoints(file)
.find((bp) => bp.line === line);
const breakpoint = this.session.debug_data.get_breakpoints(file).find((bp) => bp.line === line);
if (!breakpoint) {
if (this.session.debug_data.stack_count > 1) {
continueStepping = this.session.debug_data.stack_count === stackCount;
} else {
const fileSame =
stackFrames[0].file === this.session.debug_data.last_frame.file;
const funcSame =
stackFrames[0].function === this.session.debug_data.last_frame.function;
const lineGreater =
stackFrames[0].line >= this.session.debug_data.last_frame.line;
const fileSame = stackFrames[0].file === this.session.debug_data.last_frame.file;
const funcSame = stackFrames[0].function === this.session.debug_data.last_frame.function;
const lineGreater = stackFrames[0].line >= this.session.debug_data.last_frame.line;
continueStepping = fileSame && funcSame && lineGreater;
}
@@ -518,20 +719,17 @@ export class ServerController {
this.session.sendEvent(new StoppedEvent("breakpoint", 0));
} else {
this.session.set_exception(true);
this.session.sendEvent(
new StoppedEvent("exception", 0, this.exception)
);
this.session.sendEvent(new StoppedEvent("exception", 0, this.exception));
}
}
private send_command(command: string, parameters?: any[]) {
const commandArray: any[] = [command];
// log.debug("send_command", this.connectedVersion);
if (this.connectedVersion[2] >= "2") {
if (this.projectVersionMinor >= 2) {
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();
@@ -547,26 +745,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 variable: GodotVariable = { name, value, type };
build_sub_values(variable);
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);
}
}
}

View File

@@ -0,0 +1,467 @@
import { promises as fs } from "fs";
import * as path from "path";
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";
const execFileAsync = promisify(execFile);
chai.use(chaiSubset);
const { expect } = chai;
async function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Given a path to a script, returns an object where each key is the name of a
* breakpoint (delimited by `breakpoint::`) and each value is the line number
* where the breakpoint appears in the script.
*
* @param scriptPath The path to the script to scan.
* @returns An object of breakpoint names to line numbers.
*/
async function getBreakpointLocations(scriptPath: string): Promise<{ [key: string]: vscode.Location }> {
const script_content = await fs.readFile(scriptPath, "utf-8");
const breakpoints: { [key: string]: vscode.Location } = {};
const breakpointRegex = /\b(breakpoint::.*)\b/g;
let match: RegExpExecArray | null;
while ((match = breakpointRegex.exec(script_content)) !== null) {
const breakpointName = match[1];
const line = match.index ? script_content.substring(0, match.index).split("\n").length : 1;
breakpoints[breakpointName] = new vscode.Location(
vscode.Uri.file(scriptPath),
new vscode.Position(line - 1, 0),
);
}
return breakpoints;
}
async function waitForActiveStackItemChange(
ms: number = 10000,
): Promise<vscode.DebugThread | vscode.DebugStackFrame | undefined> {
const res = await new Promise<vscode.DebugThread | vscode.DebugStackFrame | undefined>((resolve, reject) => {
const debugListener = vscode.debug.onDidChangeActiveStackItem((event) => {
debugListener.dispose();
resolve(vscode.debug.activeStackItem);
});
// Timeout fallback in case stack item never changes
setTimeout(() => {
debugListener.dispose();
console.warn();
reject(new Error(`The ActiveStackItem eventwas not changed within the timeout period of '${ms}'`));
}, ms);
});
return res;
}
async function getStackFrames(threadId: number = 1): Promise<DebugProtocol.StackFrame[]> {
// Ensure there is an active debug session
if (!vscode.debug.activeDebugSession) {
throw new Error("No active debug session found");
}
// corresponds to file://./debug_session.ts stackTraceRequest(...)
const stackTraceResponse = await vscode.debug.activeDebugSession.customRequest("stackTrace", {
threadId: threadId,
});
// Extract and return the stack frames
return stackTraceResponse.stackFrames || [];
}
async function waitForBreakpoint(
breakpoint: vscode.SourceBreakpoint,
timeoutMs: number,
ctx?: Mocha.Context,
): Promise<void> {
const t0 = performance.now();
console.log(
fmt(
`Waiting for breakpoint ${breakpoint.location.uri.path}:${breakpoint.location.range.start.line}, enabled: ${breakpoint.enabled}`,
),
);
const res = await waitForActiveStackItemChange(timeoutMs);
const t1 = performance.now();
console.log(
fmt(
`Waiting for breakpoint completed ${breakpoint.location.uri.path}:${breakpoint.location.range.start.line}, enabled: ${breakpoint.enabled}, took ${t1 - t0}ms`,
),
);
const stackFrames = await getStackFrames();
if (
stackFrames[0].source.path !== breakpoint.location.uri.fsPath ||
stackFrames[0].line != breakpoint.location.range.start.line + 1
) {
throw new Error(
`Wrong breakpoint was hit. Expected: ${breakpoint.location.uri.fsPath}:${breakpoint.location.range.start.line + 1}, Got: ${stackFrames[0].source.path}:${stackFrames[0].line}`,
);
}
}
enum VariableScope {
Locals,
Members,
Globals,
}
async function getVariablesForVSCodeID(vscode_id: number): Promise<DebugProtocol.Variable[]> {
// corresponds to file://./debug_session.ts protected async variablesRequest
const variablesResponse = await vscode.debug.activeDebugSession?.customRequest("variables", {
variablesReference: vscode_id,
});
return variablesResponse?.variables || [];
}
async function getVariablesForScope(
scope: VariableScope,
stack_frame_id: number = 0,
): Promise<DebugProtocol.Variable[]> {
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<any> {
// corresponds to file://./debug_session.ts protected async evaluateRequest
const evaluateResponse: DebugProtocol.EvaluateResponse = await vscode.debug.activeDebugSession?.customRequest(
"evaluate",
{
context,
expression,
frameId,
},
);
return evaluateResponse.body;
}
function formatMs(ms: number): string {
const seconds = Math.floor((ms / 1000) % 60);
const minutes = Math.floor((ms / (1000 * 60)) % 60);
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}.${(Math.round(ms) % 1000).toString().padStart(3, "0")}`;
}
function formatMessage(this: Mocha.Context, msg: string): string {
return `[${formatMs(performance.now() - this.testStart)}] ${msg}`;
}
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<void> {
const t0 = performance.now();
const debugConfig: vscode.DebugConfiguration = {
type: "godot",
request: "launch",
name: "Godot Debug",
scene: scene,
additional_options: "--headless",
};
console.log(fmt(`Starting debugger for scene ${scene}`));
const res = await vscode.debug.startDebugging(vscode.workspace.workspaceFolders?.[0], debugConfig);
const t1 = performance.now();
console.log(fmt(`Starting debugger for scene ${scene} completed, took ${t1 - t0}ms`));
if (!res) {
throw new Error(`Failed to start debugging for scene ${scene}`);
}
}
suite("DAP Integration Tests - Variable Scopes", () => {
// workspaceFolder should match `.vscode-test.js`::workspaceFolder
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`
console.log("Environment Variables:");
for (const [key, value] of Object.entries(process.env)) {
console.log(`${key}: ${value}`);
}
// 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<string>("editorPath.godot4");
// get the path for currently opened project in vscode test instance:
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);
}
console.log(exec_res.stdout);
});
setup(async function () {
console.log(`➤ Test '${this?.currentTest.title}' starting`);
await vscode.commands.executeCommand("workbench.action.closeAllEditors");
if (vscode.debug.breakpoints) {
await vscode.debug.removeBreakpoints(vscode.debug.breakpoints);
}
this.testStart = performance.now();
fmt = formatMessage.bind(this);
});
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("should return correct scopes", async function () {
const breakpointLocations = await getBreakpointLocations(path.join(workspaceFolder, "ScopeVars.gd"));
const breakpoint = new vscode.SourceBreakpoint(
breakpointLocations["breakpoint::ScopeVars::ClassFoo::test_function"],
);
vscode.debug.addBreakpoints([breakpoint]);
await startDebugging("ScopeVars.tscn");
await waitForBreakpoint(breakpoint, 2000);
// TODO: current DAP needs a delay before it will return variables
console.log("Sleeping for 2 seconds");
await sleep(2000);
// corresponds to file://./debug_session.ts async scopesRequest
const stack_scopes_map: Map<
number,
{
Locals: number;
Members: number;
Globals: number;
}
> = 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],
};
}
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 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" },
]);
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"));
const breakpoint = new vscode.SourceBreakpoint(breakpointLocations["breakpoint::ScopeVars::_ready"]);
vscode.debug.addBreakpoints([breakpoint]);
await startDebugging("ScopeVars.tscn");
await waitForBreakpoint(breakpoint, 2000);
// TODO: current DAP needs a delay before it will return variables
console.log("Sleeping for 2 seconds");
await sleep(2000);
const variables = await getVariablesForScope(VariableScope.Globals);
expect(variables).to.containSubset([{ name: "GlobalScript" }]);
})?.timeout(10000);
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]);
await startDebugging("ScopeVars.tscn");
await waitForBreakpoint(breakpoint, 2000);
// TODO: current DAP needs a delay before it will return variables
console.log("Sleeping for 2 seconds");
await sleep(2000);
const variables = await getVariablesForScope(VariableScope.Locals);
expect(variables.length).to.equal(2);
expect(variables).to.containSubset([{ name: "str_var" }]);
expect(variables).to.containSubset([{ name: "self_var" }]);
})?.timeout(10000);
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]);
await startDebugging("ScopeVars.tscn");
await waitForBreakpoint(breakpoint, 2000);
// TODO: current DAP needs a delay before it will return variables
console.log("Sleeping for 2 seconds");
await sleep(2000);
const variables = await getVariablesForScope(VariableScope.Members);
expect(variables.length).to.equal(4);
expect(variables).to.containSubset([{ name: "self" }]);
expect(variables).to.containSubset([{ name: "member1" }]);
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"));
const breakpoint = new vscode.SourceBreakpoint(breakpointLocations["breakpoint::BuiltInTypes::_ready"]);
vscode.debug.addBreakpoints([breakpoint]);
await startDebugging("BuiltInTypes.tscn");
await waitForBreakpoint(breakpoint, 2000);
// TODO: current DAP needs a delay before it will return variables
console.log("Sleeping for 2 seconds");
await sleep(2000);
const variables = await getVariablesForScope(VariableScope.Locals);
expect(variables).to.containSubset([{ name: "int_var", value: "42" }]);
expect(variables).to.containSubset([{ name: "float_var", value: "3.14" }]);
expect(variables).to.containSubset([{ name: "bool_var", value: "true" }]);
expect(variables).to.containSubset([{ name: "string_var", value: "Hello, Godot!" }]);
expect(variables).to.containSubset([{ name: "nil_var", value: "null" }]);
expect(variables).to.containSubset([{ name: "vector2", value: "Vector2(10, 20)" }]);
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: "(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: "(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)" }]);
expect(variables).to.containSubset([{ name: "callable_var", value: "Callable()" }]);
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>)'",
);
})?.timeout(10000);
test("should retrieve all complex variables correctly", async function () {
const breakpointLocations = await getBreakpointLocations(path.join(workspaceFolder, "ExtensiveVars.gd"));
const breakpoint = new vscode.SourceBreakpoint(breakpointLocations["breakpoint::ExtensiveVars::_ready"]);
vscode.debug.addBreakpoints([breakpoint]);
await startDebugging("ExtensiveVars.tscn");
await waitForBreakpoint(breakpoint, 2000);
// TODO: current DAP needs a delay before it will return variables
console.log("Sleeping for 2 seconds");
await sleep(2000);
const memberVariables = await getVariablesForScope(VariableScope.Members);
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);
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);
});

View File

@@ -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);
});
});

View File

@@ -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<GodotIdWithPathString, number>; // use GodotIdWithPathString, since JS Map treats GodotIdWithPath only by reference
private vscode_to_godot: Map<number, GodotIdWithPath>;
private next_vscode_id: number;
constructor() {
this.godot_to_vscode = new Map<GodotIdWithPathString, number>();
this.vscode_to_godot = new Map<number, GodotIdWithPath>();
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;
}
}

View File

@@ -0,0 +1,78 @@
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");
});
});

View File

@@ -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<GodotObject>) => void;
private _reject!: (reason?: any) => void;
public promise: Promise<GodotObject>;
private timeoutId?: NodeJS.Timeout;
constructor(timeoutMs?: number) {
this.promise = new Promise<GodotObject>((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);
}
}

View File

@@ -0,0 +1,282 @@
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<bigint, GodotObjectPromise> = new Map();
public godot_id_to_vscode_id_mapper = new GodotIdToVscodeIdMapper();
// variablesFrameId: number;
private frame_id_to_scopes_map: Map<number, VsCodeScopeIDs> = 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<DebugProtocol.Variable[]> {
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<DebugProtocol.Variable> {
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<DebugProtocol.Variable> {
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);
}
}

View File

@@ -23,7 +23,9 @@ import {
Projection,
ENCODE_FLAG_64,
ENCODE_FLAG_OBJECT_AS_ID,
ENCODE_FLAG_TYPED_ARRAY,
ENCODE_FLAG_TYPED_ARRAY_MASK,
ENCODE_FLAG_TYPED_DICT_MASK,
ContainerTypeFlags,
RID,
Callable,
Signal,
@@ -142,13 +144,9 @@ export class VariantDecoder {
case GDScriptTypes.SIGNAL:
return this.decode_Signal(model);
case GDScriptTypes.DICTIONARY:
return this.decode_Dictionary(model);
return this.decode_Dictionary(model, type);
case GDScriptTypes.ARRAY:
if (type & ENCODE_FLAG_TYPED_ARRAY) {
return this.decode_TypedArray(model);
} else {
return this.decode_Array(model);
}
return this.decode_Array(model, type);
case GDScriptTypes.PACKED_BYTE_ARRAY:
return this.decode_PackedByteArray(model);
case GDScriptTypes.PACKED_INT32_ARRAY:
@@ -210,6 +208,15 @@ export class VariantDecoder {
return output;
}
private decode_ContainerTypeFlag(model: BufferModel, type: GDScriptTypes, bitOffset: number) {
const shiftedType = (type >> bitOffset) & 0b11;
if (shiftedType === ContainerTypeFlags.BUILTIN) {
return this.decode_UInt32(model);
} else {
return this.decode_String(model);
}
}
private decode_AABBf(model: BufferModel) {
return new AABB(this.decode_Vector3f(model), this.decode_Vector3f(model));
}
@@ -218,26 +225,16 @@ export class VariantDecoder {
return new AABB(this.decode_Vector3d(model), this.decode_Vector3d(model));
}
private decode_Array(model: BufferModel) {
const output: Array<any> = [];
private decode_Array(model: BufferModel, type: GDScriptTypes) {
const output = [];
const count = this.decode_UInt32(model);
for (let i = 0; i < count; i++) {
const value = this.decode_variant(model);
output.push(value);
let arrayType = null;
if (type & ENCODE_FLAG_TYPED_ARRAY_MASK) {
arrayType = this.decode_ContainerTypeFlag(model, type, 16);
}
return output;
}
private decode_TypedArray(model: BufferModel) {
const output: Array<any> = [];
// TODO: the type information is currently discarded
// it needs to be decoded and then packed into the output somehow
const type = this.decode_UInt32(model);
const count = this.decode_UInt32(model);
for (let i = 0; i < count; i++) {
@@ -271,8 +268,20 @@ export class VariantDecoder {
return new Color(rgb.x, rgb.y, rgb.z, a);
}
private decode_Dictionary(model: BufferModel) {
const output = new Map<any, any>();
private decode_Dictionary(model: BufferModel, type: GDScriptTypes) {
const output = new Map();
let keyType = null;
let valueType = null;
if (type & ENCODE_FLAG_TYPED_DICT_MASK) {
keyType = this.decode_ContainerTypeFlag(model, type, 16);
valueType = this.decode_ContainerTypeFlag(model, type, 18);
// console.log("type:", (type >> 16) & 0b11, "keyType:", keyType);
// console.log("type:", type >> 18, "valueType:", valueType);
}
// TODO: the type information is currently discarded
// it needs to be decoded and then packed into the output somehow
const count = this.decode_UInt32(model);
for (let i = 0; i < count; i++) {

View File

@@ -54,7 +54,14 @@ export enum GDScriptTypes {
export const ENCODE_FLAG_64 = 1 << 16;
export const ENCODE_FLAG_OBJECT_AS_ID = 1 << 16;
export const ENCODE_FLAG_TYPED_ARRAY = 1 << 16;
export const ENCODE_FLAG_TYPED_ARRAY_MASK = 0b11 << 16;
export const ENCODE_FLAG_TYPED_DICT_MASK = 0b1111 << 16;
export enum ContainerTypeFlags {
BUILTIN = 1,
CLASS_NAME = 2,
SCRIPT = 3,
}
export interface BufferModel {
buffer: Buffer;
@@ -276,7 +283,7 @@ export class ObjectId implements GDObject {
}
public type_name(): string {
return "Object";
return "ObjectId";
}
}
@@ -464,7 +471,7 @@ export class Signal implements GDObject {
constructor(public name: string, public oid: ObjectId) {}
public stringify_value(): string {
return `${this.name}() ${this.oid.stringify_value()}`;
return `(${this.name}, ${this.oid.stringify_value()})`;
}
public sub_values(): GodotVariable[] {

View File

@@ -1,23 +1,13 @@
import {
TreeDataProvider,
EventEmitter,
Event,
ProviderResult,
TreeItem,
TreeItemCollapsibleState,
} from "vscode";
import { TreeDataProvider, EventEmitter, Event, ProviderResult, TreeItem, TreeItemCollapsibleState } from "vscode";
import { GodotVariable, RawObject, ObjectId } from "./debug_runtime";
export class InspectorProvider implements TreeDataProvider<RemoteProperty> {
private _on_did_change_tree_data: EventEmitter<
private _on_did_change_tree_data: EventEmitter<RemoteProperty | undefined> = new EventEmitter<
RemoteProperty | undefined
> = new EventEmitter<RemoteProperty | undefined>();
>();
private tree: RemoteProperty | undefined;
public readonly onDidChangeTreeData: Event<RemoteProperty> | undefined = this
._on_did_change_tree_data.event;
constructor() {}
public readonly onDidChangeTreeData: Event<RemoteProperty> | undefined = this._on_did_change_tree_data.event;
public clean_up() {
if (this.tree) {
@@ -26,12 +16,7 @@ export class InspectorProvider implements TreeDataProvider<RemoteProperty> {
}
}
public fill_tree(
element_name: string,
class_name: string,
object_id: number,
variable: GodotVariable
) {
public fill_tree(element_name: string, class_name: string, object_id: number, variable: GodotVariable) {
this.tree = this.parse_variable(variable, object_id);
this.tree.label = element_name;
this.tree.collapsibleState = TreeItemCollapsibleState.Expanded;
@@ -39,17 +24,15 @@ export class InspectorProvider implements TreeDataProvider<RemoteProperty> {
this._on_did_change_tree_data.fire(undefined);
}
public getChildren(
element?: RemoteProperty
): ProviderResult<RemoteProperty[]> {
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;
}
}
@@ -57,15 +40,11 @@ export class InspectorProvider implements TreeDataProvider<RemoteProperty> {
return element;
}
public get_changed_value(
parents: RemoteProperty[],
property: RemoteProperty,
new_parsed_value: any
) {
public get_changed_value(parents: RemoteProperty[], property: RemoteProperty, new_parsed_value: any) {
const idx = parents.length - 1;
const value = parents[idx].value;
if (Array.isArray(value)) {
const idx = parseInt(property.label);
const idx = Number.parseInt(property.label);
if (idx < value.length) {
value[idx] = new_parsed_value;
}
@@ -104,13 +83,9 @@ export class InspectorProvider implements TreeDataProvider<RemoteProperty> {
if (Number.isInteger(value)) {
rendered_value = `${value}`;
} else {
rendered_value = `${parseFloat(value.toFixed(5))}`;
rendered_value = `${Number.parseFloat(value.toFixed(5))}`;
}
} else if (
typeof value === "bigint" ||
typeof value === "boolean" ||
typeof value === "string"
) {
} else if (typeof value === "bigint" || typeof value === "boolean" || typeof value === "string") {
rendered_value = `${value}`;
} else if (typeof value === "undefined") {
rendered_value = "null";
@@ -131,25 +106,21 @@ export class InspectorProvider implements TreeDataProvider<RemoteProperty> {
let child_props: RemoteProperty[] = [];
if (value) {
const sub_variables =
typeof value["sub_values"] === "function" &&
value instanceof ObjectId === false
? value.sub_values()
: Array.isArray(value)
? value.map((va, i) => {
return { name: `${i}`, value: va };
})
: value instanceof Map
? Array.from(value.keys()).map((va) => {
const name =
typeof va["rendered_value"] === "function"
? va.rendered_value()
: `${va}`;
const map_value = value.get(va);
let sub_variables = [];
if (typeof value.sub_values === "function" && value instanceof ObjectId === false) {
sub_variables = value.sub_values();
} else if (Array.isArray(value)) {
sub_variables = value.map((va, i) => {
return { name: `${i}`, value: va };
});
} else if (value instanceof Map) {
sub_variables = Array.from(value.keys()).map((va) => {
const name = typeof va.rendered_value === "function" ? va.rendered_value() : `${va}`;
const map_value = value.get(va);
return { name: name, value: map_value };
});
}
return { name: name, value: map_value };
})
: [];
child_props = sub_variables?.map((va) => {
return this.parse_variable(va, object_id);
});
@@ -160,14 +131,12 @@ export class InspectorProvider implements TreeDataProvider<RemoteProperty> {
value,
object_id,
child_props,
child_props.length === 0
? TreeItemCollapsibleState.None
: TreeItemCollapsibleState.Collapsed
child_props.length === 0 ? TreeItemCollapsibleState.None : TreeItemCollapsibleState.Collapsed,
);
out_prop.description = rendered_value;
out_prop.properties.forEach((prop) => {
for (const prop of out_prop.properties) {
prop.parent = out_prop;
});
}
out_prop.description = rendered_value;
if (value instanceof ObjectId) {
@@ -180,11 +149,10 @@ export class InspectorProvider implements TreeDataProvider<RemoteProperty> {
typeof value === "string"
) {
out_prop.contextValue = "editable_value";
} else if (
Array.isArray(value) ||
(value instanceof Map && value instanceof RawObject === false)
) {
out_prop.properties.forEach((prop) => (prop.changes_parent = true));
} else if (Array.isArray(value) || (value instanceof Map && value instanceof RawObject === false)) {
for (const prop of out_prop.properties) {
prop.parent = out_prop;
}
}
return out_prop;
@@ -200,7 +168,7 @@ export class RemoteProperty extends TreeItem {
public value: any,
public object_id: number,
public properties: RemoteProperty[],
public collapsibleState?: TreeItemCollapsibleState
public collapsibleState?: TreeItemCollapsibleState,
) {
super(label, collapsibleState);
}

View File

@@ -5,6 +5,7 @@ import {
ProviderResult,
TreeItem,
TreeItemCollapsibleState,
Uri
} from "vscode";
import path = require("path");
import { get_extension_uri } from "../utils";
@@ -27,15 +28,15 @@ export class SceneTreeProvider implements TreeDataProvider<SceneNode> {
this._on_did_change_tree_data.fire(undefined);
}
public getChildren(element?: SceneNode): ProviderResult<SceneNode[]> {
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;
}
}
@@ -81,8 +82,8 @@ export class SceneNode extends TreeItem {
const iconName = class_name + ".svg";
this.iconPath = {
light: path.join(iconDir, "light", iconName),
dark: path.join(iconDir, "dark", iconName),
light: Uri.file(path.join(iconDir, "light", iconName)),
dark: Uri.file(path.join(iconDir, "dark", iconName)),
};
}
}

View File

@@ -1,9 +1,11 @@
import * as fs from "node:fs";
import * as path from "node:path";
import * as vscode from "vscode";
import { attemptSettingsUpdate, get_extension_uri, clean_godot_path } from "./utils";
import {
GDInlayHintsProvider,
GDHoverProvider,
GDDocumentDropEditProvider,
GDDocumentLinkProvider,
GDSemanticTokensProvider,
GDCompletionItemProvider,
@@ -21,9 +23,11 @@ import {
find_project_file,
register_command,
set_context,
get_editor_data_dir,
get_project_dir,
get_project_version,
verify_godot_version,
convert_uri_to_resource_path,
} from "./utils";
import { prompt_for_godot_executable } from "./utils/prompts";
import { killSubProcesses, subProcess } from "./utils/subspawn";
@@ -34,6 +38,7 @@ interface Extension {
debug?: GodotDebugger;
scenePreviewProvider?: ScenePreviewProvider;
linkProvider?: GDDocumentLinkProvider;
dropsProvider?: GDDocumentDropEditProvider;
hoverProvider?: GDHoverProvider;
inlayProvider?: GDInlayHintsProvider;
formattingProvider?: FormattingProvider;
@@ -54,6 +59,7 @@ export function activate(context: vscode.ExtensionContext) {
globals.debug = new GodotDebugger(context);
globals.scenePreviewProvider = new ScenePreviewProvider(context);
globals.linkProvider = new GDDocumentLinkProvider(context);
globals.dropsProvider = new GDDocumentDropEditProvider(context);
globals.hoverProvider = new GDHoverProvider(context);
globals.inlayProvider = new GDInlayHintsProvider(context);
globals.formattingProvider = new FormattingProvider(context);
@@ -65,13 +71,14 @@ export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(
register_command("openEditor", open_workspace_with_editor),
register_command("openEditorSettings", open_godot_editor_settings),
register_command("copyResourcePath", copy_resource_path),
register_command("listGodotClasses", list_classes),
register_command("switchSceneScript", switch_scene_script),
register_command("getGodotPath", get_godot_path),
);
set_context("godotFiles", ["gdscript", "gdscene", "gdresource", "gdshader",]);
set_context("godotFiles", ["gdscript", "gdscene", "gdresource", "gdshader"]);
set_context("sceneLikeFiles", ["gdscript", "gdscene"]);
get_project_version().then(async () => {
@@ -116,25 +123,18 @@ export function deactivate(): Thenable<void> {
});
}
function copy_resource_path(uri: vscode.Uri) {
async function copy_resource_path(uri: vscode.Uri) {
if (!uri) {
uri = vscode.window.activeTextEditor.document.uri;
}
const project_dir = path.dirname(find_project_file(uri.fsPath));
if (project_dir === null) {
return;
}
let relative_path = path.normalize(path.relative(project_dir, uri.fsPath));
relative_path = relative_path.split(path.sep).join(path.posix.sep);
relative_path = "res://" + relative_path;
const relative_path = await convert_uri_to_resource_path(uri);
vscode.env.clipboard.writeText(relative_path);
}
async function list_classes() {
await globals.lsp.client.list_classes();
await globals.docsProvider.list_native_classes();
}
async function switch_scene_script() {
@@ -166,7 +166,7 @@ async function open_workspace_with_editor() {
if (get_configuration("editor.verbose")) {
command += " -v";
}
const existingTerminal = vscode.window.terminals.find(t => t.name === "Godot Editor");
const existingTerminal = vscode.window.terminals.find((t) => t.name === "Godot Editor");
if (existingTerminal) {
existingTerminal.dispose();
}
@@ -195,6 +195,39 @@ async function open_workspace_with_editor() {
}
}
async function open_godot_editor_settings() {
const dir = get_editor_data_dir();
const files = fs.readdirSync(dir).filter((v) => v.endsWith(".tres"));
const ver = await get_project_version();
for (const file of files) {
if (file.includes(ver)) {
files.unshift(files.splice(files.indexOf(file), 1)[0]);
break;
}
}
const choices: vscode.QuickPickItem[] = [];
for (const file of files) {
const pick: vscode.QuickPickItem = {
label: file,
description: path.join(dir, file),
};
choices.push(pick);
}
vscode.window.showQuickPick(choices).then(async (item) => {
if (item === undefined) {
return;
}
const _path = path.join(dir, item.label);
const doc = await vscode.workspace.openTextDocument(_path);
vscode.window.showTextDocument(doc);
});
}
/**
* Returns the executable path for Godot based on the current project's version.
* Created to allow other extensions to get the path without having to go
@@ -202,7 +235,7 @@ async function open_workspace_with_editor() {
* value (godotTools.editorPath.godot3/4).
* @returns
*/
async function get_godot_path(): Promise<string|undefined> {
async function get_godot_path(): Promise<string | undefined> {
const projectVersion = await get_project_version();
if (projectVersion === undefined) {
return undefined;
@@ -217,7 +250,7 @@ class GodotEditorTerminal implements vscode.Pseudoterminal {
private closeEmitter = new vscode.EventEmitter<number>();
onDidClose?: vscode.Event<number> = this.closeEmitter.event;
constructor(private command: string) { }
constructor(private command: string) {}
open(initialDimensions: vscode.TerminalDimensions | undefined): void {
const proc = subProcess("GodotEditor", this.command, { shell: true, detached: true });

View File

@@ -1,61 +1,199 @@
import * as vscode from "vscode";
import * as path from "node:path";
import * as fs from "node:fs";
import * as path from "node:path";
import * as vscode from "vscode";
import { format_document, type FormatterOptions } from "./textmate";
import * as chai from "chai";
const expect = chai.expect;
import { expect } from "chai";
const dots = ["..", "..", ".."];
const basePath = path.join(__filename, ...dots);
const snapshotsFolderPath = path.join(basePath, "src/formatter/snapshots");
function get_options(testFolderPath: string) {
const options: FormatterOptions = {
maxEmptyLines: 2,
denseFunctionParameters: false,
};
const optionsPath = path.join(testFolderPath, "config.json");
function normalizeLineEndings(str: string) {
return str.replace(/\r?\n/g, "\n");
}
const defaultOptions: FormatterOptions = {
maxEmptyLines: 2,
denseFunctionParameters: false,
};
function get_options(folder: fs.Dirent) {
const optionsPath = path.join(folder.path, folder.name, "config.json");
if (fs.existsSync(optionsPath)) {
const file = fs.readFileSync(optionsPath).toString();
const config = JSON.parse(file);
return { ...options, ...config } as FormatterOptions;
return { ...defaultOptions, ...config } as FormatterOptions;
}
return options;
return defaultOptions;
}
function set_content(content: string) {
return vscode.workspace
.openTextDocument()
.then((doc) => vscode.window.showTextDocument(doc))
.then((editor) => {
const editBuilder = (textEdit) => {
textEdit.insert(new vscode.Position(0, 0), String(content));
};
return editor
.edit(editBuilder, {
undoStopBefore: true,
undoStopAfter: false,
})
.then(() => editor);
});
}
function build_config(lines: string[]) {
try {
return JSON.parse(lines.join("\n"));
} catch (e) {
return {};
}
}
class TestLines {
config: string[] = [];
in: string[] = [];
out: string[] = [];
parse(_config) {
const config = { ...defaultOptions, ..._config, ...build_config(this.config) };
const test: Test = {
in: this.in.join("\n"),
out: this.out.join("\n"),
config: config,
};
if (test.out === "") {
test.out = this.in.join("\n");
}
if (!config.strictTrailingNewlines) {
test.in = test.in.trimEnd();
test.out = test.out.trimEnd();
}
return test;
}
}
interface Test {
config?: FormatterOptions;
in: string;
out: string;
}
const CONFIG_ALL = "# --- CONFIG ALL ---";
const CONFIG = "# --- CONFIG ---";
const IN = "# --- IN ---";
const OUT = "# --- OUT ---";
const END = "# --- END ---";
const MODES = [CONFIG_ALL, CONFIG, IN, OUT, END];
function parse_test_file(content: string): Test[] {
let defaultConfig = null;
let defaultConfigString: string[] = [];
const tests: Test[] = [];
let mode = null;
let test = new TestLines();
for (const _line of content.split("\n")) {
const line = _line.trim();
if (MODES.includes(line)) {
if (line === CONFIG || line === IN) {
if (test.in.length !== 0) {
tests.push(test.parse(defaultConfig));
test = new TestLines();
}
}
if (defaultConfigString.length !== 0) {
defaultConfig = build_config(defaultConfigString);
defaultConfigString = [];
}
mode = line;
continue;
}
if (mode === CONFIG_ALL) defaultConfigString.push(line);
if (mode === CONFIG) test.config.push(line);
if (mode === IN) test.in.push(line);
if (mode === OUT) test.out.push(line);
}
if (test.in.length !== 0) {
tests.push(test.parse(defaultConfig));
}
return tests;
}
suite("GDScript Formatter Tests", () => {
// Search for all folders in the snapshots folder and run a test for each
// comparing the output of the formatter with the expected output.
// To add a new test, create a new folder in the snapshots folder
// and add two files, `in.gd` and `out.gd` for the input and expected output.
const snapshotsFolderPath = path.join(basePath, "src/formatter/snapshots");
const testFolders = fs.readdirSync(snapshotsFolderPath);
const testFiles = fs.readdirSync(snapshotsFolderPath, { withFileTypes: true, recursive: true });
// biome-ignore lint/complexity/noForEach: <explanation>
testFolders.forEach((testFolder) => {
const testFolderPath = path.join(snapshotsFolderPath, testFolder);
if (fs.statSync(testFolderPath).isDirectory()) {
test(`Snapshot Test: ${testFolder}`, async () => {
const uriIn = vscode.Uri.file(path.join(testFolderPath, "in.gd"));
const uriOut = vscode.Uri.file(path.join(testFolderPath, "out.gd"));
teardown(async () => {
await vscode.commands.executeCommand("workbench.action.closeAllEditors");
});
const documentIn = await vscode.workspace.openTextDocument(uriIn);
const documentOut = await vscode.workspace.openTextDocument(uriOut);
for (const file of testFiles.filter((f) => f.isFile())) {
if (["in.gd", "out.gd"].includes(file.name) || !file.name.endsWith(".gd")) {
continue;
}
test(`Snapshot Test: ${file.name}`, async () => {
const uri = vscode.Uri.file(path.join(snapshotsFolderPath, file.name));
const inDoc = await vscode.workspace.openTextDocument(uri);
const text = inDoc.getText();
const options = get_options(testFolderPath);
const edits = format_document(documentIn, options);
for (const test of parse_test_file(text)) {
const editor = await set_content(test.in);
const document = editor.document;
const edits = format_document(document, test.config);
// Apply the formatting edits
const workspaceEdit = new vscode.WorkspaceEdit();
workspaceEdit.set(uriIn, edits);
workspaceEdit.set(document.uri, edits);
await vscode.workspace.applyEdit(workspaceEdit);
// Compare the result with the expected output
expect(documentIn.getText().replace("\r\n", "\n")).to.equal(
documentOut.getText().replace("\r\n", "\n"),
);
});
const actual = normalizeLineEndings(document.getText());
const expected = normalizeLineEndings(test.out);
expect(actual).to.equal(expected);
}
});
}
for (const folder of testFiles.filter((f) => f.isDirectory())) {
const pathIn = path.join(folder.path, folder.name, "in.gd");
const pathOut = path.join(folder.path, folder.name, "out.gd");
if (!(fs.existsSync(pathIn) && fs.existsSync(pathOut))) {
continue;
}
});
test(`Snapshot Pair Test: ${folder.name}`, async () => {
const uriIn = vscode.Uri.file(path.join(folder.path, folder.name, "in.gd"));
const uriOut = vscode.Uri.file(path.join(folder.path, folder.name, "out.gd"));
const documentIn = await vscode.workspace.openTextDocument(uriIn);
const documentOut = await vscode.workspace.openTextDocument(uriOut);
const options = get_options(folder);
const edits = format_document(documentIn, options);
// Apply the formatting edits
const workspaceEdit = new vscode.WorkspaceEdit();
workspaceEdit.set(uriIn, edits);
await vscode.workspace.applyEdit(workspaceEdit);
// Compare the result with the expected output
const actual = normalizeLineEndings(documentIn.getText());
const expected = normalizeLineEndings(documentOut.getText());
expect(actual).to.equal(expected);
});
}
});

View File

@@ -0,0 +1,101 @@
## An `IN` block is fed into the formatter and the output is compared to the `OUT` block
```
# --- IN ---
var a = 10
# --- OUT ---
var a = 10
```
## Trailing newlines in `IN` and `OUT` blocks is automatically removed
```
# --- IN ---
var a = 10
# --- OUT ---
var a = 10
# --- IN ---
var b = 'ten'
# --- OUT ---
var b = 'ten'
```
## An `IN` block by itself will be reused at the `OUT` target
Many test cases can simply be expressed as "do not change this":
```
# --- IN ---
var a = """ {
level_file: '%s',
md5_hash: %s,
}
"""
```
## Formatter and test harness options can be controlled with `CONFIG` blocks
This test will fail because `strictTrailingNewlines: true` disables trailing newline removal.
```
# --- CONFIG ---
{"strictTrailingNewlines": true}
# --- IN ---
var a = 10
# --- OUT ---
var a = 10
```
## `CONFIG ALL` set the default options moving forward, and `END` blocks allow additional layout flexibility
```
# --- CONFIG ALL ---
{"strictTrailingNewlines": true}
# --- IN ---
var a = 10
# --- OUT ---
var a = 10
# --- END ---
# anything I want goes here
# --- IN ---
var b = 'ten'
# --- OUT ---
var b = 'ten'
```
## `CONFIG` blocks override `CONFIG ALL`, and the configs are merged for a given test
This test will pass, because the second test has a `CONFIG` that overrides the `CONFIG ALL` at the top.
```
# --- CONFIG ALL ---
{"strictTrailingNewlines": true}
# --- IN ---
var a = 10
# --- OUT ---
var a = 10
# --- END ---
# anything I want goes here
# --- CONFIG ---
{"strictTrailingNewlines": false}
# --- IN ---
var b = 'ten'
# --- OUT ---
var b = 'ten'
# --- IN ---
var c = true
# --- OUT ---
var c = true
```

View File

@@ -1,15 +0,0 @@
func f():
# arithmetic
x += 1
x -= 1
x *= 1
x /= 1
x %= 1
# bitwise
x |= 1
x &= 1
x ~= 1
x /= 1
x >>= 1
x <<= 1

View File

@@ -1,15 +0,0 @@
func f():
# arithmetic
x += 1
x -= 1
x *= 1
x /= 1
x %= 1
# bitwise
x |= 1
x &= 1
x ~= 1
x /= 1
x >>= 1
x <<= 1

View File

@@ -1,5 +0,0 @@
func f():
collision_mask = 1 << 1 | 1 << 3
collision_mask = 1 << 1 & 1 << 3
collision_mask = ~1
collision_mask = 1 ^ ~ 1

View File

@@ -1,5 +0,0 @@
func f():
collision_mask = 1 << 1 | 1 << 3
collision_mask = 1 << 1 & 1 << 3
collision_mask = ~1
collision_mask = 1 ^ ~1

View File

@@ -0,0 +1,5 @@
# --- IN ---
func handleDeath() -> void:
var signalConnections: Array[Dictionary] = self.get_incoming_connections()
for connection in signalConnections:
connection.signal.disconnect(connection.callable)

View File

@@ -0,0 +1,25 @@
# --- IN ---
func test():
# The following floating-point notations are all valid:
print(is_equal_approx(123., 123))
print(is_equal_approx(.123, 0.123))
print(is_equal_approx(.123e4, 1230))
print(is_equal_approx(123.e4, 1.23e6))
print(is_equal_approx(.123e-1, 0.0123))
print(is_equal_approx(123.e-1, 12.3))
# Same as above, but with negative numbers.
print(is_equal_approx(-123., -123))
print(is_equal_approx(-.123, -0.123))
print(is_equal_approx(-.123e4, -1230))
print(is_equal_approx(-123.e4, -1.23e6))
print(is_equal_approx(-.123e-1, -0.0123))
print(is_equal_approx(-123.e-1, -12.3))
# Same as above, but with explicit positive numbers (which is redundant).
print(is_equal_approx(+123., +123))
print(is_equal_approx(+.123, +0.123))
print(is_equal_approx(+.123e4, +1230))
print(is_equal_approx(+123.e4, +1.23e6))
print(is_equal_approx(+.123e-1, +0.0123))
print(is_equal_approx(+123.e-1, +12.3))

View File

@@ -0,0 +1,4 @@
# --- IN ---
poll_animation.on_complete(func() -> void:
highlight.visible = false
)

View File

@@ -0,0 +1,17 @@
# --- IN ---
func dump() -> String:
return """
{
level_file: '%s',
md5_hash: %s,
text: '%s',
level_size: %s,
world_pos: %s,
preview_size: %s,
preview_pos: %s,
preview_texture: %s,
explorer_layer: %s,
connections: %s,
test {test},
}
"""

View File

@@ -1,3 +0,0 @@
{
"maxEmptyLines": 1
}

View File

@@ -1,27 +0,0 @@
class Test:
func _ready():
pass
func test():
pass
# comments
func with_comments():
pass

View File

@@ -1,14 +0,0 @@
class Test:
func _ready():
pass
func test():
pass
# comments
func with_comments():
pass

View File

@@ -1,28 +0,0 @@
class Test:
func _ready():
pass
func test():
pass
# comments
func with_comments():
pass

View File

@@ -1,20 +0,0 @@
class Test:
func _ready():
pass
func test():
pass
# comments
func with_comments():
pass

View File

@@ -0,0 +1,72 @@
# --- IN ---
func test():
pass
# --- OUT ---
func test():
pass
# --- IN ---
class Test:
func _ready():
pass
# --- OUT ---
class Test:
func _ready():
pass
# --- IN ---
func test(): # with comment
pass
# --- OUT ---
func test(): # with comment
pass
# --- IN ---
class Test: # with comment
func _ready(): # with comment
pass
# --- OUT ---
class Test: # with comment
func _ready(): # with comment
pass
# --- CONFIG ---
{"maxEmptyLines": 1}
# --- IN ---
func a():
pass
func b():
pass
# --- OUT ---
func a():
pass
func b():
pass
# --- CONFIG ---
{"maxEmptyLines": 2}
# --- IN ---
func a():
pass
func b():
pass
# --- OUT ---
func a():
pass
func b():
pass

View File

@@ -0,0 +1,23 @@
# --- IN ---
var test1 := deg_to_rad(
-90
)
# --- IN ---
var test2 := Vector2(
-0.0,
1.0
)
# --- IN ---
var test3 := Vector3(
0.0,
-0.0,
0.0
)
# --- IN ---
func get_audio_compensation() -> float:
return AudioServer.get_time_since_last_mix() \
- AudioServer.get_output_latency() \
+ (1 / Engine.get_frames_per_second()) * 2

View File

@@ -44,6 +44,9 @@ var a = $Child/%Unique/ChildOfUnique
var a = %Unique
var a = %Unique/Child
var a = %Unique/%UniqueChild
var a = %"Unique"
var a = %'Unique/Child'
var a = %'Unique/%UniqueChild'
var a = $"%Unique"
var a = get_node("%Unique")

View File

@@ -44,6 +44,9 @@ var a = $Child/%Unique/ChildOfUnique
var a = %Unique
var a = %Unique/Child
var a = %Unique/%UniqueChild
var a = %"Unique"
var a = %'Unique/Child'
var a = %'Unique/%UniqueChild'
var a = $"%Unique"
var a = get_node("%Unique")

View File

@@ -0,0 +1,33 @@
# --- IN ---
func f():
# arithmetic
x += 1
x -= 1
x *= 1
x /= 1
x %= 1
x = 2 ** 2
x = 2 * -1
x **= 2
# bitwise
x |= 1
x &= 1
x ^= 1
x ~= 1
x = ~1
x /= 1
x >>= 1
x <<= 1
x = 1 << 1 | 1 >> 3
x = 1 << 1 & 1 >> 3
x = 1 ^ ~1
print(x == 1)
print(x <= 1)
print(x >= 1)
var ij := 1
var k := -ij + 1
var m := 0 + -ij

View File

@@ -0,0 +1,6 @@
# --- IN ---
var a = 1e-6
var b = 4e-09
var c = 58.1e-10
var d = 58.1e+10
var e = 9.732e-06

View File

@@ -0,0 +1,25 @@
# --- IN ---
func __get():
pass
func __set(val):
pass
var a: get = __get, set = __set
var b:
get = __get,
set = __set
var c = '':
get: return __get()
set(val): __set(val)
var d = '':
get:
print('get')
return __get()
set(val):
print('set')
__set(val)
var e = '' setget __get, __set

View File

@@ -0,0 +1,14 @@
# --- CONFIG ---
{"strictTrailingNewlines": true}
# --- IN ---
var a = 10
# --- OUT ---
var a = 10
# --- END ---
# anything I want goes here
# --- IN ---
var b = 'ten'
# --- OUT ---
var b = 'ten'

View File

@@ -4,7 +4,7 @@ import * as fs from "node:fs";
import * as vsctm from "vscode-textmate";
import * as oniguruma from "vscode-oniguruma";
import { keywords, symbols } from "./symbols";
import { get_configuration, get_extension_uri, createLogger } from "../utils";
import { get_configuration, get_extension_uri, createLogger, is_debug_mode } from "../utils";
const log = createLogger("formatter.tm");
@@ -50,10 +50,11 @@ interface Token {
param?: boolean;
string?: boolean;
skip?: boolean;
identifier?: boolean;
}
export interface FormatterOptions {
maxEmptyLines: 1 | 2;
maxEmptyLines: 0 | 1 | 2;
denseFunctionParameters: boolean;
}
@@ -73,6 +74,13 @@ function parse_token(token: Token) {
if (token.scopes.includes("meta.function.parameters.gdscript")) {
token.param = true;
}
if (token.scopes[0].includes("constant.numeric")) {
token.type = "literal";
return;
}
if (token.value.match(/[A-Za-z_]\w+/)) {
token.identifier = true;
}
if (token.scopes.includes("meta.literal.nodepath.gdscript")) {
token.skip = true;
token.type = "nodepath";
@@ -111,6 +119,10 @@ function parse_token(token: Token) {
token.type = "variable";
return;
}
if (token.scopes.includes("comment.line.number-sign.gdscript")) {
token.type = "comment";
return;
}
}
function between(tokens: Token[], current: number, options: FormatterOptions) {
@@ -128,12 +140,17 @@ function between(tokens: Token[], current: number, options: FormatterOptions) {
if (prevToken.skip && nextToken.skip) return "";
if (prev === "(") return "";
if (prev === ".") {
if (nextToken?.type === "symbol") return " ";
return "";
}
if (next === ".") return "";
if (nextToken.param) {
if (options.denseFunctionParameters) {
if (prev === "-") {
if (prev === "-" || prev === "+") {
if (tokens[current - 2]?.value === "=") return "";
if (["keyword", "symbol"].includes(tokens[current - 2].type)) {
if (["keyword", "symbol"].includes(tokens[current - 2]?.type)) {
return "";
}
if ([",", "("].includes(tokens[current - 2]?.value)) {
@@ -168,13 +185,16 @@ function between(tokens: Token[], current: number, options: FormatterOptions) {
}
if (prev === "@") return "";
if (prev === "-") {
if (["keyword", "symbol"].includes(tokens[current - 2].type)) {
if (prev === "-" || prev === "+") {
if (next === "(") return " ";
if (["keyword", "symbol"].includes(tokens[current - 2]?.type)) {
return "";
}
if ([",", "(", "["].includes(tokens[current - 2]?.value)) {
return "";
}
if (nextToken.identifier) return " ";
if (current === 1) return "";
}
if (prev === ":" && next === "=") return "";
@@ -232,6 +252,7 @@ export function format_document(document: TextDocument, _options?: FormatterOpti
const options = _options ?? get_formatter_options();
let lastToken = null;
let lineTokens: vsctm.ITokenizeLineResult = null;
let onlyEmptyLinesSoFar = true;
let emptyLineCount = 0;
@@ -259,7 +280,11 @@ export function format_document(document: TextDocument, _options?: FormatterOpti
// delete consecutive empty lines
if (emptyLineCount) {
for (let i = emptyLineCount - options.maxEmptyLines; i > 0; i--) {
let maxEmptyLines = options.maxEmptyLines;
if (lastToken === ":") {
maxEmptyLines = 0;
}
for (let i = emptyLineCount - maxEmptyLines; i > 0; i--) {
edits.push(TextEdit.delete(document.lineAt(lineNum - i).rangeIncludingLineBreak));
}
emptyLineCount = 0;
@@ -284,7 +309,7 @@ export function format_document(document: TextDocument, _options?: FormatterOpti
const tokens: Token[] = [];
for (const t of lineTokens.tokens) {
const token: Token = {
scopes: t.scopes,
scopes: [t.scopes.join(" "), ...t.scopes],
original: line.text.slice(t.startIndex, t.endIndex),
value: line.text.slice(t.startIndex, t.endIndex).trim(),
};
@@ -296,12 +321,19 @@ export function format_document(document: TextDocument, _options?: FormatterOpti
tokens.push(token);
}
for (let i = 0; i < tokens.length; i++) {
log.debug(i, tokens[i].value, tokens[i]);
if (i > 0 && tokens[i - 1].string === true && tokens[i].string === true) {
if (is_debug_mode()) log.debug(i, tokens[i].value, tokens[i]);
if (i === 0 && tokens[i].string) {
// leading whitespace is already accounted for
nextLine += tokens[i].original.trimStart();
} else if (i > 0 && tokens[i - 1].string && tokens[i].string) {
nextLine += tokens[i].original;
} else {
nextLine += between(tokens, i, options) + tokens[i].value.trim();
}
if (tokens[i].type !== "comment") {
lastToken = tokens[i].value;
}
}
edits.push(TextEdit.replace(line.range, nextLine));

View File

@@ -1,18 +1,19 @@
import * as vscode from "vscode";
import GDScriptLanguageClient, { ClientStatus, TargetLSP } from "./GDScriptLanguageClient";
import {
createLogger,
get_configuration,
get_free_port,
get_project_dir,
get_project_version,
set_context,
register_command,
set_configuration,
createLogger,
set_context,
verify_godot_version,
} from "../utils";
import { prompt_for_godot_executable, prompt_for_reload, select_godot_executable } from "../utils/prompts";
import { subProcess, killSubProcesses } from "../utils/subspawn";
import { killSubProcesses, subProcess } from "../utils/subspawn";
import GDScriptLanguageClient, { ClientStatus, TargetLSP } from "./GDScriptLanguageClient";
const log = createLogger("lsp.manager", { output: "Godot LSP" });
@@ -24,6 +25,7 @@ enum ManagerStatus {
DISCONNECTED = 4,
CONNECTED = 5,
RETRYING = 6,
WRONG_WORKSPACE = 7,
}
export class ClientConnectionManager {
@@ -38,10 +40,8 @@ export class ClientConnectionManager {
private connectedVersion = "";
constructor(private context: vscode.ExtensionContext) {
this.context = context;
this.client = new GDScriptLanguageClient(context);
this.client.watch_status(this.on_client_status_changed.bind(this));
this.client = new GDScriptLanguageClient();
this.client.events.on("status", this.on_client_status_changed.bind(this));
setInterval(() => {
this.retry_callback();
@@ -60,7 +60,7 @@ export class ClientConnectionManager {
this.start_language_server();
this.reconnectionAttempts = 0;
this.target = TargetLSP.HEADLESS;
this.client.connect_to_server(this.target);
this.client.connect(this.target);
}),
register_command("stopLanguageServer", this.stop_language_server.bind(this)),
register_command("checkStatus", this.on_status_item_click.bind(this)),
@@ -81,7 +81,7 @@ export class ClientConnectionManager {
}
this.reconnectionAttempts = 0;
this.client.connect_to_server(this.target);
this.client.connect(this.target);
}
private stop_language_server() {
@@ -212,6 +212,9 @@ export class ClientConnectionManager {
case ManagerStatus.RETRYING:
this.show_retrying_prompt();
break;
case ManagerStatus.WRONG_WORKSPACE:
this.retry_connect_client();
break;
}
}
@@ -254,6 +257,10 @@ export class ClientConnectionManager {
tooltip += `\n${this.connectedVersion}`;
}
break;
case ManagerStatus.WRONG_WORKSPACE:
text = "$(x) Wrong Project";
tooltip = "Disconnected from the GDScript language server.";
break;
}
this.statusWidget.text = text;
this.statusWidget.tooltip = tooltip;
@@ -269,8 +276,8 @@ export class ClientConnectionManager {
this.reconnectionAttempts = 0;
set_context("connectedToLSP", true);
this.status = ManagerStatus.CONNECTED;
if (!this.client.started) {
this.context.subscriptions.push(this.client.start());
if (this.client.needsStart()) {
this.client.start().then(() => log.info("LSP Client started"));
}
break;
case ClientStatus.DISCONNECTED:
@@ -286,6 +293,10 @@ export class ClientConnectionManager {
}
this.retry = true;
break;
case ClientStatus.REJECTED:
this.status = ManagerStatus.WRONG_WORKSPACE;
this.retry = false;
break;
default:
break;
}
@@ -305,7 +316,7 @@ export class ClientConnectionManager {
const maxAttempts = get_configuration("lsp.autoReconnect.attempts");
if (autoRetry && this.reconnectionAttempts <= maxAttempts - 1) {
this.reconnectionAttempts++;
this.client.connect_to_server(this.target);
this.client.connect(this.target);
this.retry = true;
return;
}

View File

@@ -1,21 +1,19 @@
import EventEmitter from "node:events";
import * as path from "node:path";
import * as vscode from "vscode";
import {
LanguageClient,
MessageSignature,
type LanguageClientOptions,
type NotificationMessage,
type RequestMessage,
type ResponseMessage,
type ServerOptions,
} from "vscode-languageclient/node";
import { EventEmitter } from "node:events";
import { get_configuration, createLogger } from "../utils";
import {
type Message,
type MessageIO,
MessageIOReader,
MessageIOWriter,
TCPMessageIO,
WebSocketMessageIO,
} from "./MessageIO";
import { globals } from "../extension";
import { createLogger, get_configuration, get_project_dir } from "../utils";
import { MessageIO } from "./MessageIO";
const log = createLogger("lsp.client", { output: "Godot LSP" });
@@ -23,6 +21,7 @@ export enum ClientStatus {
PENDING = 0,
DISCONNECTED = 1,
CONNECTED = 2,
REJECTED = 3,
}
export enum TargetLSP {
@@ -30,80 +29,87 @@ export enum TargetLSP {
EDITOR = 1,
}
const CUSTOM_MESSAGE = "gdscript_client/";
export type Target = {
host: string;
port: number;
type: TargetLSP;
};
type HoverResult = {
contents: {
kind: string;
value: string;
};
range: {
end: {
character: number;
line: number;
};
start: {
character: number;
line: number;
};
};
};
type HoverResponseMesssage = {
id: number;
jsonrpc: string;
result: HoverResult;
};
type ChangeWorkspaceNotification = {
method: string;
params: {
path: string;
};
};
export default class GDScriptLanguageClient extends LanguageClient {
public readonly io: MessageIO =
get_configuration("lsp.serverProtocol") === "ws" ? new WebSocketMessageIO() : new TCPMessageIO();
private _status_changed_callbacks: ((v: ClientStatus) => void)[] = [];
private _initialize_request: Message = null;
private messageHandler: MessageHandler = null;
public io: MessageIO = new MessageIO();
public target: TargetLSP = TargetLSP.EDITOR;
public port = -1;
public lastPortTried = -1;
public sentMessages = new Map();
public lastSymbolHovered = "";
private initMessage: RequestMessage;
private rejected = false;
private _started = false;
public get started(): boolean {
return this._started;
}
events = new EventEmitter();
private _status: ClientStatus;
public get status(): ClientStatus {
return this._status;
}
public set status(v: ClientStatus) {
if (this._status !== v) {
this._status = v;
for (const callback of this._status_changed_callbacks) {
callback(v);
}
}
this._status = v;
this.events.emit("status", this._status);
}
public watch_status(callback: (v: ClientStatus) => void) {
if (this._status_changed_callbacks.indexOf(callback) === -1) {
this._status_changed_callbacks.push(callback);
}
}
constructor() {
const serverOptions: ServerOptions = () => {
return new Promise((resolve, reject) => {
resolve({ reader: this.io.reader, writer: this.io.writer });
});
};
constructor(private context: vscode.ExtensionContext) {
super(
"GDScriptLanguageClient",
() => {
return new Promise((resolve, reject) => {
resolve({ reader: new MessageIOReader(this.io), writer: new MessageIOWriter(this.io) });
});
},
{
// Register the server for plain text documents
documentSelector: [
{ scheme: "file", language: "gdscript" },
{ scheme: "untitled", language: "gdscript" },
],
synchronize: {
// Notify the server about file changes to '.gd files contain in the workspace
fileEvents: vscode.workspace.createFileSystemWatcher("**/*.gd"),
},
},
);
const clientOptions: LanguageClientOptions = {
documentSelector: [
{ scheme: "file", language: "gdscript" },
{ scheme: "untitled", language: "gdscript" },
],
};
super("GDScriptLanguageClient", serverOptions, clientOptions);
this.status = ClientStatus.PENDING;
this.io.on("disconnected", this.on_disconnected.bind(this));
this.io.on("connected", this.on_connected.bind(this));
this.io.on("message", this.on_message.bind(this));
this.io.on("send_message", this.on_send_message.bind(this));
this.messageHandler = new MessageHandler(this.io);
this.io.on("disconnected", this.on_disconnected.bind(this));
this.io.requestFilter = this.request_filter.bind(this);
this.io.responseFilter = this.response_filter.bind(this);
this.io.notificationFilter = this.notification_filter.bind(this);
}
public async list_classes() {
await globals.docsProvider.list_native_classes();
}
connect_to_server(target: TargetLSP = TargetLSP.EDITOR) {
connect(target: TargetLSP = TargetLSP.EDITOR) {
this.rejected = false;
this.target = target;
this.status = ClientStatus.PENDING;
@@ -123,70 +129,123 @@ export default class GDScriptLanguageClient extends LanguageClient {
const host = get_configuration("lsp.serverHost");
log.info(`attempting to connect to LSP at ${host}:${port}`);
this.io.connect_to_language_server(host, port);
this.io.connect(host, port);
}
start() {
this._started = true;
return super.start();
}
private on_send_message(message: RequestMessage) {
this.sentMessages.set(message.id, message);
if (message.method === "initialize") {
this._initialize_request = message;
async send_request(method: string, params) {
try {
return this.sendRequest(method, params);
} catch {
log.warn("sending request failed!");
}
}
private on_message(message: ResponseMessage | NotificationMessage) {
const msgString = JSON.stringify(message);
handleFailedRequest<T>(
type: MessageSignature,
token: vscode.CancellationToken | undefined,
error: any,
defaultValue: T,
showNotification?: boolean,
): T {
if (type.method === "textDocument/documentSymbol") {
if (error.message.includes("selectionRange must be contained in fullRange")) {
log.warn(`Request failed for method "${type.method}", suppressing notification - see issue #820`);
return super.handleFailedRequest(type, token, error, defaultValue, false);
}
}
return super.handleFailedRequest(type, token, error, defaultValue, showNotification);
}
// This is a dirty hack to fix the language server sending us
// invalid file URIs
// This should be forward-compatible, meaning that it will work
// with the current broken version, AND the fixed future version.
const match = msgString.match(/"target":"file:\/\/[^\/][^"]*"/);
if (match) {
const count = (message["result"] as Array<object>).length;
for (let i = 0; i < count; i++) {
const x: string = message["result"][i]["target"];
message["result"][i]["target"] = x.replace("file://", "file:///");
private request_filter(message: RequestMessage) {
if (this.rejected) {
if (message.method === "shutdown") {
return message;
}
return false;
}
this.sentMessages.set(message.id, message);
if (!this.initMessage && message.method === "initialize") {
this.initMessage = message;
}
// discard outgoing messages that we know aren't supported
// if (message.method === "textDocument/didSave") {
// return false;
// }
// if (message.method === "textDocument/willSaveWaitUntil") {
// return false;
// }
if (message.method === "workspace/didChangeWatchedFiles") {
return false;
}
if (message.method === "workspace/symbol") {
return false;
}
return message;
}
private response_filter(message: ResponseMessage) {
const sentMessage = this.sentMessages.get(message.id);
if (sentMessage?.method === "textDocument/hover") {
// fix markdown contents
let value: string = (message as HoverResponseMesssage).result.contents.value;
if (value) {
// this is a dirty hack to fix language server sending us prerendered
// markdown but not correctly stripping leading #'s, leading to
// docstrings being displayed as titles
value = value.replace(/\n[#]+/g, "\n");
// fix bbcode line breaks
value = value.replaceAll("`br`", "\n\n");
// fix bbcode code boxes
value = value.replace("`codeblocks`", "");
value = value.replace("`/codeblocks`", "");
value = value.replace("`gdscript`", "\nGDScript:\n```gdscript");
value = value.replace("`/gdscript`", "```");
value = value.replace("`csharp`", "\nC#:\n```csharp");
value = value.replace("`/csharp`", "```");
(message as HoverResponseMesssage).result.contents.value = value;
}
}
if ("method" in message && message.method === "gdscript/capabilities") {
return message;
}
private async check_workspace(message: ChangeWorkspaceNotification) {
const server_path = path.normalize(message.params.path);
const client_path = path.normalize(await get_project_dir());
if (server_path !== client_path) {
log.warn("Connected LSP is a different workspace");
this.io.socket.resetAndDestroy();
this.rejected = true;
}
}
private notification_filter(message: NotificationMessage) {
if (message.method === "gdscript_client/changeWorkspace") {
this.check_workspace(message as ChangeWorkspaceNotification);
}
if (message.method === "gdscript/capabilities") {
globals.docsProvider.register_capabilities(message);
}
if ("id" in message) {
const sentMessage = this.sentMessages.get(message.id);
if (sentMessage && sentMessage.method === "textDocument/hover") {
// fix markdown contents
let value: string = message.result["contents"]?.value;
if (value) {
// this is a dirty hack to fix language server sending us prerendered
// markdown but not correctly stripping leading #'s, leading to
// docstrings being displayed as titles
value = value.replace(/\n[#]+/g, "\n");
// if (message.method === "textDocument/publishDiagnostics") {
// for (const diagnostic of message.params.diagnostics) {
// if (diagnostic.code === 6) {
// log.debug("UNUSED_SIGNAL", diagnostic);
// return;
// }
// if (diagnostic.code === 2) {
// log.debug("UNUSED_VARIABLE", diagnostic);
// return;
// }
// }
// }
// fix bbcode line breaks
value = value.replaceAll("`br`", "\n\n");
// fix bbcode code boxes
value = value.replace("`codeblocks`", "");
value = value.replace("`/codeblocks`", "");
value = value.replace("`gdscript`", "\nGDScript:\n```gdscript");
value = value.replace("`/gdscript`", "```");
value = value.replace("`csharp`", "\nC#:\n```csharp");
value = value.replace("`/csharp`", "```");
message.result["contents"].value = value;
}
}
}
this.messageHandler.on_message(message);
return message;
}
public async get_symbol_at_position(uri: vscode.Uri, position: vscode.Position) {
@@ -194,13 +253,12 @@ export default class GDScriptLanguageClient extends LanguageClient {
textDocument: { uri: uri.toString() },
position: { line: position.line, character: position.character },
};
const response = await this.sendRequest("textDocument/hover", params);
return this.parse_hover_response(response);
const response = await this.send_request("textDocument/hover", params);
return this.parse_hover_result(response as HoverResult);
}
private parse_hover_response(message) {
const contents = message["contents"];
private parse_hover_result(message: HoverResult) {
const contents = message.contents;
let decl: string;
if (Array.isArray(contents)) {
@@ -229,16 +287,21 @@ export default class GDScriptLanguageClient extends LanguageClient {
}
private on_connected() {
if (this._initialize_request) {
this.io.writer.write(this._initialize_request);
}
this.status = ClientStatus.CONNECTED;
const host = get_configuration("lsp.serverHost");
log.info(`connected to LSP at ${host}:${this.lastPortTried}`);
if (this.initMessage) {
this.send_request(this.initMessage.method, this.initMessage.params);
}
}
private on_disconnected() {
if (this.rejected) {
this.status = ClientStatus.REJECTED;
return;
}
if (this.target === TargetLSP.EDITOR) {
const host = get_configuration("lsp.serverHost");
let port = get_configuration("lsp.serverPort");
@@ -249,7 +312,7 @@ export default class GDScriptLanguageClient extends LanguageClient {
log.info(`attempting to connect to LSP at ${host}:${port}`);
this.lastPortTried = port;
this.io.connect_to_language_server(host, port);
this.io.connect(host, port);
return;
}
}
@@ -257,39 +320,3 @@ export default class GDScriptLanguageClient extends LanguageClient {
this.status = ClientStatus.DISCONNECTED;
}
}
class MessageHandler extends EventEmitter {
private io: MessageIO = null;
constructor(io: MessageIO) {
super();
this.io = io;
}
// changeWorkspace(params: { path: string }) {
// vscode.window.showErrorMessage("The GDScript language server can't work properly!\nThe open workspace is different from the editor's.", 'Reload', 'Ignore').then(item => {
// if (item == "Reload") {
// let folderUrl = vscode.Uri.file(params.path);
// vscode.commands.executeCommand('vscode.openFolder', folderUrl, false);
// }
// });
// }
on_message(message: any) {
// FIXME: Hot fix VSCode 1.42 hover position
if (message?.result?.range && message.result.contents) {
message.result.range = undefined;
}
// What does this do?
if (message?.method && (message.method as string).startsWith(CUSTOM_MESSAGE)) {
const method = (message.method as string).substring(CUSTOM_MESSAGE.length, message.method.length);
if (this[method]) {
const ret = this[method](message.params);
if (ret) {
this.io.writer.write(ret);
}
}
}
}
}

View File

@@ -3,21 +3,28 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
* ------------------------------------------------------------------------------------------ */
import { createLogger } from "../utils";
const log = createLogger("lsp.buf");
const DefaultSize: number = 8192;
const CR: number = Buffer.from("\r", "ascii")[0];
const LF: number = Buffer.from("\n", "ascii")[0];
const CRLF: string = "\r\n";
export default class MessageBuffer {
private encoding: BufferEncoding;
private index: number;
private buffer: Buffer;
type Headers = { [key: string]: string };
constructor(encoding = "utf8") {
this.encoding = encoding as BufferEncoding;
this.index = 0;
this.buffer = Buffer.allocUnsafe(DefaultSize);
}
export default class MessageBuffer {
private encoding: BufferEncoding = "utf8";
private index = 0;
private buffer: Buffer = Buffer.allocUnsafe(DefaultSize);
private nextMessageLength: number;
private messageToken: number;
private partialMessageTimer: NodeJS.Timeout | undefined;
private _partialMessageTimeout = 10000;
constructor(private reader) {}
public append(chunk: Buffer | string): void {
let toAppend: Buffer = <Buffer>chunk;
@@ -41,8 +48,7 @@ export default class MessageBuffer {
this.index += toAppend.length;
}
public tryReadHeaders(): { [key: string]: string } | undefined {
let result: { [key: string]: string } | undefined = undefined;
public tryReadHeaders(): Headers | undefined {
let current = 0;
while (
current + 3 < this.index &&
@@ -55,19 +61,19 @@ export default class MessageBuffer {
}
// No header / body separator found (e.g CRLFCRLF)
if (current + 3 >= this.index) {
return result;
return undefined;
}
result = Object.create(null);
const result = Object.create(null);
const headers = this.buffer.toString("ascii", 0, current).split(CRLF);
for (const header of headers) {
for (const header of headers) {
const index: number = header.indexOf(":");
if (index === -1) {
throw new Error("Message header must separate key and value using :");
}
const key = header.substr(0, index);
const value = header.substr(index + 1).trim();
result![key] = value;
}
result[key] = value;
}
const nextStart = current + 4;
this.buffer = this.buffer.slice(nextStart);
@@ -86,7 +92,66 @@ export default class MessageBuffer {
return result;
}
public get numberOfBytes(): number {
return this.index;
public ready() {
if (this.nextMessageLength === -1) {
const headers = this.tryReadHeaders();
if (!headers) {
return;
}
const contentLength = headers["Content-Length"];
if (!contentLength) {
log.warn("Header must provide a Content-Length property.");
return;
}
const length = Number.parseInt(contentLength);
if (Number.isNaN(length)) {
log.warn("Content-Length value must be a number.");
return;
}
this.nextMessageLength = length;
}
const msg = this.tryReadContent(this.nextMessageLength);
if (!msg) {
log.warn("haven't recieved full message");
this.setPartialMessageTimer();
return;
}
this.clearPartialMessageTimer();
this.nextMessageLength = -1;
this.messageToken++;
return msg;
}
public reset() {
this.nextMessageLength = -1;
this.messageToken = 0;
this.partialMessageTimer = undefined;
}
private clearPartialMessageTimer(): void {
if (this.partialMessageTimer) {
clearTimeout(this.partialMessageTimer);
this.partialMessageTimer = undefined;
}
}
private setPartialMessageTimer(): void {
this.clearPartialMessageTimer();
if (this._partialMessageTimeout <= 0) {
return;
}
this.partialMessageTimer = setTimeout(
(token, timeout) => {
this.partialMessageTimer = undefined;
if (token === this.messageToken) {
this.reader.firePartialMessage({ messageToken: token, waitingTime: timeout });
this.setPartialMessageTimer();
}
},
this._partialMessageTimeout,
this.messageToken,
this._partialMessageTimeout,
);
}
}

View File

@@ -10,7 +10,6 @@ import {
type MessageWriter,
} from "vscode-jsonrpc";
import { EventEmitter } from "node:events";
import { WebSocket, type Data } from "ws";
import { Socket } from "net";
import MessageBuffer from "./MessageBuffer";
import { createLogger } from "../utils";
@@ -20,252 +19,132 @@ const log = createLogger("lsp.io", { output: "Godot LSP" });
export type Message = RequestMessage | ResponseMessage | NotificationMessage;
export class MessageIO extends EventEmitter {
reader: MessageIOReader = null;
writer: MessageIOWriter = null;
reader = new MessageIOReader(this);
writer = new MessageIOWriter(this);
public send_message(message: string) {
// virtual
}
requestFilter: (msg: RequestMessage) => RequestMessage | false = (msg) => msg;
responseFilter: (msg: ResponseMessage) => ResponseMessage | false = (msg) => msg;
notificationFilter: (msg: NotificationMessage) => NotificationMessage | false = (msg) => msg;
protected on_message(chunk: Data) {
const message = chunk.toString();
this.emit("data", message);
}
socket: Socket = null;
messageCache: string[] = [];
on_send_message(message: any) {
this.emit("send_message", message);
}
on_message_callback(message: any) {
this.emit("message", message);
}
async connect_to_language_server(host: string, port: number): Promise<void> {
// virtual
}
}
export class WebSocketMessageIO extends MessageIO {
private socket: WebSocket = null;
public send_message(message: string) {
if (this.socket) {
this.socket.send(message);
}
}
async connect_to_language_server(host: string, port: number): Promise<void> {
async connect(host: string, port: number): Promise<void> {
log.debug(`connecting to ${host}:${port}`);
return new Promise((resolve, reject) => {
this.socket = null;
const ws = new WebSocket(`ws://${host}:${port}`);
ws.on("open", () => {
this.on_connected(ws);
resolve();
});
ws.on("message", this.on_message.bind(this));
ws.on("error", this.on_disconnected.bind(this));
ws.on("close", this.on_disconnected.bind(this));
});
}
protected on_connected(socket: WebSocket) {
this.socket = socket;
this.emit("connected");
}
protected on_disconnected() {
this.socket = null;
this.emit("disconnected");
}
}
export class TCPMessageIO extends MessageIO {
private socket: Socket = null;
public send_message(message: string) {
if (this.socket) {
this.socket.write(message);
}
}
async connect_to_language_server(host: string, port: number): Promise<void> {
return new Promise((resolve, reject) => {
this.socket = null;
const socket = new Socket();
socket.connect(port, host);
socket.on("connect", () => {
this.on_connected(socket);
this.socket = socket;
while (this.messageCache.length > 0) {
const msg = this.messageCache.shift();
this.socket.write(msg);
}
this.emit("connected");
resolve();
});
socket.on("data", this.on_message.bind(this));
socket.on("end", this.on_disconnected.bind(this));
socket.on("close", this.on_disconnected.bind(this));
socket.on("error", this.on_error.bind(this));
socket.on("data", (chunk: Buffer) => {
this.emit("data", chunk);
});
// socket.on("end", this.on_disconnected.bind(this));
socket.on("error", () => {
this.socket = null;
this.emit("disconnected");
});
socket.on("close", () => {
this.socket = null;
this.emit("disconnected");
});
});
}
protected on_connected(socket: Socket) {
this.socket = socket;
this.emit("connected");
}
protected on_disconnected() {
this.socket = null;
this.emit("disconnected");
}
protected on_error(error) {
// TODO: handle errors?
write(message: string) {
if (this.socket) {
this.socket.write(message);
} else {
this.messageCache.push(message);
}
}
}
export class MessageIOReader extends AbstractMessageReader implements MessageReader {
private io: MessageIO;
private callback: DataCallback;
private buffer: MessageBuffer;
private nextMessageLength: number;
private messageToken: number;
private partialMessageTimer: NodeJS.Timeout | undefined;
private _partialMessageTimeout: number;
callback: DataCallback;
private buffer = new MessageBuffer(this);
public constructor(io: MessageIO, encoding: BufferEncoding = "utf8") {
constructor(public io: MessageIO) {
super();
this.io = io;
this.io.reader = this;
this.buffer = new MessageBuffer(encoding);
this._partialMessageTimeout = 10000;
}
public set partialMessageTimeout(timeout: number) {
this._partialMessageTimeout = timeout;
}
listen(callback: DataCallback): Disposable {
this.buffer.reset();
public get partialMessageTimeout(): number {
return this._partialMessageTimeout;
}
public listen(callback: DataCallback): Disposable {
this.nextMessageLength = -1;
this.messageToken = 0;
this.partialMessageTimer = undefined;
this.callback = callback;
this.io.on("data", this.onData.bind(this));
this.io.on("data", this.on_data.bind(this));
this.io.on("error", this.fireError.bind(this));
this.io.on("close", this.fireClose.bind(this));
return;
}
private onData(data: Buffer | string): void {
private on_data(data: Buffer | string): void {
this.buffer.append(data);
while (true) {
if (this.nextMessageLength === -1) {
const headers = this.buffer.tryReadHeaders();
if (!headers) {
return;
}
const contentLength = headers["Content-Length"];
if (!contentLength) {
throw new Error("Header must provide a Content-Length property.");
}
const length = Number.parseInt(contentLength);
if (Number.isNaN(length)) {
throw new Error("Content-Length value must be a number.");
}
this.nextMessageLength = length;
// Take the encoding form the header. For compatibility
// treat both utf-8 and utf8 as node utf8
}
const msg = this.buffer.tryReadContent(this.nextMessageLength);
if (msg === null) {
/** We haven't received the full message yet. */
this.setPartialMessageTimer();
const msg = this.buffer.ready();
if (!msg) {
return;
}
this.clearPartialMessageTimer();
this.nextMessageLength = -1;
this.messageToken++;
const json = JSON.parse(msg);
// allow message to be modified
let modified: ResponseMessage | NotificationMessage | false;
if ("id" in json) {
modified = this.io.responseFilter(json);
} else if ("method" in json) {
modified = this.io.notificationFilter(json);
} else {
log.warn("rx [unhandled]:", json);
}
log.debug("rx:", json);
if (modified === false) {
log.debug("rx [discarded]:", json);
return;
}
log.debug("rx:", modified);
this.callback(json);
// callback
this.io.on_message_callback(json);
}
}
private clearPartialMessageTimer(): void {
if (this.partialMessageTimer) {
clearTimeout(this.partialMessageTimer);
this.partialMessageTimer = undefined;
}
}
private setPartialMessageTimer(): void {
this.clearPartialMessageTimer();
if (this._partialMessageTimeout <= 0) {
return;
}
this.partialMessageTimer = setTimeout(
(token, timeout) => {
this.partialMessageTimer = undefined;
if (token === this.messageToken) {
this.firePartialMessage({ messageToken: token, waitingTime: timeout });
this.setPartialMessageTimer();
}
},
this._partialMessageTimeout,
this.messageToken,
this._partialMessageTimeout,
);
}
}
const ContentLength: string = "Content-Length: ";
const CRLF = "\r\n";
export class MessageIOWriter extends AbstractMessageWriter implements MessageWriter {
private io: MessageIO;
private encoding: BufferEncoding;
private errorCount: number;
public constructor(io: MessageIO, encoding: BufferEncoding = "utf8") {
constructor(public io: MessageIO) {
super();
this.io = io;
this.io.writer = this;
this.encoding = encoding as BufferEncoding;
this.errorCount = 0;
this.io.on("error", (error: any) => this.fireError(error));
this.io.on("close", () => this.fireClose());
}
public end(): void {}
public write(msg: Message): Promise<void> {
// discard outgoing messages that we know aren't supported
if ((msg as RequestMessage).method === "didChangeWatchedFiles") {
async write(msg: RequestMessage) {
const modified = this.io.requestFilter(msg);
if (modified === false) {
log.debug("tx [discarded]:", msg);
return;
}
if ((msg as RequestMessage).method === "workspace/symbol") {
return;
}
const json = JSON.stringify(msg);
const contentLength = Buffer.byteLength(json, this.encoding);
log.debug("tx:", modified);
const json = JSON.stringify(modified);
const headers: string[] = [ContentLength, contentLength.toString(), CRLF, CRLF];
const contentLength = Buffer.byteLength(json, "utf-8").toString();
const message = `Content-Length: ${contentLength}\r\n\r\n${json}`;
try {
// callback
this.io.on_send_message(msg);
// Header must be written in ASCII encoding
this.io.send_message(headers.join(""));
// Now write the content. This can be written in any encoding
log.debug("tx:", msg);
this.io.send_message(json);
this.io.write(message);
this.errorCount = 0;
} catch (error) {
this.errorCount++;
this.fireError(error, msg, this.errorCount);
this.fireError(error, modified, this.errorCount);
}
return;
}
end(): void {}
}

View File

@@ -0,0 +1,123 @@
import * as path from "node:path";
import * as vscode from "vscode";
import {
CancellationToken,
DataTransfer,
DocumentDropEdit,
DocumentDropEditProvider,
ExtensionContext,
languages,
Position,
ProviderResult,
Range,
TextDocument,
Uri,
} from "vscode";
import { SceneParser } from "../scene_tools/parser";
import { createLogger, node_name_to_snake, get_project_version, convert_uri_to_resource_path } from "../utils";
import { SceneNode } from "../scene_tools/types";
const log = createLogger("providers.drops");
export class GDDocumentDropEditProvider implements DocumentDropEditProvider {
public parser = new SceneParser();
constructor(private context: ExtensionContext) {
const dropEditSelector = [
{ language: "csharp", scheme: "file" },
{ language: "gdscript", scheme: "file" },
];
context.subscriptions.push(languages.registerDocumentDropEditProvider(dropEditSelector, this));
}
public async provideDocumentDropEdits(
document: TextDocument,
position: Position,
dataTransfer: DataTransfer,
token: CancellationToken,
): Promise<DocumentDropEdit> {
// log.debug("provideDocumentDropEdits", document, dataTransfer);
const targetResPath = await convert_uri_to_resource_path(document.uri);
const originFsPath = dataTransfer.get("godot/scene").value;
const originUri = vscode.Uri.file(originFsPath);
const originDocument = await vscode.workspace.openTextDocument(originUri);
const scene = await this.parser.parse_scene(originDocument);
let scriptId = "";
for (const res of scene.externalResources.values()) {
if (res.path === targetResPath) {
scriptId = res.id;
break;
}
}
let nodePathOfTarget: SceneNode;
if (scriptId) {
const find_node = () => {
if (scene.root.scriptId === scriptId) {
return scene.root;
}
for (const node of scene.nodes.values()) {
if (node.scriptId === scriptId) {
return node;
}
}
};
nodePathOfTarget = find_node();
}
const className: string = dataTransfer.get("godot/class")?.value;
if (className) {
const nodePath: string = dataTransfer.get("godot/path")?.value;
let relativePath: string = dataTransfer.get("godot/relativePath")?.value;
const unique = dataTransfer.get("godot/unique")?.value === "true";
const label: string = dataTransfer.get("godot/label")?.value;
if (nodePathOfTarget) {
const targetPath = path.normalize(path.relative(nodePathOfTarget?.path, nodePath));
relativePath = targetPath.split(path.sep).join(path.posix.sep);
}
// For the root node, the path is empty and needs to be replaced with the node name
let savePath = relativePath || label;
if (document.languageId === "gdscript") {
if (savePath.startsWith(".")) {
savePath = `'${savePath}'`;
}
let qualifiedPath = `$${savePath}`;
if (unique) {
// For unique nodes, we can use the % syntax and drop the full path
qualifiedPath = `%${label}`;
}
const line = document.lineAt(position.line);
if (line.text === "") {
// We assume that if the user is dropping a node in an empty line, they are at the top of
// the script and want to declare an onready variable
const snippet = new vscode.SnippetString();
if ((await get_project_version())?.startsWith("4")) {
snippet.appendText("@");
}
snippet.appendText("onready var ");
snippet.appendPlaceholder(node_name_to_snake(label));
snippet.appendText(`: ${className} = ${qualifiedPath}`);
return new vscode.DocumentDropEdit(snippet);
}
// In any other place, we assume the user wants to get a reference to the node itself
return new vscode.DocumentDropEdit(qualifiedPath);
}
if (document.languageId === "csharp") {
return new vscode.DocumentDropEdit(`GetNode<${className}>("${savePath}")`);
}
}
}
}

View File

@@ -40,7 +40,7 @@ export class GDDocumentLinkProvider implements DocumentLinkProvider {
const uri = Uri.from({
scheme: "file",
path: path,
fragment: `${scene.externalResources[id].line},0`,
fragment: `${scene.externalResources.get(id).line},0`,
});
const r = this.create_range(document, match);
@@ -54,7 +54,7 @@ export class GDDocumentLinkProvider implements DocumentLinkProvider {
const uri = Uri.from({
scheme: "file",
path: path,
fragment: `${scene.subResources[id].line},0`,
fragment: `${scene.subResources.get(id).line},0`,
});
const r = this.create_range(document, match);

View File

@@ -14,7 +14,7 @@ import type {
GodotNativeSymbol,
GodotNativeClassInfo,
GodotCapabilities,
} from "../lsp/gdscript.capabilities";
} from "./documentation_types";
import { make_html_content } from "./documentation_builder";
import { createLogger, get_configuration, get_extension_uri, make_docs_uri } from "../utils";
import { globals } from "../extension";
@@ -103,7 +103,7 @@ export class GDDocumentationProvider implements CustomReadonlyEditorProvider {
symbol_name: className,
};
const response = await globals.lsp.client.sendRequest("textDocument/nativeSymbol", params);
const response = await globals.lsp.client.send_request("textDocument/nativeSymbol", params);
symbol = response as GodotNativeSymbol;
symbol.class_info = this.classInfo.get(symbol.name);
@@ -114,8 +114,17 @@ export class GDDocumentationProvider implements CustomReadonlyEditorProvider {
}
const scaleFactor = get_configuration("documentation.pageScale");
panel.webview.html = this.htmlDb.get(className).replaceAll("scaleFactor", scaleFactor);
const displayMinimap = get_configuration("documentation.displayMinimap");
if (displayMinimap) {
panel.webview.html = this.htmlDb.get(className).replace("displayMinimap", "initial;");
panel.webview.html = this.htmlDb.get(className).replace("bodyMargin", "200px;");
} else {
panel.webview.html = this.htmlDb.get(className).replace("bodyMargin", "0px;");
panel.webview.html = this.htmlDb.get(className).replace("displayMinimap", "none;");
}
panel.iconPath = get_extension_uri("resources/godot_icon.svg");
panel.webview.onDidReceiveMessage((msg) => {
if (msg.type === "INSPECT_NATIVE_SYMBOL") {

View File

@@ -3,7 +3,7 @@ import { SymbolKind } from "vscode-languageclient";
import * as Prism from "prismjs";
import * as csharp from "prismjs/components/prism-csharp";
import { marked } from "marked";
import type { GodotNativeSymbol } from "../lsp/gdscript.capabilities";
import type { GodotNativeSymbol } from "./documentation_types";
import { get_extension_uri } from "../utils";
import yabbcode = require("ya-bbcode");
@@ -78,12 +78,12 @@ export function make_html_content(webview: vscode.Webview, symbol: GodotNativeSy
<title>${symbol.name}</title>
</head>
<body style="line-height: scaleFactor%; font-size: scaleFactor%;">
<body style="line-height: scaleFactor%; font-size: scaleFactor%; margin-right: bodyMargin">
<main>
${make_symbol_document(symbol)}
</main>
<canvas id='minimap'></canvas>
<canvas id='minimap' style="display: displayMinimap"></canvas>
<script src="${pagemapJsUri}"></script>
<script>
@@ -128,7 +128,7 @@ export function make_symbol_document(symbol: GodotNativeSymbol): string {
const ret_type = make_link(parts[2] || "void", undefined);
let args = (parts[1] || "").replace(
/\:\s([A-z0-9_]+)(\,\s*)?/g,
": <a href=\"\" onclick=\"inspect('$1')\">$1</a>$2",
': <a href="" onclick="inspect(\'$1\')">$1</a>$2',
);
args = args.replace(/\s=\s(.*?)[\,\)]/g, "");
return `${ret_type} ${with_class ? `${classlink}.` : ""}${element("a", s.name, {
@@ -180,7 +180,7 @@ export function make_symbol_document(symbol: GodotNativeSymbol): string {
}
const args = (parts[2] || "").replace(
/\:\s([A-z0-9_]+)(\,\s*)?/g,
": <a href=\"\" onclick=\"inspect('$1')\">$1</a>$2",
': <a href="" onclick="inspect(\'$1\')">$1</a>$2',
);
const title = element(
"p",
@@ -233,28 +233,30 @@ export function make_symbol_document(symbol: GodotNativeSymbol): string {
let propertyies = "";
let others = "";
for (const s of symbol.children as GodotNativeSymbol[]) {
const elements = make_symbol_elements(s);
switch (s.kind) {
case SymbolKind.Property:
case SymbolKind.Variable:
properties_index += element("li", elements.index);
propertyies += element("li", elements.body, { id: s.name });
break;
case SymbolKind.Constant:
constants += element("li", elements.body, { id: s.name });
break;
case SymbolKind.Event:
signals += element("li", elements.body, { id: s.name });
break;
case SymbolKind.Method:
case SymbolKind.Function:
methods_index += element("li", elements.index);
methods += element("li", elements.body, { id: s.name });
break;
default:
others += element("li", elements.body, { id: s.name });
break;
if (symbol.children) {
for (const s of symbol.children as GodotNativeSymbol[]) {
const elements = make_symbol_elements(s);
switch (s.kind) {
case SymbolKind.Property:
case SymbolKind.Variable:
properties_index += element("li", elements.index);
propertyies += element("li", elements.body, { id: s.name });
break;
case SymbolKind.Constant:
constants += element("li", elements.body, { id: s.name });
break;
case SymbolKind.Event:
signals += element("li", elements.body, { id: s.name });
break;
case SymbolKind.Method:
case SymbolKind.Function:
methods_index += element("li", elements.index);
methods += element("li", elements.body, { id: s.name });
break;
default:
others += element("li", elements.body, { id: s.name });
break;
}
}
}
@@ -330,7 +332,7 @@ function format_documentation(bbcode: string, classname: string) {
let html = parser.parse(bbcode.trim());
html = html.replaceAll(/\[\/?codeblocks\](<br\/>)?/g, "");
html = html.replaceAll("&quot;", "\"");
html = html.replaceAll("&quot;", '"');
for (const match of html.matchAll(/\[codeblock].*?\[\/codeblock]/gs)) {
let block = match[0];

View File

@@ -49,8 +49,8 @@ export class GDHoverProvider implements HoverProvider {
if (word.startsWith("ExtResource")) {
const match = word.match(wordPattern);
const id = match[1];
const resource = scene.externalResources[id];
const definition = scene.externalResources[id].body;
const resource = scene.externalResources.get(id);
const definition = resource.body;
const links = await this.get_links(definition);
const contents = new MarkdownString();
@@ -77,7 +77,7 @@ export class GDHoverProvider implements HoverProvider {
const match = word.match(wordPattern);
const id = match[1];
let definition = scene.subResources[id].body;
let definition = scene.subResources.get(id).body;
// don't display contents of giant arrays
definition = definition?.replace(/Array\([0-9,\.\- ]*\)/, "Array(...)");

View File

@@ -1,8 +1,9 @@
export * from "./completions";
export * from "./definition";
export * from "./document_drops";
export * from "./document_link";
export * from "./documentation";
export * from "./hover";
export * from "./inlay_hints";
export * from "./semantic_tokens";
export * from "./documentation";
export * from "./tasks";

View File

@@ -27,7 +27,7 @@ function fromDetail(detail: string): string {
}
async function addByHover(document: TextDocument, hoverPosition: vscode.Position, start: vscode.Position): Promise<InlayHint | undefined> {
const response = await globals.lsp.client.sendRequest("textDocument/hover", {
const response = await globals.lsp.client.send_request("textDocument/hover", {
textDocument: { uri: document.uri.toString() },
position: {
line: hoverPosition.line,
@@ -65,10 +65,12 @@ export class GDInlayHintsProvider implements InlayHintsProvider {
if (!get_configuration("inlayHints.gdscript", true)) {
return hints;
}
if (!globals.lsp.client.isRunning()) {
return hints;
}
await globals.lsp.client.onReady();
const symbolsRequest = await globals.lsp.client.sendRequest("textDocument/documentSymbol", {
const symbolsRequest = await globals.lsp.client.send_request("textDocument/documentSymbol", {
textDocument: { uri: document.uri.toString() },
}) as unknown[];
@@ -126,7 +128,7 @@ export class GDInlayHintsProvider implements InlayHintsProvider {
for (const match of text.matchAll(/ExtResource\(\s?"?(\w+)\s?"?\)/g)) {
const id = match[1];
const end = document.positionAt(match.index + match[0].length);
const resource = scene.externalResources[id];
const resource = scene.externalResources.get(id);
const label = `${resource.type}: "${resource.path}"`;
@@ -138,7 +140,7 @@ export class GDInlayHintsProvider implements InlayHintsProvider {
for (const match of text.matchAll(/SubResource\(\s?"?(\w+)\s?"?\)/g)) {
const id = match[1];
const end = document.positionAt(match.index + match[0].length);
const resource = scene.subResources[id];
const resource = scene.subResources.get(id);
const label = `${resource.type}`;

View File

@@ -1,6 +1,6 @@
import * as fs from "node:fs";
import { basename, extname } from "node:path";
import { TextDocument, Uri } from "vscode";
import { basename, extname } from "path";
import * as fs from "fs";
import { SceneNode, Scene } from "./types";
import { createLogger } from "../utils";
@@ -46,7 +46,7 @@ export class SceneParser {
const uid = line.match(/uid="([\w:/]+)"/)?.[1];
const id = line.match(/ id="?([\w]+)"?/)?.[1];
scene.externalResources[id] = {
scene.externalResources.set(id, {
body: line,
path: path,
type: type,
@@ -54,7 +54,7 @@ export class SceneParser {
id: id,
index: match.index,
line: document.lineAt(document.positionAt(match.index)).lineNumber + 1,
};
});
}
let lastResource = null;
@@ -76,7 +76,7 @@ export class SceneParser {
lastResource.body = text.slice(lastResource.index, match.index).trimEnd();
}
scene.subResources[id] = resource;
scene.subResources.set(id, resource);
lastResource = resource;
}
@@ -87,9 +87,9 @@ export class SceneParser {
const nodeRegex = /\[node.*/g;
for (const match of text.matchAll(nodeRegex)) {
const line = match[0];
const name = line.match(/name="([\w]+)"/)?.[1];
const name = line.match(/name="([^.:@/"%]+)"/)?.[1];
const type = line.match(/type="([\w]+)"/)?.[1] ?? "PackedScene";
let parent = line.match(/parent="([\w\/.]+)"/)?.[1];
let parent = line.match(/parent="(([^.:@/"%]|[\/.])+)"/)?.[1];
const instance = line.match(/instance=ExtResource\(\s*"?([\w]+)"?\s*\)/)?.[1];
// leaving this in case we have a reason to use these node paths in the future
@@ -134,9 +134,10 @@ export class SceneParser {
scene.nodes.set(_path, node);
if (instance) {
if (instance in scene.externalResources) {
node.tooltip = scene.externalResources[instance].path;
node.resourcePath = scene.externalResources[instance].path;
const res = scene.externalResources.get(instance);
if (res) {
node.tooltip = res.path;
node.resourcePath = res.path;
if ([".tscn"].includes(extname(node.resourcePath))) {
node.contextValue += "openable";
}

View File

@@ -1,41 +1,36 @@
import * as fs from "node:fs";
import * as vscode from "vscode";
import {
type CancellationToken,
type Event,
EventEmitter,
type ExtensionContext,
type FileDecoration,
type ProviderResult,
type TreeDataProvider,
type TreeDragAndDropController,
type ExtensionContext,
EventEmitter,
type Event,
type TreeView,
type ProviderResult,
type TreeItem,
TreeItemCollapsibleState,
window,
languages,
type TreeView,
type Uri,
type CancellationToken,
type FileDecoration,
type DocumentDropEditProvider,
window,
workspace,
} from "vscode";
import * as fs from "node:fs";
import {
get_configuration,
find_file,
set_context,
convert_resource_path_to_uri,
register_command,
createLogger,
find_file,
get_configuration,
make_docs_uri,
node_name_to_snake,
register_command,
set_context,
} from "../utils";
import { SceneParser } from "./parser";
import type { SceneNode, Scene } from "./types";
import type { Scene, SceneNode } from "./types";
const log = createLogger("scenes.preview");
export class ScenePreviewProvider
implements TreeDataProvider<SceneNode>, TreeDragAndDropController<SceneNode>, DocumentDropEditProvider
{
export class ScenePreviewProvider implements TreeDataProvider<SceneNode>, TreeDragAndDropController<SceneNode> {
public dropMimeTypes = [];
public dragMimeTypes = [];
private tree: TreeView<SceneNode>;
@@ -58,10 +53,6 @@ export class ScenePreviewProvider
dragAndDropController: this,
});
const selector = [
{ language: "csharp", scheme: "file" },
{ language: "gdscript", scheme: "file" },
];
context.subscriptions.push(
register_command("scenePreview.lock", this.lock_preview.bind(this)),
register_command("scenePreview.unlock", this.unlock_preview.bind(this)),
@@ -70,19 +61,26 @@ export class ScenePreviewProvider
register_command("scenePreview.openScene", this.open_scene.bind(this)),
register_command("scenePreview.openScript", this.open_script.bind(this)),
register_command("scenePreview.openCurrentScene", this.open_current_scene.bind(this)),
register_command("scenePreview.openCurrentScript", this.open_main_script.bind(this)),
register_command("scenePreview.openMainScript", this.open_main_script.bind(this)),
register_command("scenePreview.goToDefinition", this.go_to_definition.bind(this)),
register_command("scenePreview.openDocumentation", this.open_documentation.bind(this)),
register_command("scenePreview.refresh", this.refresh.bind(this)),
window.onDidChangeActiveTextEditor(this.refresh.bind(this)),
window.onDidChangeActiveTextEditor(this.text_editor_changed.bind(this)),
window.registerFileDecorationProvider(this.uniqueDecorator),
window.registerFileDecorationProvider(this.scriptDecorator),
languages.registerDocumentDropEditProvider(selector, this),
this.watcher.onDidChange(this.on_file_changed.bind(this)),
this.watcher,
this.tree.onDidChangeSelection(this.tree_selection_changed),
this.tree,
);
const result: string | undefined = this.context.workspaceState.get("godotTools.scenePreview.lockedScene");
if (result) {
if (fs.existsSync(result)) {
set_context("scenePreview.locked", true);
this.scenePreviewLocked = true;
this.currentScene = result;
}
}
this.refresh();
}
@@ -92,59 +90,15 @@ export class ScenePreviewProvider
data: vscode.DataTransfer,
token: vscode.CancellationToken,
): void | Thenable<void> {
data.set("godot/path", new vscode.DataTransferItem(source[0].relativePath));
data.set("godot/scene", new vscode.DataTransferItem(this.currentScene));
data.set("godot/node", new vscode.DataTransferItem(source[0]));
data.set("godot/path", new vscode.DataTransferItem(source[0].path));
data.set("godot/relativePath", new vscode.DataTransferItem(source[0].relativePath));
data.set("godot/class", new vscode.DataTransferItem(source[0].className));
data.set("godot/unique", new vscode.DataTransferItem(source[0].unique));
data.set("godot/label", new vscode.DataTransferItem(source[0].label));
}
public provideDocumentDropEdits(
document: vscode.TextDocument,
position: vscode.Position,
dataTransfer: vscode.DataTransfer,
token: vscode.CancellationToken,
): vscode.ProviderResult<vscode.DocumentDropEdit> {
const path: string = dataTransfer.get("godot/path").value;
const className: string = dataTransfer.get("godot/class").value;
const line = document.lineAt(position.line);
const unique = dataTransfer.get("godot/unique").value === "true";
const label: string = dataTransfer.get("godot/label").value;
// TODO: compare the source scene to the target file
// What should happen when you drag a node into a script that isn't the
// "main" script for that scene?
// Attempt to calculate a relative path that resolves correctly?
if (className) {
// For the root node, the path is empty and needs to be replaced with the node name
const savePath = path || label;
if (document.languageId === "gdscript") {
let qualifiedPath = `$${savePath}`;
if (unique) {
// For unique nodes, we can use the % syntax and drop the full path
qualifiedPath = `%${label}`;
}
if (line.text === "") {
// We assume that if the user is dropping a node in an empty line, they are at the top of
// the script and want to declare an onready variable
return new vscode.DocumentDropEdit(
`@onready var ${node_name_to_snake(label)}: ${className} = ${qualifiedPath}`,
);
}
// In any other place, we assume the user wants to get a reference to the node itself
return new vscode.DocumentDropEdit(qualifiedPath);
}
if (document.languageId === "csharp") {
return new vscode.DocumentDropEdit(`GetNode<${className}>("${savePath}")`);
}
}
}
public async on_file_changed(uri: vscode.Uri) {
if (!uri.fsPath.endsWith(".tscn")) {
return;
@@ -159,11 +113,10 @@ export class ScenePreviewProvider
}, 20);
}
public async refresh() {
public async text_editor_changed() {
if (this.scenePreviewLocked) {
return;
}
const editor = vscode.window.activeTextEditor;
if (editor) {
let fileName = editor.document.uri.fsPath;
@@ -196,24 +149,34 @@ export class ScenePreviewProvider
return;
}
const document = await vscode.workspace.openTextDocument(fileName);
this.scene = this.parser.parse_scene(document);
this.tree.message = this.scene.title;
this.currentScene = fileName;
this.changeTreeEvent.fire();
this.refresh();
}
}
public async refresh() {
if (!fs.existsSync(this.currentScene)) {
return;
}
const document = await vscode.workspace.openTextDocument(this.currentScene);
this.scene = this.parser.parse_scene(document);
this.tree.message = this.scene.title;
this.changeTreeEvent.fire();
}
private lock_preview() {
this.scenePreviewLocked = true;
set_context("scenePreview.locked", true);
this.context.workspaceState.update("godotTools.scenePreview.lockedScene", this.currentScene);
}
private unlock_preview() {
this.scenePreviewLocked = false;
set_context("scenePreview.locked", false);
this.context.workspaceState.update("godotTools.scenePreview.lockedScene", "");
this.refresh();
}
@@ -237,7 +200,7 @@ export class ScenePreviewProvider
}
private async open_script(item: SceneNode) {
const path = this.scene.externalResources[item.scriptId].path;
const path = this.scene.externalResources.get(item.scriptId).path;
const uri = await convert_resource_path_to_uri(path);
if (uri) {
@@ -256,7 +219,7 @@ export class ScenePreviewProvider
if (this.currentScene) {
const root = this.scene.root;
if (root?.hasScript) {
const path = this.scene.externalResources[root.scriptId].path;
const path = this.scene.externalResources.get(root.scriptId).path;
const uri = await convert_resource_path_to_uri(path);
if (uri) {
vscode.window.showTextDocument(uri, { preview: true });

View File

@@ -2,6 +2,7 @@ import {
TreeItem,
TreeItemCollapsibleState,
MarkdownString,
Uri
} from "vscode";
import * as path from "path";
import { get_extension_uri } from "../utils";
@@ -31,8 +32,8 @@ export class SceneNode extends TreeItem {
const iconName = className + ".svg";
this.iconPath = {
light: path.join(iconDir, "light", iconName),
dark: path.join(iconDir, "dark", iconName),
light: Uri.file(path.join(iconDir, "light", iconName)),
dark: Uri.file(path.join(iconDir, "dark", iconName)),
};
}
@@ -52,7 +53,7 @@ export class SceneNode extends TreeItem {
this.scriptId = line.match(/script = ExtResource\(\s*"?([\w]+)"?\s*\)/)[1];
this.contextValue += "hasScript";
}
if (line != "") {
if (line !== "") {
newLines.push(line);
}
}
@@ -78,7 +79,7 @@ export class Scene {
public title: string;
public mtime: number;
public root: SceneNode | undefined;
public externalResources: {[key: string]: GDResource} = {};
public subResources: {[key: string]: GDResource} = {};
public externalResources: Map<string, GDResource> = new Map();
public subResources: Map<string, GDResource> = new Map();
public nodes: Map<string, SceneNode> = new Map();
}

View File

@@ -4,6 +4,17 @@ import * as fs from "node:fs";
import * as os from "node:os";
import { execSync } from "node:child_process";
export function get_editor_data_dir(): string {
// from: https://stackoverflow.com/a/26227660
const appdata =
process.env.APPDATA ||
(process.platform === "darwin"
? `${process.env.HOME}/Library/Preferences`
: `${process.env.HOME}/.local/share`);
return path.join(appdata, "Godot");
}
let projectDir: string | undefined = undefined;
let projectFile: string | undefined = undefined;
@@ -33,10 +44,10 @@ export async function get_project_dir(): Promise<string | undefined> {
}
projectFile = file;
projectDir = path.dirname(file);
if (os.platform() === "win32") {
// capitalize the drive letter in windows absolute paths
projectDir = projectDir[0].toUpperCase() + projectDir.slice(1);
}
if (os.platform() === "win32") {
// capitalize the drive letter in windows absolute paths
projectDir = projectDir[0].toUpperCase() + projectDir.slice(1);
}
return projectDir;
}
@@ -105,8 +116,19 @@ export async function convert_resource_path_to_uri(resPath: string): Promise<vsc
return vscode.Uri.joinPath(vscode.Uri.file(dir), resPath.substring("res://".length));
}
type VERIFY_STATUS = "SUCCESS" | "WRONG_VERSION" | "INVALID_EXE";
type VERIFY_RESULT = {
export async function convert_uri_to_resource_path(uri: vscode.Uri): Promise<string | null> {
const project_dir = path.dirname(find_project_file(uri.fsPath));
if (project_dir === null) {
return;
}
let relative_path = path.normalize(path.relative(project_dir, uri.fsPath));
relative_path = relative_path.split(path.sep).join(path.posix.sep);
return `res://${relative_path}`;
}
export type VERIFY_STATUS = "SUCCESS" | "WRONG_VERSION" | "INVALID_EXE";
export type VERIFY_RESULT = {
status: VERIFY_STATUS;
godotPath: string;
version?: string;

View File

@@ -1,10 +1,10 @@
import * as vscode from "vscode";
import * as path from "path";
import * as fs from "fs";
import { AddressInfo, createServer } from "net";
import * as fs from "node:fs";
import * as path from "node:path";
import * as vscode from "vscode";
export * from "./logger";
export * from "./project_utils";
export * from "./godot_utils";
export * from "./settings_updater";
export * from "./vscode_utils";
@@ -21,12 +21,12 @@ export async function find_file(file: string): Promise<vscode.Uri | null> {
if (results.length === 1) {
return results[0];
}
return null;
}
export async function get_free_port(): Promise<number> {
return new Promise(res => {
return new Promise((res) => {
const srv = createServer();
srv.listen(0, () => {
const port = (srv.address() as AddressInfo).port;
@@ -45,7 +45,7 @@ export function make_docs_uri(path: string, fragment?: string) {
/**
* Can be used to convert a conventional node name to a snake_case variable name.
*
*
* @example
* ```ts
* nodeNameToVar("MyNode") // my_node
@@ -54,10 +54,39 @@ export function make_docs_uri(path: string, fragment?: string) {
* ```
*/
export function node_name_to_snake(name: string): string {
const snakeCase: string = name.replace(/([a-z])([A-Z0-9])/g, "$1_$2").toLowerCase();
if (snakeCase.startsWith("_")) {
return snakeCase.substring(1);
}
return snakeCase;
const snakeCase: string = name.replace(/([a-z])([A-Z0-9])/g, "$1_$2").toLowerCase();
if (snakeCase.startsWith("_")) {
return snakeCase.substring(1);
}
return snakeCase;
}
export const ansi = {
reset: "\u001b[0;37m",
red: "\u001b[0;31m",
green: "\u001b[0;32m",
yellow: "\u001b[0;33m",
blue: "\u001b[0;34m",
purple: "\u001b[0;35m",
cyan: "\u001b[0;36m",
white: "\u001b[0;37m",
bright: {
red: "\u001b[1;31m",
green: "\u001b[1;32m",
yellow: "\u001b[1;33m",
blue: "\u001b[1;34m",
purple: "\u001b[1;35m",
cyan: "\u001b[1;36m",
white: "\u001b[1;37m",
},
dim: {
red: "\u001b[1;2;31m",
green: "\u001b[1;2;32m",
yellow: "\u001b[1;2;33m",
blue: "\u001b[1;2;34m",
purple: "\u001b[1;2;35m",
cyan: "\u001b[1;2;36m",
white: "\u001b[1;2;37m",
},
} as const;

View File

@@ -35,8 +35,8 @@
},
"expression": {
"patterns": [
{ "include": "#base_expression" },
{ "include": "#getter_setter_godot4" },
{ "include": "#base_expression" },
{ "include": "#assignment_operator" },
{ "include": "#annotations" },
{ "include": "#class_name" },
@@ -74,6 +74,7 @@
{ "include": "#square_braces" },
{ "include": "#round_braces" },
{ "include": "#function_call" },
{ "include": "#region"},
{ "include": "#comment" },
{ "include": "#self" },
{ "include": "#func" },
@@ -83,6 +84,10 @@
{ "include": "#line_continuation" }
]
},
"region": {
"match": "#(end)?region.*$\\n?",
"name": "keyword.language.region.gdscript"
},
"comment": {
"match": "(##|#).*$\\n?",
"name": "comment.line.number-sign.gdscript",
@@ -170,7 +175,7 @@
}
]
},
{ "include": "#base_expression" }
{ "include": "#expression" }
]
},
"self": {
@@ -229,7 +234,7 @@
"name": "keyword.operator.comparison.gdscript"
},
"arithmetic_operator": {
"match": "->|\\+=|-=|\\*=|\\^=|/=|%=|&=|~=|\\|=|\\*\\*|\\*|/|%|\\+|-",
"match": "->|\\+=|-=|\\*\\*=|\\*=|\\^=|/=|%=|&=|~=|\\|=|\\*\\*|\\*|/|%|\\+|-",
"name": "keyword.operator.arithmetic.gdscript"
},
"assignment_operator": {
@@ -245,7 +250,7 @@
"captures": { "1": { "name": "keyword.control.gdscript" } }
},
"keywords": {
"match": "\\b(?:class|class_name|is|onready|tool|static|export|as|void|enum|assert|breakpoint|sync|remote|master|puppet|slave|remotesync|mastersync|puppetsync|trait|namespace)\\b",
"match": "\\b(?:class|class_name|abstract|is|onready|tool|static|export|as|void|enum|assert|breakpoint|sync|remote|master|puppet|slave|remotesync|mastersync|puppetsync|trait|namespace|super)\\b",
"name": "keyword.language.gdscript"
},
"letter": {
@@ -263,15 +268,19 @@
"name": "constant.numeric.integer.hexadecimal.gdscript"
},
{
"match": "[-]?([0-9][0-9_]+\\.[0-9_]*(e[\\-\\+]?[0-9_]+)?)",
"match": "\\.[0-9][0-9_]*([eE][+-]?[0-9_]+)?",
"name": "constant.numeric.float.gdscript"
},
{
"match": "[-]?(\\.[0-9][0-9_]*(e[\\-\\+]?[0-9_]+)?)",
"match": "([0-9][0-9_]*)\\.[0-9_]*([eE][+-]?[0-9_]+)?",
"name": "constant.numeric.float.gdscript"
},
{
"match": "[-]?([0-9][0-9_]*e[\\-\\+]?\\[0-9_])",
"match": "([0-9][0-9_]*)?\\.[0-9_]*([eE][+-]?[0-9_]+)",
"name": "constant.numeric.float.gdscript"
},
{
"match": "[0-9][0-9_]*[eE][+-]?[0-9_]+",
"name": "constant.numeric.float.gdscript"
},
{
@@ -293,7 +302,7 @@
"match": "(:)?\\s*(set|get)\\s+=\\s+([a-zA-Z_]\\w*)",
"captures": {
"1": { "name": "punctuation.separator.annotation.gdscript" },
"2": { "name": "keyword.language.gdscript storage.type.const.gdscript" },
"2": { "name": "entity.name.function.gdscript" },
"3": { "name": "entity.name.function.gdscript" }
}
},
@@ -311,7 +320,7 @@
{
"match": "(setget)\\s+([a-zA-Z_]\\w*)(?:[,]\\s*([a-zA-Z_]\\w*))?",
"captures": {
"1": { "name": "keyword.language.gdscript storage.type.const.gdscript" },
"1": { "name": "keyword.language.gdscript" },
"2": { "name": "entity.name.function.gdscript" },
"3": { "name": "entity.name.function.gdscript" }
}
@@ -326,18 +335,23 @@
"getter_setter_godot4": {
"patterns": [
{
"match": "\\b(get):",
"captures": { "1": { "name": "entity.name.function.gdscript" } }
"name": "meta.variable.declaration.getter.gdscript",
"match": "(get)\\s*(:)",
"captures": {
"1": { "name": "entity.name.function.gdscript" },
"2": { "name": "punctuation.separator.annotation.gdscript" }
}
},
{
"name": "meta.function.gdscript",
"begin": "(?x) \\s+\n (set) \\s*\n (?=\\()",
"end": "(:|(?=[#'\"\\n]))",
"beginCaptures": { "1": { "name": "entity.name.function.gdscript" } },
"patterns": [
{ "include": "#parameters" },
{ "include": "#line_continuation" }
]
"name": "meta.variable.declaration.setter.gdscript",
"match": "(set)\\s*(\\()\\s*([A-Za-z_]\\w*)\\s*(\\))\\s*(:)",
"captures": {
"1": { "name": "entity.name.function.gdscript" },
"2": { "name": "punctuation.definition.arguments.begin.gdscript" },
"3": { "name": "variable.other.gdscript" },
"4": { "name": "punctuation.definition.arguments.end.gdscript" },
"5": { "name": "punctuation.separator.annotation.gdscript" }
}
}
]
},
@@ -367,7 +381,7 @@
"match": "\\b([A-Z][a-zA-Z_0-9]*)\\.([A-Z_0-9]+)",
"captures": {
"1": { "name": "entity.name.type.class.gdscript" },
"2": { "name": "constant.language.gdscript" }
"2": { "name": "variable.other.enummember.gdscript" }
}
},
"class_name": {
@@ -386,7 +400,7 @@
},
"builtin_get_node_shorthand_quoted": {
"name": "string.quoted.gdscript meta.literal.nodepath.gdscript constant.character.escape.gdscript",
"begin": "(?:(\\$)|(&|\\^|@))(\"|')",
"begin": "(?:(\\$|%)|(&|\\^|@))(\"|')",
"beginCaptures": {
"1": { "name": "keyword.control.flow.gdscript" },
"2": { "name": "variable.other.enummember.gdscript" }
@@ -430,19 +444,19 @@
]
},
"annotations": {
"match": "(@)(export|export_color_no_alpha|export_custom|export_dir|export_enum|export_exp_easing|export_file|export_flags|export_flags_2d_navigation|export_flags_2d_physics|export_flags_2d_render|export_flags_3d_navigation|export_flags_3d_physics|export_flags_3d_render|export_global_dir|export_global_file|export_multiline|export_node_path|export_placeholder|export_range|export_storage|icon|onready|rpc|tool|warning_ignore|abstract)\\b",
"match": "(@)(export|export_group|export_color_no_alpha|export_custom|export_dir|export_enum|export_exp_easing|export_file|export_flags|export_flags_2d_navigation|export_flags_2d_physics|export_flags_2d_render|export_flags_3d_navigation|export_flags_3d_physics|export_flags_3d_render|export_global_dir|export_global_file|export_multiline|export_node_path|export_placeholder|export_range|export_storage|icon|onready|rpc|tool|warning_ignore|static_unload)\\b",
"captures": {
"1": { "name": "entity.name.function.decorator.gdscript" },
"2": { "name": "entity.name.function.decorator.gdscript" }
}
},
"builtin_classes": {
"match": "(?<![^.]\\.|:)\\b(OS|GDScript|Vector2|Vector2i|Vector3|Vector3i|Color|Rect2|Rect2i|Array|Basis|Dictionary|Plane|Quat|RID|Rect3|Transform|Transform2D|Transform3D|AABB|String|Color|NodePath|Object|PoolByteArray|PoolIntArray|PoolRealArray|PoolStringArray|PoolVector2Array|PoolVector3Array|PoolColorArray|bool|int|float|StringName|Quaternion|PackedByteArray|PackedInt32Array|PackedInt64Array|PackedFloat32Array|PackedFloat64Array|PackedStringArray|PackedVector2Array|PackedVector2iArray|PackedVector3Array|PackedVector3iArray|PackedColorArray|super)\\b",
"match": "(?<![^.]\\.|:)\\b(Vector2|Vector2i|Vector3|Vector3i|Vector4|Vector4i|Color|Rect2|Rect2i|Array|Basis|Dictionary|Plane|Quat|RID|Rect3|Transform|Transform2D|Transform3D|AABB|String|Color|NodePath|PoolByteArray|PoolIntArray|PoolRealArray|PoolStringArray|PoolVector2Array|PoolVector3Array|PoolColorArray|bool|int|float|Signal|Callable|StringName|Quaternion|Projection|PackedByteArray|PackedInt32Array|PackedInt64Array|PackedFloat32Array|PackedFloat64Array|PackedStringArray|PackedVector2Array|PackedVector2iArray|PackedVector3Array|PackedVector3iArray|PackedVector4Array|PackedColorArray|JSON|UPNP|OS|IP|JSONRPC|XRVRS)\\b",
"name": "entity.name.type.class.builtin.gdscript"
},
"const_vars": {
"match": "\\b([A-Z_][A-Z_0-9]*)\\b",
"name": "constant.language.gdscript"
"name": "variable.other.constant.gdscript"
},
"pascal_case_class": {
"match": "\\b([A-Z]+[a-z_0-9]*([A-Z]?[a-z_0-9]+)*[A-Z]?)\\b",
@@ -498,13 +512,8 @@
"1": { "name": "keyword.language.gdscript storage.type.function.gdscript" },
"2": { "name": "entity.name.function.gdscript" }
},
"end": "(:|(?=[#'\"\\n]))",
"end2": "(\\s*(\\-\\>)\\s*(void\\w*)|([a-zA-Z_]\\w*)\\s*\\:)",
"endCaptures2": {
"1": { "name": "punctuation.separator.annotation.result.gdscript" },
"2": { "name": "keyword.language.void.gdscript" },
"3": { "name": "entity.name.type.class.gdscript markup.italic" }
},
"end": "(:)",
"endCaptures": { "1": { "name": "punctuation.section.function.begin.gdscript" } },
"patterns": [
{ "include": "#parameters" },
{ "include": "#line_continuation" },
@@ -535,7 +544,7 @@
"end": "(,)|(?=\\))",
"beginCaptures": { "1": { "name": "keyword.operator.gdscript" } },
"endCaptures": { "1": { "name": "punctuation.separator.parameters.gdscript" } },
"patterns": [ { "include": "#base_expression" } ]
"patterns": [ { "include": "#expression" } ]
},
"annotated_parameter": {
"begin": "(?x)\n \\s* ([a-zA-Z_]\\w*) \\s* (:)\\s* ([a-zA-Z_]\\w*)? \n",
@@ -547,7 +556,7 @@
"end": "(,)|(?=\\))",
"endCaptures": { "1": { "name": "punctuation.separator.parameters.gdscript" } },
"patterns": [
{ "include": "#base_expression" },
{ "include": "#expression" },
{
"name": "keyword.operator.assignment.gdscript",
"match": "=(?!=)"

View File

@@ -0,0 +1,40 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "GDScript: Launch ScopeVars.tscn",
"type": "godot",
"request": "launch",
"project": "${workspaceFolder}",
"scene": "ScopeVars.tscn"
// "debug_collisions": false,
// "debug_paths": false,
// "debug_navigation": false,
// "additional_options": ""
},
{
"name": "GDScript: Launch ExtensiveVars.tscn",
"type": "godot",
"request": "launch",
"project": "${workspaceFolder}",
"scene": "ExtensiveVars.tscn"
},
{
"name": "GDScript: Launch BuiltInTypes.tscn",
"type": "godot",
"request": "launch",
"project": "${workspaceFolder}",
"scene": "BuiltInTypes.tscn"
},
{
"name": "GDScript: Launch NodeVars.tscn",
"type": "godot",
"request": "launch",
"project": "${workspaceFolder}",
"scene": "NodeVars.tscn"
}
]
}

View File

@@ -0,0 +1,42 @@
extends Node
signal member_signal
signal member_signal_with_parameters(my_param1: String)
# Called when the node enters the scene tree for the first time.
func _ready() -> void:
var int_var = 42
var float_var = 3.14
var bool_var = true
var string_var = "Hello, Godot!"
var nil_var = null
var vector2 = Vector2(10, 20)
var vector3 = Vector3(1, 2, 3)
var rect2 = Rect2(0, 0, 100, 50)
var quaternion = Quaternion(0, 0, 0, 1)
var simple_array = [1, 2, 3]
var nested_dict = {
"nested_key": "Nested Value",
"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
var aabb_var = AABB(Vector3(0, 0, 0), Vector3(1, 1, 1))
var plane_var = Plane(Vector3(0, 1, 0), -5)
var callable_var = self.my_callable_func
var signal_var = member_signal
member_signal.connect(singal_connected_func)
print("breakpoint::BuiltInTypes::_ready")
func my_callable_func():
pass
func singal_connected_func():
pass

View File

@@ -0,0 +1 @@
uid://bl7k8rh4vgbma

View File

@@ -0,0 +1,11 @@
[gd_scene load_steps=2 format=3 uid="uid://d0ovhv6f38jj4"]
[ext_resource type="Script" path="res://BuiltInTypes.gd" id="1_2dpge"]
[node name="BuiltInTypes" type="Node"]
script = ExtResource("1_2dpge")
[node name="Label" type="Label" parent="."]
offset_right = 40.0
offset_bottom = 23.0
text = "Built-in types"

View File

@@ -0,0 +1,61 @@
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
func _ready() -> void:
var local_label := label
var local_self_var_through_label := label.parent_var
var local_classA = ClassA.new()
var local_classB = ClassB.new()
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
# var dict = {}
# dict["self_ref"] = dict
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_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

View File

@@ -0,0 +1 @@
uid://jj6y8lb0lkij

View File

@@ -0,0 +1,12 @@
[gd_scene load_steps=3 format=3 uid="uid://bsonfthpqa3dx"]
[ext_resource type="Script" path="res://ExtensiveVars.gd" id="1_fnilr"]
[ext_resource type="Script" path="res://ExtensiveVars_Label.gd" id="2_jijf2"]
[node name="ExtensiveVars" type="Node2D"]
script = ExtResource("1_fnilr")
[node name="Label" type="Label" parent="."]
text = "Extensive Vars scene"
script = ExtResource("2_jijf2")
metadata/_edit_use_anchors_ = true

View File

@@ -0,0 +1,14 @@
extends Label
class_name ExtensiveVars_Label
@onready var parent_var: Node2D = $".."
# Called when the node enters the scene tree for the first time.
func _ready() -> void:
pass # Replace with function body.
# Called every frame. 'delta' is the elapsed time since the previous frame.
func _process(delta: float) -> void:
pass

View File

@@ -0,0 +1 @@
uid://ca1f5tmqgm6hu

View File

@@ -0,0 +1,3 @@
extends Node
var globalMember := "global member"

View File

@@ -0,0 +1 @@
uid://c4ypojhmiyhhf

View File

@@ -0,0 +1,8 @@
extends Node
@onready var parent_node: Node2D = $".."
@onready var sibling_node2: Node = $"../node2"
# Called when the node enters the scene tree for the first time.
func _ready() -> void:
pass # Replace with function body.

View File

@@ -0,0 +1 @@
uid://bxlldk7s267hd

View File

@@ -0,0 +1,10 @@
extends Node2D
@onready var node_1: Node = $node1
@onready var node_2: Node = $node2
# 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

View File

@@ -0,0 +1 @@
uid://ciokiqoyaox13

View File

@@ -0,0 +1,17 @@
[gd_scene load_steps=3 format=3 uid="uid://xrjtth0d2nc5"]
[ext_resource type="Script" path="res://NodeVars.gd" id="1_6eeca"]
[ext_resource type="Script" path="res://Node1.gd" id="2_bl41t"]
[node name="NodeVars" type="Node2D"]
script = ExtResource("1_6eeca")
[node name="node1" type="Node" parent="."]
script = ExtResource("2_bl41t")
[node name="node2" type="Node" parent="."]
[node name="Label" type="Label" parent="."]
offset_right = 40.0
offset_bottom = 23.0
text = "NodeVars"

View File

@@ -0,0 +1,26 @@
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 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)

View File

@@ -0,0 +1 @@
uid://cbgugy44s0uia

View File

@@ -0,0 +1,11 @@
[gd_scene load_steps=2 format=3 uid="uid://g5gqewj2i2xs"]
[ext_resource type="Script" path="res://ScopeVars.gd" id="1_wtcpp"]
[node name="RootNode" type="Node"]
script = ExtResource("1_wtcpp")
[node name="Label" type="Label" parent="."]
offset_right = 40.0
offset_bottom = 23.0
text = "Godot test project"

View File

@@ -0,0 +1,5 @@
class_name TestClassA
var testclassa_member1 := "member1"
var testclassa_member2: Node

View File

@@ -0,0 +1 @@
uid://ct5jeingo4ge

View File

@@ -0,0 +1,19 @@
; Engine configuration file.
; It's best edited using the editor UI and not directly,
; since the parameters that go here are not all obvious.
;
; Format:
; [section] ; section goes between []
; param=value ; assign values to parameters
config_version=5
[application]
config/name="Test DAP project godot4"
run/main_scene="res://ScopeVars.tscn"
config/features=PackedStringArray("4.3", "Forward Plus")
[autoload]
GlobalScript="*res://GlobalScript.gd"

View File

@@ -14,7 +14,8 @@
"rootDir": "src",
"strict": false,
"skipLibCheck": true,
"allowJs": true
"allowJs": true,
"strictBindCallApply": true
},
"exclude": [
"node_modules",