Merge pull request #118598 from Finishxx/gdscript-lsp-color-bbcode-support

GDScript LSP: Convert [color] BBCode to <span> HTML for capable clients
This commit is contained in:
Thaddeus Crews
2026-06-25 08:32:24 -05:00
6 changed files with 110 additions and 42 deletions
@@ -250,6 +250,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();
}
@@ -440,6 +447,12 @@ ExtendGDScriptParser *GDScriptLanguageProtocol::get_parse_result(const String &p
return *cached_parser;
}
const HashSet<String> &GDScriptLanguageProtocol::get_client_markdown_allowed_html_tags() const {
static const HashSet<String> 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;
@@ -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<String> 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<String> &get_client_markdown_allowed_html_tags() const;
GDScriptLanguageProtocol();
~GDScriptLanguageProtocol() {
clients.clear();
@@ -244,7 +244,8 @@ Dictionary GDScriptTextDocument::resolve(const Dictionary &p_params) {
}
if (symbol) {
item.documentation = symbol->render();
const HashSet<String> &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<String> &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<const LSP::DocumentSymbol *> list;
GDScriptLanguageProtocol::get_singleton()->resolve_related_symbols(params, list);
const HashSet<String> &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;
@@ -771,7 +771,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<String> &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];
+36 -7
View File
@@ -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<String> &p_allowed_html_tags);
/**
* Text documents are identified using a URI. On the protocol level, URIs are passed as strings.
@@ -1306,13 +1306,13 @@ struct DocumentSymbol {
return dict;
}
_FORCE_INLINE_ MarkupContent render() const {
_FORCE_INLINE_ MarkupContent render(const HashSet<String> &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 + ")";
@@ -1916,7 +1916,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<String> &p_allowed_html_tags) {
bool span_allowed = p_allowed_html_tags.has("span");
String markdown = p_bbcode.strip_edges();
Vector<String> lines = markdown.split("\n");
@@ -1996,7 +1997,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.
@@ -2069,6 +2069,35 @@ static String marked_documentation(const String &p_bbcode) {
pos += replacement.length();
}
// Convert [color] BBCode to <span>, 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 = "<span style=\"color:" + color + "\">" + text + "</span>";
} else if (int named_color_index = Color::find_named_color(color); named_color_index != -1) {
replacement = "<span style=\"color:#" + Color::get_named_color(named_color_index).to_html(false) + "\">" + text + "</span>";
}
}
// 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<String> link_start_patterns = {
@@ -2090,10 +2119,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;
+43 -31
View File
@@ -453,61 +453,73 @@ TEST_SUITE("[Modules][GDScript][LSP][Editor]") {
// 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<String> span_allowed_tags = { "span" };
CHECK_EQ(LSP::marked_documentation("[color=red]red text[/color]", span_allowed_tags), "<span style=\"color:#ff0000\">red text</span>");
CHECK_EQ(LSP::marked_documentation("[color=#ff0000]red text[/color]", span_allowed_tags), "<span style=\"color:#ff0000\">red text</span>");
CHECK_EQ(LSP::marked_documentation("[color=#ff0000ff]red text[/color]", span_allowed_tags), "<span style=\"color:#ff0000ff\">red text</span>");
CHECK_EQ(LSP::marked_documentation("[color=red]r[/color] and [color=blue]b[/color]", span_allowed_tags), "<span style=\"color:#ff0000\">r</span> and <span style=\"color:#0000ff\">b</span>");
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\"> <script>malicious code</script>]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)");
}
TEST_CASE("get_symbol_name_under_position") {