From 4cc88e5175b642e5bf3afdbfc2a7c584734a9d33 Mon Sep 17 00:00:00 2001 From: HolonProduction Date: Wed, 25 Jun 2025 14:43:48 +0200 Subject: [PATCH] LSP: Rework management of client owned files --- modules/SCsub | 1 + .../gdscript_extend_parser.cpp | 14 +- .../language_server/gdscript_extend_parser.h | 3 +- .../gdscript_language_protocol.cpp | 192 +++++++++++++++- .../gdscript_language_protocol.h | 48 +++- .../gdscript_text_document.cpp | 58 ++--- .../language_server/gdscript_text_document.h | 2 - .../language_server/gdscript_workspace.cpp | 207 ++++-------------- .../language_server/gdscript_workspace.h | 26 ++- modules/gdscript/language_server/godot_lsp.h | 36 ++- modules/gdscript/tests/test_lsp.h | 18 +- 11 files changed, 346 insertions(+), 259 deletions(-) diff --git a/modules/SCsub b/modules/SCsub index 69dfadfa311..e987dde0e5c 100644 --- a/modules/SCsub +++ b/modules/SCsub @@ -52,6 +52,7 @@ for name, path in env.module_list.items(): # Generate header to be included in `tests/test_main.cpp` to run module-specific tests. if env["tests"]: + env.Append(CPPDEFINES=["TESTS_ENABLED"]) env.CommandNoCache("modules_tests.gen.h", test_headers, env.Run(modules_builders.modules_tests_builder)) # libmodules.a with only register_module_types. diff --git a/modules/gdscript/language_server/gdscript_extend_parser.cpp b/modules/gdscript/language_server/gdscript_extend_parser.cpp index 8780eb0fcc7..ed664aa45ae 100644 --- a/modules/gdscript/language_server/gdscript_extend_parser.cpp +++ b/modules/gdscript/language_server/gdscript_extend_parser.cpp @@ -344,8 +344,9 @@ void ExtendGDScriptParser::parse_class_symbol(const GDScriptParser::ClassNode *p if (res.is_valid() && !res->get_path().is_empty()) { value_text = "preload(\"" + res->get_path() + "\")"; if (symbol.documentation.is_empty()) { - if (HashMap::Iterator S = GDScriptLanguageProtocol::get_singleton()->get_workspace()->scripts.find(res->get_path())) { - symbol.documentation = S->value->class_symbol.documentation; + ExtendGDScriptParser *parser = GDScriptLanguageProtocol::get_singleton()->get_parse_result(res->get_path()); + if (parser) { + symbol.documentation = parser->class_symbol.documentation; } } } else { @@ -1038,18 +1039,17 @@ Dictionary ExtendGDScriptParser::generate_api() const { return api; } -Error ExtendGDScriptParser::parse(const String &p_code, const String &p_path) { +void ExtendGDScriptParser::parse(const String &p_code, const String &p_path) { path = p_path; lines = p_code.split("\n"); - Error err = GDScriptParser::parse(p_code, p_path, false); + parse_result = GDScriptParser::parse(p_code, p_path, false); GDScriptAnalyzer analyzer(this); - if (err == OK) { - err = analyzer.analyze(); + if (parse_result == OK) { + parse_result = analyzer.analyze(); } update_diagnostics(); update_symbols(); update_document_links(p_code); - return err; } diff --git a/modules/gdscript/language_server/gdscript_extend_parser.h b/modules/gdscript/language_server/gdscript_extend_parser.h index e7f6e41e1ce..cbc797649f6 100644 --- a/modules/gdscript/language_server/gdscript_extend_parser.h +++ b/modules/gdscript/language_server/gdscript_extend_parser.h @@ -143,6 +143,7 @@ public: _FORCE_INLINE_ const Vector &get_diagnostics() const { return diagnostics; } _FORCE_INLINE_ const ClassMembers &get_members() const { return members; } _FORCE_INLINE_ const HashMap &get_inner_classes() const { return inner_classes; } + Error parse_result; Error get_left_function_call(const LSP::Position &p_position, LSP::Position &r_func_pos, int &r_arg_index) const; @@ -166,5 +167,5 @@ public: const Array &get_member_completions(); Dictionary generate_api() const; - Error parse(const String &p_code, const String &p_path); + void parse(const String &p_code, const String &p_path); }; diff --git a/modules/gdscript/language_server/gdscript_language_protocol.cpp b/modules/gdscript/language_server/gdscript_language_protocol.cpp index 0bf73a1ed62..fb3d35dd17e 100644 --- a/modules/gdscript/language_server/gdscript_language_protocol.cpp +++ b/modules/gdscript/language_server/gdscript_language_protocol.cpp @@ -36,6 +36,19 @@ #include "editor/editor_log.h" #include "editor/editor_node.h" #include "editor/settings/editor_settings.h" +#include "modules/gdscript/language_server/godot_lsp.h" + +#define LSP_CLIENT_V(m_ret_val) \ + ERR_FAIL_COND_V(latest_client_id == LSP_NO_CLIENT, m_ret_val); \ + ERR_FAIL_COND_V(!clients.has(latest_client_id), m_ret_val); \ + Ref client = clients.get(latest_client_id); \ + ERR_FAIL_COND_V(!client.is_valid(), m_ret_val); + +#define LSP_CLIENT \ + ERR_FAIL_COND(latest_client_id == LSP_NO_CLIENT); \ + ERR_FAIL_COND(!clients.has(latest_client_id)); \ + Ref client = clients.get(latest_client_id); \ + ERR_FAIL_COND(!client.is_valid()); GDScriptLanguageProtocol *GDScriptLanguageProtocol::singleton = nullptr; @@ -312,8 +325,7 @@ void GDScriptLanguageProtocol::notify_client(const String &p_method, const Varia } #endif if (p_client_id == -1) { - ERR_FAIL_COND_MSG(latest_client_id == -1, - "GDScript LSP: Can't notify client as none was connected."); + ERR_FAIL_COND_MSG(latest_client_id == LSP_NO_CLIENT, "GDScript LSP: Can't notify client as none was connected."); p_client_id = latest_client_id; } ERR_FAIL_COND(!clients.has(p_client_id)); @@ -333,8 +345,7 @@ void GDScriptLanguageProtocol::request_client(const String &p_method, const Vari } #endif if (p_client_id == -1) { - ERR_FAIL_COND_MSG(latest_client_id == -1, - "GDScript LSP: Can't notify client as none was connected."); + ERR_FAIL_COND_MSG(latest_client_id == LSP_NO_CLIENT, "GDScript LSP: Can't notify client as none was connected."); p_client_id = latest_client_id; } ERR_FAIL_COND(!clients.has(p_client_id)); @@ -356,6 +367,174 @@ bool GDScriptLanguageProtocol::is_goto_native_symbols_enabled() const { return bool(_EDITOR_GET("network/language_server/show_native_symbols_in_editor")); } +ExtendGDScriptParser *GDScriptLanguageProtocol::LSPeer::parse_script(const String &p_path) { + remove_cached_parser(p_path); + + String content; + const LSP::TextDocumentItem *document = managed_files.getptr(p_path); + if (document == nullptr) { + if (!p_path.has_extension("gd")) { + return nullptr; + } + Error err; + content = FileAccess::get_file_as_string(p_path, &err); + if (err != OK) { + return nullptr; + } + } else { + if (document->languageId != LSP::LanguageId::GDSCRIPT) { + return nullptr; + } + content = document->text; + } + + ExtendGDScriptParser *parser = memnew(ExtendGDScriptParser); + parser->parse(content, p_path); + + if (document != nullptr) { + parse_results[p_path] = parser; + GDScriptLanguageProtocol::get_singleton()->get_workspace()->publish_diagnostics(p_path); + } else { + stale_parsers[p_path] = parser; + } + + return parser; +} + +void GDScriptLanguageProtocol::LSPeer::remove_cached_parser(const String &p_path) { + HashMap::Iterator cached = parse_results.find(p_path); + if (cached) { + memdelete(cached->value); + parse_results.remove(cached); + } + + HashMap::Iterator stale = stale_parsers.find(p_path); + if (stale) { + memdelete(stale->value); + stale_parsers.remove(stale); + } +} + +ExtendGDScriptParser *GDScriptLanguageProtocol::get_parse_result(const String &p_path) { + LSP_CLIENT_V(nullptr); + + ExtendGDScriptParser **cached_parser = client->parse_results.getptr(p_path); + if (cached_parser == nullptr) { + return client->parse_script(p_path); + } + return *cached_parser; +} + +void GDScriptLanguageProtocol::lsp_did_open(const Dictionary &p_params) { + LSP_CLIENT; + + LSP::TextDocumentItem document; + document.load(p_params["textDocument"]); + + // We keep track of non GDScript files that the client owns, but we are not interested in the content. + if (document.languageId != LSP::LanguageId::GDSCRIPT) { + document.text = ""; + } + + String path = get_workspace()->get_file_path(document.uri); + + /// An open notification must not be sent more than once without a corresponding close notification send before. + ERR_FAIL_COND_MSG(client->managed_files.has(path), "LSP: Client is opening already opened file."); + + client->managed_files[path] = document; + client->parse_script(path); +} + +void GDScriptLanguageProtocol::lsp_did_change(const Dictionary &p_params) { + LSP_CLIENT; + + LSP::TextDocumentIdentifier identifier; + identifier.load(p_params["textDocument"]); + + String path = get_workspace()->get_file_path(identifier.uri); + LSP::TextDocumentItem *document = client->managed_files.getptr(path); + + /// Before a client can change a text document it must claim ownership of its content using the textDocument/didOpen notification. + ERR_FAIL_COND_MSG(document == nullptr, "LSP: Client is changing file without opening it."); + + if (document->languageId != LSP::LanguageId::GDSCRIPT) { + return; + } + + Array contentChanges = p_params["contentChanges"]; + + if (contentChanges.is_empty()) { + return; + } + + // We only support TextDocumentSyncKind::Full. So only the last full text is relevant. + LSP::TextDocumentContentChangeEvent event; + event.load(contentChanges.back()); + document->text = event.text; + + client->parse_script(path); +} + +void GDScriptLanguageProtocol::lsp_did_close(const Dictionary &p_params) { + LSP_CLIENT; + + LSP::TextDocumentIdentifier identifier; + identifier.load(p_params["textDocument"]); + + String path = get_workspace()->get_file_path(identifier.uri); + bool was_opened = client->managed_files.erase(path); + + client->remove_cached_parser(path); + + /// A close notification requires a previous open notification to be sent. + ERR_FAIL_COND_MSG(!was_opened, "LSP: Client is closing file without opening it."); +} + +void GDScriptLanguageProtocol::resolve_related_symbols(const LSP::TextDocumentPositionParams &p_doc_pos, List &r_list) { + LSP_CLIENT; + + String path = workspace->get_file_path(p_doc_pos.textDocument.uri); + + const ExtendGDScriptParser *parser = get_parse_result(path); + if (!parser) { + return; + } + + String symbol_identifier; + LSP::Range range; + symbol_identifier = parser->get_identifier_under_position(p_doc_pos.position, range); + + for (const KeyValue &E : workspace->native_members) { + if (const LSP::DocumentSymbol *const *symbol = E.value.getptr(symbol_identifier)) { + r_list.push_back(*symbol); + } + } + + for (const KeyValue &E : client->parse_results) { + const ExtendGDScriptParser *scr = E.value; + const ClassMembers &members = scr->get_members(); + if (const LSP::DocumentSymbol *const *symbol = members.getptr(symbol_identifier)) { + r_list.push_back(*symbol); + } + + for (const KeyValue &F : scr->get_inner_classes()) { + const ClassMembers *inner_class = &F.value; + if (const LSP::DocumentSymbol *const *symbol = inner_class->getptr(symbol_identifier)) { + r_list.push_back(*symbol); + } + } + } +} + +GDScriptLanguageProtocol::LSPeer::~LSPeer() { + while (!parse_results.is_empty()) { + remove_cached_parser(parse_results.begin()->key); + } + while (!stale_parsers.is_empty()) { + remove_cached_parser(stale_parsers.begin()->key); + } +} + // clang-format off #define SET_DOCUMENT_METHOD(m_method) set_method(_STR(textDocument/m_method), callable_mp(text_document.ptr(), &GDScriptTextDocument::m_method)) #define SET_COMPLETION_METHOD(m_method) set_method(_STR(completionItem/m_method), callable_mp(text_document.ptr(), &GDScriptTextDocument::m_method)) @@ -392,8 +571,6 @@ GDScriptLanguageProtocol::GDScriptLanguageProtocol() { SET_COMPLETION_METHOD(resolve); - SET_WORKSPACE_METHOD(didDeleteFiles); - set_method("initialize", callable_mp(this, &GDScriptLanguageProtocol::initialize)); set_method("initialized", callable_mp(this, &GDScriptLanguageProtocol::initialized)); @@ -403,3 +580,6 @@ GDScriptLanguageProtocol::GDScriptLanguageProtocol() { #undef SET_DOCUMENT_METHOD #undef SET_COMPLETION_METHOD #undef SET_WORKSPACE_METHOD + +#undef LSP_CLIENT +#undef LSP_CLIENT_V diff --git a/modules/gdscript/language_server/gdscript_language_protocol.h b/modules/gdscript/language_server/gdscript_language_protocol.h index afdd849b8d9..bff630e177a 100644 --- a/modules/gdscript/language_server/gdscript_language_protocol.h +++ b/modules/gdscript/language_server/gdscript_language_protocol.h @@ -41,9 +41,15 @@ #define LSP_MAX_BUFFER_SIZE 4194304 #define LSP_MAX_CLIENTS 8 +#define LSP_NO_CLIENT -1 + class GDScriptLanguageProtocol : public JSONRPC { GDCLASS(GDScriptLanguageProtocol, JSONRPC) +#ifdef TESTS_ENABLED + friend class TestGDScriptLanguageProtocolInitializer; +#endif + private: struct LSPeer : RefCounted { Ref connection; @@ -58,6 +64,24 @@ private: Error handle_data(); Error send_data(); + + /** + * Tracks all files that the client claimed, however for files deemed not relevant + * to the server the `text` might not be persisted. + */ + HashMap managed_files; + HashMap parse_results; + + void remove_cached_parser(const String &p_path); + ExtendGDScriptParser *parse_script(const String &p_path); + + ~LSPeer(); + + private: + // We can't cache parsers for scripts not managed by the editor since we have + // no way to invalidate the cache. We still need to keep track of those parsers + // to clean them up properly. + HashMap stale_parsers; }; enum LSPErrorCode { @@ -69,7 +93,7 @@ private: HashMap> clients; Ref server; - int latest_client_id = 0; + int latest_client_id = LSP_NO_CLIENT; int next_client_id = 0; int next_server_id = 0; @@ -107,5 +131,27 @@ public: bool is_smart_resolve_enabled() const; bool is_goto_native_symbols_enabled() const; + // Text Document Synchronization + void lsp_did_open(const Dictionary &p_params); + void lsp_did_change(const Dictionary &p_params); + void lsp_did_close(const Dictionary &p_params); + + /** + * Returns a list of symbols that might be related to the document position. + * + * The result fulfills no semantic guarantees, nor is it guaranteed to be complete. + * Should only be used for "smart resolve". + */ + void resolve_related_symbols(const LSP::TextDocumentPositionParams &p_doc_pos, List &r_list); + + /** + * Returns parse results for the given path, using the cache if available. + * If no such file exists, or the file is not a GDScript file a `nullptr` is returned. + */ + ExtendGDScriptParser *get_parse_result(const String &p_path); + GDScriptLanguageProtocol(); + ~GDScriptLanguageProtocol() { + clients.clear(); + } }; diff --git a/modules/gdscript/language_server/gdscript_text_document.cpp b/modules/gdscript/language_server/gdscript_text_document.cpp index 1d985df41cf..ad0ccb338f0 100644 --- a/modules/gdscript/language_server/gdscript_text_document.cpp +++ b/modules/gdscript/language_server/gdscript_text_document.cpp @@ -63,29 +63,21 @@ void GDScriptTextDocument::_bind_methods() { } void GDScriptTextDocument::didOpen(const Variant &p_param) { - LSP::TextDocumentItem doc = load_document_item(p_param); - sync_script_content(doc.uri, doc.text); -} - -void GDScriptTextDocument::didClose(const Variant &p_param) { - // Left empty on purpose. Godot does nothing special on closing a document, - // but it satisfies LSP clients that require didClose be implemented. + GDScriptLanguageProtocol::get_singleton()->lsp_did_open(p_param); } void GDScriptTextDocument::didChange(const Variant &p_param) { - LSP::TextDocumentItem doc = load_document_item(p_param); - Dictionary dict = p_param; - Array contentChanges = dict["contentChanges"]; - for (int i = 0; i < contentChanges.size(); ++i) { - LSP::TextDocumentContentChangeEvent evt; - evt.load(contentChanges[i]); - doc.text = evt.text; - } - sync_script_content(doc.uri, doc.text); + GDScriptLanguageProtocol::get_singleton()->lsp_did_change(p_param); +} + +void GDScriptTextDocument::didClose(const Variant &p_param) { + GDScriptLanguageProtocol::get_singleton()->lsp_did_close(p_param); } void GDScriptTextDocument::willSaveWaitUntil(const Variant &p_param) { - LSP::TextDocumentItem doc = load_document_item(p_param); + Dictionary dict = p_param; + LSP::TextDocumentIdentifier doc; + doc.load(dict["textDocument"]); String path = GDScriptLanguageProtocol::get_singleton()->get_workspace()->get_file_path(doc.uri); Ref