Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[LSP] Add textDocument/references & prepareRename, Enhancements for resolving symbols #79988

Closed
wants to merge 59 commits into from

Conversation

Booksbaum
Copy link
Contributor

Started as just adding support for textDocument/references (godotengine/godot-proposals#3687) -- but led to a bunch of fixes & enhancements regarding what symbol is under the cursor/at a certain location (-> better results for: where are usages of a certain symbol). Which improves results for other LSP methods too.

Note: with the exception of one small addition to ./modules/gdscript/gdscript_editor.cpp, all changes are isolated to just LSP (inside ./modules/gdscript/language_server/)

  • Fix Implement lsp-references support in the GDScript language server godot-proposals#3687: Add: textDocument/references ("Find All References" in VSCode):
    find_references_class
    • works with native things too:
      find_references_vector2
  • Enhancement for textDocument/rename: prevent renaming of native or external symbols
  • Add: textDocument/prepareRename
    • -> if rename at location not valid, VSCode prevents renaming (-> no Rename popup to change name)
      • prev:
        rename_int_old
      • now:
        rename_int_new
  • Enhancement for rename/references: Don't search through other script files when symbol is local to current file
  • Fix: renameing a parameter at usage renames function instead of parameter
  • Enhancement: textDocument/documentSymbol now returns DocumentSymbols instead of SymbolInformations
    (SymbolInformation is marked as deprecated in LSP specs)
    • -> Enhancement: In VSCode Outline (and breadcrumb above source): Hierarchical symbols instead of flat list:
      • prev:
        outline_old
        (hierarchy is auto-created by VSCode)
      • now:
        outline_new
  • Enhancement: In textDocument/documentSymbol: return only public symbols, not local ones (like parameters or variables inside a func)
    • see images above. For example:
      • prev: all variables inside do_stuff were listed (value, desc)
      • now: no local variables
  • Fix: Unrelated tooltip on punctuations, parens, operators
    • prev:
      tooltip_colon_old
      -> some unrelated fallback
    • now:
      tooltip_colon_new
      -> no tooltip
      (another example: see images in next fix)
  • Fix/Enhancement: Commands now recognize identifier when cursor at end of identifier
    (mentioned in LSP rename not working correctly #65563 (comment))
    (Cursor is between r and : )
    • prev:
      tooltip_end_identifier_old
    • now:
      tooltip_end_identifier_new
      • Note: That's great with line-cursor, but a bit strange for block-cursor or mouse: As shown in example above (block-cursor): the character after identifier now "belongs" to identifier.
        That's because LSP uses cursor position that are BETWEEN characters, while block-cursor (and mouse) is in practice on a certain character/column.
  • Fix: Incorrect column/character (Positions & Ranges) when line contains tabs
  • Enhancement/Fix: incorrect, imprecise or missing ranges and selection ranges (in DocumentSymbol -> ranges VSCode uses for selection and cursor movement)
  • Fix: Symbol is resolved to first declaration of symbol with same name
    • prev:
      hover_variable_first_old
      -> shows tooltip for int value in other func, but hover is on string value
    • now:
      hover_variable_first_new
  • Fix Rename symbol sometimes does not work properly godot-vscode-plugin#448: Rename sometimes does not work properly

  • And in general several Fixes and Enhancements regarding resolve_symbol (what is under the cursor, where is it defined).
    The notable fixes (or the ones I remember, or are easy to showcase) are already mentioned above. This here just as extra bullet point to provide some general Notes:
    • Note: with one small exception these fixes are all just for LSP GDScriptWorkspace::resolve_symbol, NOT GDScriptLanguage::lookup_code (-> for Godot editor too). LSP already had couple of special handlings for things lookup_code failed or didn't return all needed data (columns) and I expanded on these.
      Long-term solution and proper fixes would probably be to handle all things in lookup_code (and remove special cases from LSP) -- but also more difficult and might need changes in related classes (like GDScriptParser) too.
      But this also means: I just added or expanded special handling for "simple" cases and just for LSP.
    • Note: there are some cases not handled by LSP, or even issues introduced by LSP (as far as I know already existed prior to this PR).
      For example:
      • words inside strings & comments get resolved as symbols:
        var value = 42
        # comment containing value
        -> value in comments gets resolved to usage of property symbol
        -> results in: tooltip for hover, Find References returns occurrence in comment too, rename renames value in comment too.
        • issue is in LSP: if lookup_code returns nothing, it tries to find member with same name as word under cursor. Often right, but here it's wrong
      • shadowed variable inside a variable initializer (rhs) doesn't always get resolved to correct variable:
        var value = 42
        func f():
          var value = value + 42
          #           ^^^^^
        -> usage of value gets resolve to value defined in same line instead of member value
        • I think issue is in GDScriptLanguage::lookup_code: doesn't consider rhs as part of outer suite
      • cannot resolve Element of Named Enum
        enum Named { Alpha, Beta }
        var v := Named.Alpha
        -> no tooltip or GoTo Definition for Alpha
        • I think issue is in GDScriptLanguage::lookup_code (-> fails to resolve, didn't look closer)


  • Add some tests for LSP (test suite: [Modules][GDScript][LSP])
    • Note: Not a complete cover of LSP methods -- just some of the things I wanted reproducible tests (resolve_symbol & converting Positions between LSP & Godot)
    • Note: To check symbols I use comment annotations in gdscript examples (see scripts in ./modules/gdscript/tests/lsp_project/) ... which led to A LOT of tests ... which take some time: LSP tests are slower than gdscript tests:
      > Measure-Command { .\bin\godot.windows.editor.dev.x86_64.console.exe --test --test-suite="[Modules][GDScript]" | Out-Default }
      
      [...]
      [doctest] test cases:   1 |   1 passed | 0 failed | 799 skipped
      [doctest] assertions: 437 | 437 passed | 0 failed |
      [doctest] Status: SUCCESS!
      [...]
      TotalSeconds      : 3.2028532
      
      
      
      > Measure-Command { .\bin\godot.windows.editor.dev.x86_64.console.exe --test --test-suite="[Modules][GDScript][LSP]" | Out-Default }
      
      [...]
      [doctest] test cases:     2 |     2 passed | 0 failed | 798 skipped
      [doctest] assertions: 55742 | 55742 passed | 0 failed |
      [doctest] Status: SUCCESS!
      [...]
      TotalSeconds      : 19.4215479
      If that's too many checks/too slow I can reduce them.
    • Note: some tests are uncommented (in gdscript: with #!). These are some of the not handled cases mentioned above.

Booksbaum added 30 commits July 28, 2023 17:06
is obsolete

Instead `textDocument/resolveSymbols` now returns `DocumentSymbol`.  
Practical difference in VSCode: in breadcrumb navigation bar at top of source file:
Instead of a flat list of all elements its now a hierarchical tree.
Note: just stub -- doesn't return anything yet
Note: `includeDeclaration` isn't handled yet (decl is always included)

Add `find_usages_in_file` & `find_all_usages`
Enhancement: Don't look through other files when renaming local symbol
Example:
* `a.gd`:
  ```gdscript
  extends Node

  var member = 42
  ```
* `b.gd`:
  ```gdscript
  extends Node

  const A = preload("res://a.gd")
  func test(a: A) -> void:
    var v = a.member
  ```

-> `Find all references` (or `rename`) on `member` inside:
* `a.gd` -> only find `member` inside `a.gd`
* `b.gd` -> doesn't find any references

Issue was: variable was always treated as local
Example:
```gdscript
func do_stuff(arg: int) -> int:
  arg += 2
  return arg
```
-> `resolve_symbol` on `arg` (in `arg += 2`) returns `do_stuff` (the function), NOT `arg` (the parameter)

Reason: `resolve_symbol` searches for the first symbol in the definition line -- which is the function, NOT the parameter.

Effects of this fix:
* Tooltip for `arg` usage now correctly displays parameter info instead of function info
* `Go to definition` now locates parameter instead of function
* `Find all references` now finds references of parameter instead of references of function
* `Rename` now renames parameter instead of function
* **Regression**: resolves to first symbol declaration with that name. If there's another variable/parameter with same name defined before in script it resolves to that symbol. This issue was already present before with things other than parameters, but this commit "introduces" the issue to parameter.
  * Example:
    ```gdscript
    func a(arg: int):
      pass
    func b(arg: int):
      arg += 1
    ```
    -> `arg` (in `arg += 1`) resolve to `arg` in `a`
  * Reason: prev: parameter was resolved to its function. Now its correctly resolved and as result: handled like variable.

Note: I now require the definition symbol to have the same name as usage name. (In example above: symbol must be named `arg`).
A better solution would be to adjust `GDScriptLanguage::lookup_code` to return not just the line number (`location`), but also the column (or even better: complete range -- or maybe even node?). But that's out of scope of this PR. And additionally: would require deeper changes (`lookup_code` & `LookupResult` isn't just local to GDScript, but for all `ScriptLanguage`s).
prevents renaming of invalid rename locations, like keywords or builtins

Effect in VSCode: rename box doesn't open when rename not valid.
prev: return `defaultBehavior: true`

Reason for change:
`defaultBehavior` doesn't work with current `godot-vscode-plugin`:
Uses old `vscode-languageclient` -- and updating requires significant more changes than just updating the package.
-> current `godot-vscode-plugin` (Version `1.3.1`) works with `range`, but not `defaultBehavior`

Fix/Enhancement: `ExtendGDScriptParser::get_identifier_under_position` returns incorrect or unclear offset
Though I don't think that had any real consequences -- might also just me misunderstanding how exactly it should be used.
To better document and clarify: `get_identifier_under_position` now returns a `lsp::Range` which is:
* zero based
* start: inclusive
* end: exclusive

Example:
```gdscript
[...]
var member := 2
```
Hover on `b` in `member`: (pos: `{ line: 3, character: 7}`)
* prev: offset (`Vector2i`): `(-4, 2)` -> range in `line: 3`: from `char: 3` to `char: 9`
  * `char: 3` exclusive? or 1-based & inclusive?
  * `char: 9`: inclusive? or 1-based & exclusive?
* now: LSP range `{ "start": { "line": 3, "character": 4 }, "end": { "line": 3, "character": 10 } }`
  * `char: 4`: inclusive
  * `char 10`: exclusive
… uses cursor position (instead of character position)

Consequence: Commands now recognize identifier when cursor at end of identifier

Example: (`|`: cursor, `^`: position)
```gdscript
var member| := my_func|(my_var|)
```
* prev: position was used to determine identifier
        -> no valid identifier under position, but instead space or parens
        -> rename didn't work
* now: cursor is BETWEEN chars 
       -> position is adjusted when at end of identifier
       -> rename does work



Note:  
Commands via mouse on character AFTER identifier (`^` in example above) now apply to identifier. That's just how LSP work: no difference between cursor (between chars) & mouse interaction (on char). `Position` in LSP is specified as "between two character" -- which is what this commit does.

Other extensions show same behaviour. For example: `rust-analyzer` allows commands on characters after identifier too.  
Though not all extensions: `C/C++` extension allows rename & find references on char after identifier, but doesn't show hover.
Same check as in `textDocument/prepareRename`
run with `--test --test-suite="[Modules][GDScript][LSP]"`

Note: Currently doesn't pass

Note: doesn't cover much. For further testing. Currently focus on `resolve_symbol`
Ranges are now directly marked inside gdscript.
Every Range can be annotated with a name and a ref

Rewrite LSP tests from prev commit to use inline markers
Note: still failing: range of symbol spans more than just identifier
There are two ranges:
* `range`: covers the complete node
* `selectionRange`: covers just the identifier

Note: Fixes some tests, but not all:
`resolve_symbol` for `test`s is still failing.
I think because GDScript AST doesn't keep tabs as characters but converts them to columns?
Godot expands `\t` with `text_editor/behavior/indent/size`, while in LSP `\t` is just a single character.
-> Requires conversion between LSP and Godot

Note: currently only parsing to `DocumentSymbols`s converts Godot to LSP.
That should cover most cases -- especially `resolve_symbol`.  
But there might be still some unhandled cases -> not yet checked.

Note: requires more tests -- currently only simple test with single tab
Addendum to prev commit message:
prev commit seems to cover all cases which require conversion between LSP and Godot.
Currently there seem to be only two main bridges between GDScriptParser & ExtendGDScriptParser:
* parsing in ExtendGDScriptParser -> transforms GDScript AST (Godot Positions) into `DocumentSymbol` (LSP positions). That's then used inside `resolve_symbol` (-> LSP positions)
* `GDScriptLanguage::get_text_for_completion` && `lookup_code` -> take source code with cursor marked in string instead of extra passed position -> no explicit conversion necessary
in `textDocument/resolveSymbol`.
VSCode shows result as breadcrumbs & navigation at top of source.
With local `DocumentSymbol`: includes parameters & variables local to `func`s
-> quite crowded & distracting clutter
-> only emit public `DocumentSymbol`s (classes, variables, signals, functions, ...)

(same behaviour as in other LSP implementations)
Handled here:
* in getter/setter
* in lambdas inside functions
* parameters in signals
* in different scopes
* inside match
* when script contains multiple variables with same name

Though not all cases are fixed. For example:
* lambdas in fields
* match cases
* shadowed variables  
  Though: shadowing is discouraged in gdscript
Merge lambdas with parent variable
@AThousandShips
Copy link
Member

In the future please do not make multiple commits in such rapid succession, currently it won't fire CI because you're a first time contributor but in the future it will

@Booksbaum
Copy link
Contributor Author

Ok, reading files fails.

(path="res//local_variables.gd")
Why does FileAccess::get_file_as_string(path, &err) fail? Project should be initialized to correct directory (root = "modules/gdscript/tests/lsp_project/")

And additional: why does it work locally?


difference I found: Godot Project Settings root path isn't with trailing / (while gdscript tests use trailing /) ... but internal GDScript uses only its DirAccess etc. ... so shouldn't make any difference.

@dalexeev
Copy link
Member

@Booksbaum If the status of this PR is Work In Progress / Early Preview, then you can mark it as draft for now, to make it more obvious to contributors. And mark it as ready for review later, when you decide that the moment has come. Thanks!

@Booksbaum
Copy link
Contributor Author

Booksbaum commented Jul 28, 2023

Error is: ProjectSettings::resource_path is still from gdscript tests. ProjectSettings::setup doesn't change resource_path when called again (and maybe even more state remains?)
-> lsp tests still try to load resource from gdscript tests

Which explains why it worked locally: I either always filtered to test-suite [Modules][GDScript] or [Modules][GDScript][LSP] -- but never both together ([Modules][GDScript]* -> fails)

But ProjectSettings doesn't have a method to reset its state ... hmpf...
In here:

Error ProjectSettings::_setup(const String &p_path, const String &p_main_pack, bool p_upwards, bool p_ignore_override) {
if (!OS::get_singleton()->get_resource_dir().is_empty()) {
// OS will call ProjectSettings->get_resource_path which will be empty if not overridden!
// If the OS would rather use a specific location, then it will not be empty.
resource_path = OS::get_singleton()->get_resource_dir().replace("\\", "/");
if (!resource_path.is_empty() && resource_path[resource_path.length() - 1] == '/') {
resource_path = resource_path.substr(0, resource_path.length() - 1); // Chop end.
}
}
// Attempt with a user-defined main pack first
if (!p_main_pack.is_empty()) {
bool ok = _load_resource_pack(p_main_pack);
ERR_FAIL_COND_V_MSG(!ok, ERR_CANT_OPEN, "Cannot open resource pack '" + p_main_pack + "'.");
Error err = _load_settings_text_or_binary("res://project.godot", "res://project.binary");
if (err == OK && !p_ignore_override) {
// Load override from location of the main pack
// Optional, we don't mind if it fails
_load_settings_text(p_main_pack.get_base_dir().path_join("override.cfg"));
}
return err;
}
String exec_path = OS::get_singleton()->get_executable_path();
if (!exec_path.is_empty()) {
// We do several tests sequentially until one succeeds to find a PCK,
// and if so, we attempt loading it at the end.
// Attempt with PCK bundled into executable.
bool found = _load_resource_pack(exec_path);
// Attempt with exec_name.pck.
// (This is the usual case when distributing a Godot game.)
String exec_dir = exec_path.get_base_dir();
String exec_filename = exec_path.get_file();
String exec_basename = exec_filename.get_basename();
// Based on the OS, it can be the exec path + '.pck' (Linux w/o extension, macOS in .app bundle)
// or the exec path's basename + '.pck' (Windows).
// We need to test both possibilities as extensions for Linux binaries are optional
// (so both 'mygame.bin' and 'mygame' should be able to find 'mygame.pck').
#ifdef MACOS_ENABLED
if (!found) {
// Attempt to load PCK from macOS .app bundle resources.
found = _load_resource_pack(OS::get_singleton()->get_bundle_resource_dir().path_join(exec_basename + ".pck")) || _load_resource_pack(OS::get_singleton()->get_bundle_resource_dir().path_join(exec_filename + ".pck"));
}
#endif
if (!found) {
// Try to load data pack at the location of the executable.
// As mentioned above, we have two potential names to attempt.
found = _load_resource_pack(exec_dir.path_join(exec_basename + ".pck")) || _load_resource_pack(exec_dir.path_join(exec_filename + ".pck"));
}
if (!found) {
// If we couldn't find them next to the executable, we attempt
// the current working directory. Same story, two tests.
found = _load_resource_pack(exec_basename + ".pck") || _load_resource_pack(exec_filename + ".pck");
}
// If we opened our package, try and load our project.
if (found) {
Error err = _load_settings_text_or_binary("res://project.godot", "res://project.binary");
if (err == OK && !p_ignore_override) {
// Load overrides from the PCK and the executable location.
// Optional, we don't mind if either fails.
_load_settings_text("res://override.cfg");
_load_settings_text(exec_path.get_base_dir().path_join("override.cfg"));
}
return err;
}
}
// Try to use the filesystem for files, according to OS.
// (Only Android -when reading from pck- and iOS use this.)
if (!OS::get_singleton()->get_resource_dir().is_empty()) {
Error err = _load_settings_text_or_binary("res://project.godot", "res://project.binary");
if (err == OK && !p_ignore_override) {
// Optional, we don't mind if it fails.
_load_settings_text("res://override.cfg");
}
return err;
}
// Nothing was found, try to find a project file in provided path (`p_path`)
// or, if requested (`p_upwards`) in parent directories.
Ref<DirAccess> d = DirAccess::create(DirAccess::ACCESS_FILESYSTEM);
ERR_FAIL_COND_V_MSG(d.is_null(), ERR_CANT_CREATE, "Cannot create DirAccess for path '" + p_path + "'.");
d->change_dir(p_path);
String current_dir = d->get_current_dir();
bool found = false;
Error err;
while (true) {
// Set the resource path early so things can be resolved when loading.
resource_path = current_dir;
resource_path = resource_path.replace("\\", "/"); // Windows path to Unix path just in case.
err = _load_settings_text_or_binary(current_dir.path_join("project.godot"), current_dir.path_join("project.binary"));
if (err == OK && !p_ignore_override) {
// Optional, we don't mind if it fails.
_load_settings_text(current_dir.path_join("override.cfg"));
found = true;
break;
}
if (p_upwards) {
// Try to load settings ascending through parent directories
d->change_dir("..");
if (d->get_current_dir() == current_dir) {
break; // not doing anything useful
}
current_dir = d->get_current_dir();
} else {
break;
}
}
if (!found) {
return err;
}
if (resource_path.length() && resource_path[resource_path.length() - 1] == '/') {
resource_path = resource_path.substr(0, resource_path.length() - 1); // Chop end.
}
return OK;
}
-> two OS::get_singleton()->get_resource_dir().is_empty() checks which use old resource_path.

Simple fix seems to be manually reset resource_path. Then tests work (at least local). Is it ok to expose resource_path for test builds (or a reset method only available for test builds ... though other state remains untouched :/ )?

Edit: just changing resource_path isn't enough. (In fact: there's even another test that requires resource_path from GDScript tests...)






If the status of this PR is Work In Progress / Early Preview, then you can mark it as draft for now, to make it more obvious to contributors. And mark it as ready for review later, when you decide that the moment has come. Thanks!

Wasn't planned as such :D ..... :(

But marked as WIP for now -> enough for today

@Booksbaum Booksbaum marked this pull request as draft July 28, 2023 19:10
Note:
Running `[Modules][GDScript]` and `[Modules][GDScript][LSP]` together (`[Modules][GDScript]*`) fails because it cannot load resources of the later test.
Issue: `ProjectSettings::resource_path` doesn't get reset inside `ProjectSettings::setup` (`_setup`) -- but instead if `resource_path` is already set it's reused -> LSP tests use `resource_path` of GDScript tests -> loading LSP resources fails
Issue was:
`ProjectSettings` cannot be reset, but uses old state (especially `resource_path`) when `setup` gets called.
-> Cannot change workspace (from `modules/gdscript/tests/scripts/` to `module/gdscript/tests/lsp/`)
-> Loading resources (script files) in LSP tests fails (because tries to read from old gdscript folder)

LSP scripts are now inside GDScript tests project, and GDScript tests & LSP tests now share a common scripts directory.
LSP scripts are inside `lsp` sub-directory.
Required for LSP server
@Booksbaum
Copy link
Contributor Author

Tests should now run.

I moved LSP test scripts into same workspace as other GDScript test scripts -> both test suites use same ProjectSettings state.

@Booksbaum Booksbaum marked this pull request as ready for review July 29, 2023 17:10
@adamscott adamscott self-requested a review July 29, 2023 17:19
@Booksbaum
Copy link
Contributor Author

Yay...Unit tests succeeded

But:

ranlib: /home/runner/work/godot/godot/godot-cpp/bin/libgodot-cpp.linux.template_debug.dev.x86_64.a: No space left on device

in Build godot-cpp test extension
Though seems to be a general CI run error and not directly caused by this PR? I don't think this PR adds so much to waste extreme amount of disk space...

@AThousandShips
Copy link
Member

Yes unrelated to this PR

@ryanabx
Copy link
Contributor

ryanabx commented Aug 24, 2023

I'd like to take this on and fix the unsuccessful check if possible.

Yay...Unit tests succeeded

But:

ranlib: /home/runner/work/godot/godot/godot-cpp/bin/libgodot-cpp.linux.template_debug.dev.x86_64.a: No space left on device

in Build godot-cpp test extension Though seems to be a general CI run error and not directly caused by this PR? I don't think this PR adds so much to waste extreme amount of disk space...

Yes unrelated to this PR

Was this ever fixed? Is this the only blocker to getting this approved?

@AThousandShips
Copy link
Member

This has been fixed and this needs a rebase to include the fix

@ryanabx
Copy link
Contributor

ryanabx commented Aug 25, 2023

This has been fixed and this needs a rebase to include the fix

Rebase finished! Had to fix some references to class variables that were moved to other places. The workflow checks passed on my fork, and I created a PR: #80973

@Booksbaum
Copy link
Contributor Author

superseeded by #80973

@Booksbaum Booksbaum closed this Sep 11, 2023
@YuriSizov YuriSizov removed this from the 4.x milestone Sep 11, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
5 participants