diff --git a/modules/gdscript/language_server/gdscript_language_protocol.cpp b/modules/gdscript/language_server/gdscript_language_protocol.cpp index a5538ddafb..db3722ac6c 100644 --- a/modules/gdscript/language_server/gdscript_language_protocol.cpp +++ b/modules/gdscript/language_server/gdscript_language_protocol.cpp @@ -276,6 +276,13 @@ Variant GDScriptLanguageProtocol::initialize(const Dictionary &p_params) { client->behavior.use_snippets_for_brace_completion = get_deep(capabilities, false, "textDocument", "completion", "completionItem", "snippetSupport"); + Array allowed_tags = get_deep(capabilities, Array(), "general", "markdown", "allowedTags"); + for (const Variant &tag : allowed_tags) { + if (tag.is_string()) { + client->behavior.markdown_allowed_html_tags.insert(tag); + } + } + return ret.to_json(); } @@ -466,6 +473,12 @@ ExtendGDScriptParser *GDScriptLanguageProtocol::get_parse_result(const String &p return *cached_parser; } +const HashSet &GDScriptLanguageProtocol::get_client_markdown_allowed_html_tags() const { + static const HashSet default_tags = {}; + LSP_CLIENT_V(default_tags); + return client->behavior.markdown_allowed_html_tags; +} + void GDScriptLanguageProtocol::lsp_did_open(const Dictionary &p_params) { LSP_CLIENT; diff --git a/modules/gdscript/language_server/gdscript_language_protocol.h b/modules/gdscript/language_server/gdscript_language_protocol.h index 6ba4fc53ee..20f1d9f91b 100644 --- a/modules/gdscript/language_server/gdscript_language_protocol.h +++ b/modules/gdscript/language_server/gdscript_language_protocol.h @@ -53,6 +53,8 @@ public: struct ClientBehavior { /** If `true` use snippet insert mode to position the cursor between braces of completion options. If `false` strip braces from completion options since we can't provide good UX for them. */ bool use_snippets_for_brace_completion = false; + /** HTML tags, e.g. `span`, which the client is capable of rendering inside Markdown content. Assumes full support for attributes e.g. `style`. */ + HashSet markdown_allowed_html_tags; }; private: @@ -167,6 +169,14 @@ public: */ ExtendGDScriptParser *get_parse_result(const String &p_path); + /** + * Returns the HTML tags the client can render inside Markdown content, e.g. `span`. + * Defaults to an empty set if no client is connected. + * + * TODO: Remove after moving endpoints into unified class. Access non-null client directly. + */ + const HashSet &get_client_markdown_allowed_html_tags() const; + 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 8c07f17b3e..850deb94f0 100644 --- a/modules/gdscript/language_server/gdscript_text_document.cpp +++ b/modules/gdscript/language_server/gdscript_text_document.cpp @@ -244,7 +244,8 @@ Dictionary GDScriptTextDocument::resolve(const Dictionary &p_params) { } if (symbol) { - item.documentation = symbol->render(); + const HashSet &allowed_tags = GDScriptLanguageProtocol::get_singleton()->get_client_markdown_allowed_html_tags(); + item.documentation = symbol->render(allowed_tags); } if (item.kind == LSP::CompletionItemKind::Event) { @@ -299,7 +300,8 @@ Variant GDScriptTextDocument::hover(const Dictionary &p_params) { const LSP::DocumentSymbol *symbol = GDScriptLanguageProtocol::get_singleton()->get_workspace()->resolve_symbol(params); if (symbol) { LSP::Hover hover; - hover.contents = symbol->render(); + const HashSet &allowed_tags = GDScriptLanguageProtocol::get_singleton()->get_client_markdown_allowed_html_tags(); + hover.contents = symbol->render(allowed_tags); hover.range.start = params.position; hover.range.end = params.position; return hover.to_json(); @@ -309,9 +311,10 @@ Variant GDScriptTextDocument::hover(const Dictionary &p_params) { Array contents; List list; GDScriptLanguageProtocol::get_singleton()->resolve_related_symbols(params, list); + const HashSet &allowed_tags = GDScriptLanguageProtocol::get_singleton()->get_client_markdown_allowed_html_tags(); for (const LSP::DocumentSymbol *&E : list) { if (const LSP::DocumentSymbol *s = E) { - contents.push_back(s->render().value); + contents.push_back(s->render(allowed_tags).value); } } ret["contents"] = contents; diff --git a/modules/gdscript/language_server/gdscript_workspace.cpp b/modules/gdscript/language_server/gdscript_workspace.cpp index 50ecfe1ae1..bb2d8feafd 100644 --- a/modules/gdscript/language_server/gdscript_workspace.cpp +++ b/modules/gdscript/language_server/gdscript_workspace.cpp @@ -762,7 +762,8 @@ Error GDScriptWorkspace::resolve_signature(const LSP::TextDocumentPositionParams if (symbol->kind == LSP::SymbolKind::Method || symbol->kind == LSP::SymbolKind::Function) { LSP::SignatureInformation signature_info; signature_info.label = symbol->detail; - signature_info.documentation = symbol->render(); + const HashSet &allowed_tags = GDScriptLanguageProtocol::get_singleton()->get_client_markdown_allowed_html_tags(); + signature_info.documentation = symbol->render(allowed_tags); for (int i = 0; i < symbol->children.size(); i++) { const LSP::DocumentSymbol &arg = symbol->children[i]; diff --git a/modules/gdscript/language_server/godot_lsp.h b/modules/gdscript/language_server/godot_lsp.h index 0571920dde..0fdb94d0dd 100644 --- a/modules/gdscript/language_server/godot_lsp.h +++ b/modules/gdscript/language_server/godot_lsp.h @@ -48,7 +48,7 @@ namespace LSP { typedef String DocumentUri; /** Format BBCode documentation from DocData to markdown */ -static String marked_documentation(const String &p_bbcode); +static String marked_documentation(const String &p_bbcode, const HashSet &p_allowed_html_tags); /** * Text documents are identified using a URI. On the protocol level, URIs are passed as strings. @@ -1299,13 +1299,13 @@ struct DocumentSymbol { return dict; } - _FORCE_INLINE_ MarkupContent render() const { + _FORCE_INLINE_ MarkupContent render(const HashSet &p_allowed_html_tags) const { MarkupContent markdown; if (detail.length()) { markdown.value = "\t" + detail + "\n\n"; } if (documentation.length()) { - markdown.value += marked_documentation(documentation) + "\n\n"; + markdown.value += marked_documentation(documentation, p_allowed_html_tags) + "\n\n"; } if (script_path.length()) { markdown.value += "Defined in [" + script_path + "](" + uri + ")"; @@ -1909,7 +1909,8 @@ struct GodotCapabilities { }; /** Format BBCode documentation from DocData to markdown */ -static String marked_documentation(const String &p_bbcode) { +static String marked_documentation(const String &p_bbcode, const HashSet &p_allowed_html_tags) { + bool span_allowed = p_allowed_html_tags.has("span"); String markdown = p_bbcode.strip_edges(); Vector lines = markdown.split("\n"); @@ -1989,7 +1990,6 @@ static String marked_documentation(const String &p_bbcode) { line = line.replace("[center]", ""); line = line.replace("[/center]", ""); line = line.replace("[/font]", ""); - line = line.replace("[/color]", ""); line = line.replace("[/img]", ""); // Convert remaining simple bracketed class names to backticks and literal brackets. @@ -2062,6 +2062,35 @@ static String marked_documentation(const String &p_bbcode) { pos += replacement.length(); } + // Convert [color] BBCode to , if the client is capable of rendering it, strip it otherwise. + pos = 0; + while ((pos = line.find("[color=", pos)) != -1) { + constexpr int COLOR_OPEN_TAG_LENGTH = 7; // Length of "[color=". + constexpr int COLOR_CLOSE_TAG_LENGTH = 8; // Length of "[/color]". + + int color_end = line.find_char(']', pos); + int close_start = line.find("[/color]", color_end); + if (color_end == -1 || close_start == -1) { + break; + } + String text = line.substr(color_end + 1, close_start - color_end - 1); + String replacement; + if (span_allowed) { + const String color = line.substr(pos + COLOR_OPEN_TAG_LENGTH, color_end - pos - COLOR_OPEN_TAG_LENGTH).strip_edges(); + if (Color::html_is_valid(color)) { + replacement = "" + text + ""; + } else if (int named_color_index = Color::find_named_color(color); named_color_index != -1) { + replacement = "" + text + ""; + } + } + // If no span replacement was produced (client can't render spans, or invalid color), just keep the inner text. + if (replacement.is_empty()) { + replacement = text; + } + line = line.substr(0, pos) + replacement + line.substr(close_start + COLOR_CLOSE_TAG_LENGTH); + pos += replacement.length(); + } + // Replace the various link types with inline code ([class MyNode] to `MyNode`). // Uses a while loop because there can occasionally be multiple links of the same type in a single line. const Vector link_start_patterns = { @@ -2083,10 +2112,10 @@ static String marked_documentation(const String &p_bbcode) { } } - // Remove tags with attributes like [color=red], as they don't have a direct Markdown + // Remove tags with attributes like [font=Arial], as they don't have a direct Markdown // equivalent supported by external tools. const String attribute_tags[] = { - "color", "font", "img" + "font", "img" }; for (const String &tag_name : attribute_tags) { int tag_pos = 0; diff --git a/modules/gdscript/tests/test_lsp.h b/modules/gdscript/tests/test_lsp.h index 33de0fac8f..6777399fae 100644 --- a/modules/gdscript/tests/test_lsp.h +++ b/modules/gdscript/tests/test_lsp.h @@ -541,61 +541,73 @@ func f(): // the LSP client on documentation requests // Basic formatting - CHECK_EQ(LSP::marked_documentation("[b]bold[/b]"), "**bold**"); - CHECK_EQ(LSP::marked_documentation("[i]italic[/i]"), "*italic*"); - CHECK_EQ(LSP::marked_documentation("[u]underline[/u]"), "__underline__"); - CHECK_EQ(LSP::marked_documentation("[s]strikethrough[/s]"), "~~strikethrough~~"); - CHECK_EQ(LSP::marked_documentation("[code]code[/code]"), "`code`"); - CHECK_EQ(LSP::marked_documentation("[kbd]Ctrl + S[/kbd]"), "`Ctrl + S`"); + CHECK_EQ(LSP::marked_documentation("[b]bold[/b]", {}), "**bold**"); + CHECK_EQ(LSP::marked_documentation("[i]italic[/i]", {}), "*italic*"); + CHECK_EQ(LSP::marked_documentation("[u]underline[/u]", {}), "__underline__"); + CHECK_EQ(LSP::marked_documentation("[s]strikethrough[/s]", {}), "~~strikethrough~~"); + CHECK_EQ(LSP::marked_documentation("[code]code[/code]", {}), "`code`"); + CHECK_EQ(LSP::marked_documentation("[kbd]Ctrl + S[/kbd]", {}), "`Ctrl + S`"); // Line breaks. We insert paragraphs for [br] because the BBCode to // markdown conversion function simply makes the conversion line-wise and // we don't distinguish markdown inline elements and blocks. - CHECK_EQ(LSP::marked_documentation("Line1[br]Line2"), "Line1\n\nLine2"); + CHECK_EQ(LSP::marked_documentation("Line1[br]Line2", {}), "Line1\n\nLine2"); - // These tags (center, color, font) aren't supported in markdown and should be stripped. - CHECK_EQ(LSP::marked_documentation("[center]Centered text[/center]"), "Centered text"); - CHECK_EQ(LSP::marked_documentation("[color=red]red text[/color]"), "red text"); - CHECK_EQ(LSP::marked_documentation("[font=Arial]Arial text[/font]"), "Arial text"); + // These tags (center, font) aren't supported in markdown and should be stripped. + CHECK_EQ(LSP::marked_documentation("[center]Centered text[/center]", {}), "Centered text"); + CHECK_EQ(LSP::marked_documentation("[font=Arial]Arial text[/font]", {}), "Arial text"); + + // [color] for incapable clients is stripped + CHECK_EQ(LSP::marked_documentation("[color=red]red text[/color]", {}), "red text"); + + // [color] tag for capable clients is converted to span + HashSet span_allowed_tags = { "span" }; + CHECK_EQ(LSP::marked_documentation("[color=red]red text[/color]", span_allowed_tags), "red text"); + CHECK_EQ(LSP::marked_documentation("[color=#ff0000]red text[/color]", span_allowed_tags), "red text"); + CHECK_EQ(LSP::marked_documentation("[color=#ff0000ff]red text[/color]", span_allowed_tags), "red text"); + CHECK_EQ(LSP::marked_documentation("[color=red]r[/color] and [color=blue]b[/color]", span_allowed_tags), "r and b"); + CHECK_EQ(LSP::marked_documentation("[color=lobster]invalid color name[/color]", span_allowed_tags), "invalid color name"); + CHECK_EQ(LSP::marked_documentation("[color=#zzzzzz]invalid color hex[/color]", span_allowed_tags), "invalid color hex"); + CHECK_EQ(LSP::marked_documentation("[color=#ff0000\"> ]no escaping[/color]", span_allowed_tags), "no escaping"); // The following tests are for all the link patterns specific to Godot's built-in docs that we render as inline code. - CHECK_EQ(LSP::marked_documentation("Class link: [Node2D], [Sprite2D]"), "Class link: `Node2D`, `Sprite2D`"); - CHECK_EQ(LSP::marked_documentation("Single class [RigidBody2D]"), "Single class `RigidBody2D`"); - CHECK_EQ(LSP::marked_documentation("[method Node2D.set_position]"), "`Node2D.set_position`"); - CHECK_EQ(LSP::marked_documentation("[member Node2D.position]"), "`Node2D.position`"); - CHECK_EQ(LSP::marked_documentation("[signal Node.ready]"), "`Node.ready`"); - CHECK_EQ(LSP::marked_documentation("[constant Color.RED]"), "`Color.RED`"); - CHECK_EQ(LSP::marked_documentation("[enum Node.ProcessMode]"), "`Node.ProcessMode`"); - CHECK_EQ(LSP::marked_documentation("[annotation @GDScript.@export]"), "`@GDScript.@export`"); - CHECK_EQ(LSP::marked_documentation("[constructor Vector2.Vector2]"), "`Vector2.Vector2`"); - CHECK_EQ(LSP::marked_documentation("[operator Vector2.operator +]"), "`Vector2.operator +`"); - CHECK_EQ(LSP::marked_documentation("[theme_item Button.font]"), "`Button.font`"); - CHECK_EQ(LSP::marked_documentation("[param delta]"), "`delta`"); + CHECK_EQ(LSP::marked_documentation("Class link: [Node2D], [Sprite2D]", {}), "Class link: `Node2D`, `Sprite2D`"); + CHECK_EQ(LSP::marked_documentation("Single class [RigidBody2D]", {}), "Single class `RigidBody2D`"); + CHECK_EQ(LSP::marked_documentation("[method Node2D.set_position]", {}), "`Node2D.set_position`"); + CHECK_EQ(LSP::marked_documentation("[member Node2D.position]", {}), "`Node2D.position`"); + CHECK_EQ(LSP::marked_documentation("[signal Node.ready]", {}), "`Node.ready`"); + CHECK_EQ(LSP::marked_documentation("[constant Color.RED]", {}), "`Color.RED`"); + CHECK_EQ(LSP::marked_documentation("[enum Node.ProcessMode]", {}), "`Node.ProcessMode`"); + CHECK_EQ(LSP::marked_documentation("[annotation @GDScript.@export]", {}), "`@GDScript.@export`"); + CHECK_EQ(LSP::marked_documentation("[constructor Vector2.Vector2]", {}), "`Vector2.Vector2`"); + CHECK_EQ(LSP::marked_documentation("[operator Vector2.operator +]", {}), "`Vector2.operator +`"); + CHECK_EQ(LSP::marked_documentation("[theme_item Button.font]", {}), "`Button.font`"); + CHECK_EQ(LSP::marked_documentation("[param delta]", {}), "`delta`"); // Markdown links - CHECK_EQ(LSP::marked_documentation("[url=https://godotengine.org]link to Godot Engine[/url]"), + CHECK_EQ(LSP::marked_documentation("[url=https://godotengine.org]link to Godot Engine[/url]", {}), "[link to Godot Engine](https://godotengine.org)"); - CHECK_EQ(LSP::marked_documentation("[url]https://godotengine.org/[/url]"), + CHECK_EQ(LSP::marked_documentation("[url]https://godotengine.org/[/url]", {}), "[https://godotengine.org/](https://godotengine.org/)"); // Code listings - CHECK_EQ(LSP::marked_documentation("[codeblock]\nfunc test():\n print(\"Hello, Godot!\")\n[/codeblock]"), + CHECK_EQ(LSP::marked_documentation("[codeblock]\nfunc test():\n print(\"Hello, Godot!\")\n[/codeblock]", {}), "```gdscript\nfunc test():\n print(\"Hello, Godot!\")\n```"); - CHECK_EQ(LSP::marked_documentation("[codeblock lang=csharp]\npublic void Test()\n{\n GD.Print(\"Hello, Godot!\");\n}\n[/codeblock]"), + CHECK_EQ(LSP::marked_documentation("[codeblock lang=csharp]\npublic void Test()\n{\n GD.Print(\"Hello, Godot!\");\n}\n[/codeblock]", {}), "```csharp\npublic void Test()\n{\n GD.Print(\"Hello, Godot!\");\n}\n```"); // Code listings with multiple languages (the codeblocks tag is used in the built-in reference) // When [codeblocks] is used, we only convert the [gdscript] tag to a code block like the built-in editor. // NOTE: There is always a GDScript code listing in the built-in class reference. - CHECK_EQ(LSP::marked_documentation("[codeblocks]\n[gdscript]\nprint(hash(\"a\")) # Prints 177670\n[/gdscript]\n[csharp]\nGD.Print(GD.Hash(\"a\")); // Prints 177670\n[/csharp]\n[/codeblocks]"), + CHECK_EQ(LSP::marked_documentation("[codeblocks]\n[gdscript]\nprint(hash(\"a\")) # Prints 177670\n[/gdscript]\n[csharp]\nGD.Print(GD.Hash(\"a\")); // Prints 177670\n[/csharp]\n[/codeblocks]", {}), "```gdscript\nprint(hash(\"a\")) # Prints 177670\n```\n"); // lb and rb are used to insert literal square brackets in markdown. - CHECK_EQ(LSP::marked_documentation("[lb]literal brackets[rb]"), "\\[literal brackets\\]"); - CHECK_EQ(LSP::marked_documentation("[lb]literal[rb] with [ClassName]"), "\\[literal\\] with `ClassName`"); + CHECK_EQ(LSP::marked_documentation("[lb]literal brackets[rb]", {}), "\\[literal brackets\\]"); + CHECK_EQ(LSP::marked_documentation("[lb]literal[rb] with [ClassName]", {}), "\\[literal\\] with `ClassName`"); // We have to be careful that different patterns don't conflict with each // other, especially with urls that use brackets in markdown. - CHECK_EQ(LSP::marked_documentation("Class [Sprite2D] with [url=https://godotengine.org]link[/url]"), + CHECK_EQ(LSP::marked_documentation("Class [Sprite2D] with [url=https://godotengine.org]link[/url]", {}), "Class `Sprite2D` with [link](https://godotengine.org)"); } }