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:
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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") {
|
||||
|
||||
Reference in New Issue
Block a user