From 52e043323f2f5b8853e0306af9a7c9e2d6e72368 Mon Sep 17 00:00:00 2001 From: Adaline Simonian Date: Fri, 17 Jan 2025 22:16:25 -0800 Subject: [PATCH] feat: support additional doc comment markdown Simulates formatting that Godot does not natively support in its limited BBCode implementation, such as lists and footnotes, as best as possible. Also fixes #811 as paragraphs are correctly formatted, without excessive line breaks. --- godot-macros/src/docs.rs | 3 +- godot-macros/src/docs/markdown_converter.rs | 350 +++++++++++++++--- .../src/register_tests/register_docs_test.rs | 54 ++- .../register_tests/res/registered_docs.xml | 16 +- 4 files changed, 361 insertions(+), 62 deletions(-) diff --git a/godot-macros/src/docs.rs b/godot-macros/src/docs.rs index abe967862..7d33eafaf 100644 --- a/godot-macros/src/docs.rs +++ b/godot-macros/src/docs.rs @@ -150,8 +150,9 @@ fn xml_escape(value: String) -> String { /// for Godot's consumption. fn make_docs_from_attributes(doc: &[Attribute]) -> Option { let doc = siphon_docs_from_attributes(doc) - .collect::>() + .collect::>() .join("\n"); + (!doc.is_empty()).then(|| markdown_converter::to_bbcode(&doc)) } diff --git a/godot-macros/src/docs/markdown_converter.rs b/godot-macros/src/docs/markdown_converter.rs index e468a82ae..502330a43 100644 --- a/godot-macros/src/docs/markdown_converter.rs +++ b/godot-macros/src/docs/markdown_converter.rs @@ -5,91 +5,331 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -//! Converts [Markdown](https://en.wikipedia.org/wiki/Markdown) to [BBCode](https://en.wikipedia.org/wiki/BBCode). +//! Converts [Markdown](https://en.wikipedia.org/wiki/Markdown) to Godot-compatible [BBCode](https://en.wikipedia.org/wiki/BBCode). use markdown::mdast as md; use markdown::{to_mdast, ParseOptions}; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; -pub fn to_bbcode(md: &str) -> String { +/// Converts the provided Markdown string to BBCode suitable for Godot's docs renderer. +/// Simulates any missing features (e.g. tables) with a best-effort approach. +pub fn to_bbcode(md_text: &str) -> String { // to_mdast() never errors with normal Markdown, so unwrap is safe. - let n = to_mdast(md, &ParseOptions::gfm()).unwrap(); + let root = to_mdast(md_text, &ParseOptions::gfm()).unwrap(); - let definitions = n + // Collect link/image definitions (for reference-style links). + let definitions = root .children() - .unwrap() // root node always has children + .expect("Markdown root node should always have children") .iter() - .filter_map(|n| match n { + .filter_map(|node| match node { md::Node::Definition(def) => Some((&*def.identifier, &*def.url)), _ => None, }) .collect::>(); - walk_node(&n, &definitions).unwrap_or_default() -} + // Convert the root node to BBCode. + let mut converter = BBCodeConverter::new(&definitions); + let content = converter.walk_node(&root, 0).unwrap_or_default(); -fn walk_node(node: &md::Node, definitions: &HashMap<&str, &str>) -> Option { - use md::Node::*; + // Append footnotes at the bottom if any. + if !converter.footnote_defs.is_empty() { + let notes = converter + .footnote_defs + .iter() + .map(|(idx, text)| format!("{} {}", BBCodeConverter::superscript(*idx), text)) + .collect::>() + .join("[br]"); + format!("{content}[br][br]{notes}") + } else { + content + } +} - let bbcode = match node { - Root(root) => walk_nodes(&root.children, definitions, "[br][br]"), +/// Manages the context needed to convert Markdown AST to Godot-compatible BBCode. +pub struct BBCodeConverter<'a> { + /// Link/image references from the Markdown AST. Key is the identifier, value is the URL. + link_reference_map: &'a HashMap<&'a str, &'a str>, - InlineCode(md::InlineCode { value, .. }) => format!("[code]{value}[/code]"), + /// Footnote label -> numeric index. + footnote_map: HashMap, - Delete(delete) => format!("[s]{}[/s]", walk_nodes(&delete.children, definitions, "")), + /// Footnotes (index -> rendered text), sorted by index. + footnote_defs: BTreeMap, - Emphasis(emphasis) => format!("[i]{}[/i]", walk_nodes(&emphasis.children, definitions, "")), + /// Current footnote index (i.e. the index last used, before incrementing). + current_footnote_index: usize, +} - Image(md::Image { url, .. }) => format!("[img]{url}[/img]",), +// Given a Vec of Strings, if the Vec is empty, return None. Otherwise, join the strings +// with a separator and return the result. +fn join_if_not_empty(strings: &[String], sep: &str) -> Option { + if strings.is_empty() { + None + } else { + Some(strings.join(sep)) + } +} - ImageReference(image) => { - format!( - "[img]{}[/img]", - definitions.get(&&*image.identifier).unwrap() - ) +impl<'a> BBCodeConverter<'a> { + /// Creates a new converter with the provided link/image definitions. + pub fn new(link_reference_map: &'a HashMap<&'a str, &'a str>) -> Self { + Self { + link_reference_map, + footnote_map: HashMap::new(), + footnote_defs: BTreeMap::new(), + current_footnote_index: 0, } + } - Link(md::Link { url, children, .. }) => { - format!("[url={url}]{}[/url]", walk_nodes(children, definitions, "")) - } + /// Walk an AST node and return its BBCode. Returns `None` if the node should be + /// ignored. + /// + /// `level` is used for nesting (e.g. lists). + pub fn walk_node(&mut self, node: &md::Node, level: usize) -> Option { + use md::Node::*; - LinkReference(md::LinkReference { - identifier, - children, - .. - }) => format!( - "[url={}]{}[/url]", - definitions.get(&&**identifier).unwrap(), - walk_nodes(children, definitions, "") - ), + let result = match node { + // Root node: treat children as top-level blocks. + // We join each block with [br][br], a double line break. + Root(md::Root { children, .. }) => { + let block_strs: Vec<_> = children + .iter() + .filter_map(|child| self.walk_node(child, level)) + .collect(); - Strong(strong) => format!("[b]{}[/b]", walk_nodes(&strong.children, definitions, "")), + join_if_not_empty(&block_strs, "[br][br]")? + } - Text(text) => text.value.clone(), + // Paragraph: gather inline children as a single line. + Paragraph(md::Paragraph { children, .. }) => self.walk_inline_nodes(children, level), - // TODO: more langs? - Code(md::Code { value, .. }) => format!("[codeblock]{value}[/codeblock]"), + // Inline code -> [code]...[/code] + InlineCode(md::InlineCode { value, .. }) => format!("[code]{value}[/code]"), - Paragraph(paragraph) => walk_nodes(¶graph.children, definitions, ""), + // Strikethrough -> [s]...[/s] + Delete(md::Delete { children, .. }) => { + let inner = self.walk_inline_nodes(children, level); + format!("[s]{inner}[/s]") + } - // BBCode supports lists, but docs don't. - List(_) | Blockquote(_) | FootnoteReference(_) | FootnoteDefinition(_) | Table(_) => { - String::new() - } + // Italic -> [i]...[/i] + Emphasis(md::Emphasis { children, .. }) => { + let inner = self.walk_inline_nodes(children, level); + format!("[i]{inner}[/i]") + } - Html(html) => html.value.clone(), + // Bold -> [b]...[/b] + Strong(md::Strong { children, .. }) => { + let inner = self.walk_inline_nodes(children, level); + format!("[b]{inner}[/b]") + } - _ => walk_nodes(node.children()?, definitions, ""), - }; + // Plain text -> just the text, with newlines replaced by spaces. + Text(md::Text { value, .. }) => value.replace("\n", " "), - Some(bbcode) -} + // Heading -> single line, "fake" heading with [b]...[/b] + Heading(md::Heading { children, .. }) => { + let inner = self.walk_inline_nodes(children, level); + format!("[b]{inner}[/b]") + } -/// Calls [`walk_node`] over every node it receives, joining them with the supplied separator. -fn walk_nodes(nodes: &[md::Node], definitions: &HashMap<&str, &str>, separator: &str) -> String { - nodes - .iter() - .filter_map(|n| walk_node(n, definitions)) - .collect::>() - .join(separator) + // Blockquote -> each child is effectively a block. We gather them with a single + // [br] in between, then prefix each resulting line with "> ". + Blockquote(md::Blockquote { children, .. }) => { + let child_blocks: Vec<_> = children + .iter() + .filter_map(|child| self.walk_node(child, level)) + .collect(); + let content = child_blocks.join("[br]"); // Each child is a block. + + // Prefix each line with "> ". + let mut out = String::new(); + for (i, line) in content.split("[br]").enumerate() { + if i > 0 { + out.push_str("[br]"); + } + out.push_str("> "); + out.push_str(line); + } + out + } + + // Code block -> [codeblock lang=??]...[/codeblock] + Code(md::Code { value, lang, .. }) => { + let maybe_lang = lang + .as_ref() + .map(|l| format!(" lang={l}")) + .unwrap_or_default(); + format!("[codeblock{maybe_lang}]{value}[/codeblock]") + } + + // List -> each item is on its own line with indentation. + // For ordered lists, we use a counter we increment for each item. + // For unordered lists, we use '•'. + List(md::List { + ordered, + start, + children, + .. + }) => { + let indent = " ".repeat(level * 4); + let mut counter = start.unwrap_or(0); + + let mut lines = Vec::new(); + for item_node in children.iter() { + if let md::Node::ListItem(item) = item_node { + // Converts the item's children. These may be paragraphs or sub-lists, etc. + // We join multiple paragraphs in the same item with [br]. + let item_str = self.walk_nodes_as_block(&item.children, level + 1); + let bullet = if *ordered { + counter += 1; + format!("{counter}.") + } else { + "•".to_string() + }; + let checkbox = match item.checked { + Some(true) => "[x] ", + Some(false) => "[ ] ", + None => "", + }; + + lines.push(format!("{indent}{bullet} {checkbox}{item_str}")); + } + } + + join_if_not_empty(&lines, "[br]")? + } + + // Footnote reference -> a superscript number. + FootnoteReference(md::FootnoteReference { label, .. }) => { + if let Some(label) = label { + let idx = *self.footnote_map.entry(label.clone()).or_insert_with(|| { + self.current_footnote_index += 1; + self.current_footnote_index + }); + Self::superscript(idx) + } else { + return None; + } + } + + // Footnote definition -> keep track of it, but produce no output here. + FootnoteDefinition(md::FootnoteDefinition { + label, children, .. + }) => { + if let Some(label) = label { + let idx = *self.footnote_map.entry(label.clone()).or_insert_with(|| { + self.current_footnote_index += 1; + self.current_footnote_index + }); + let def_content = self.walk_nodes_as_block(children, level); + self.footnote_defs.insert(idx, def_content); + } + + return None; + } + + // Image -> [url=URL]URL[/url] + Image(md::Image { url, .. }) => format!("[url={url}]{url}[/url]"), + + // Reference-style image -> [url=URL]URL[/url] + ImageReference(md::ImageReference { identifier, .. }) => { + let url = self.link_reference_map.get(&**identifier).unwrap_or(&""); + format!("[url={url}]{url}[/url]") + } + + // Explicit link -> [url=URL]...[/url] + Link(md::Link { url, children, .. }) => { + let inner = self.walk_inline_nodes(children, level); + format!("[url={url}]{inner}[/url]") + } + + // Reference-style link -> [url=URL]...[/url] + LinkReference(md::LinkReference { + identifier, + children, + .. + }) => { + let url = self.link_reference_map.get(&**identifier).unwrap_or(&""); + let inner = self.walk_inline_nodes(children, level); + format!("[url={url}]{inner}[/url]") + } + + // Table: approximate by reading rows as block lines. + Table(md::Table { children, .. }) => { + let rows: Vec = children + .iter() + .filter_map(|row| self.walk_node(row, level)) + .collect(); + + join_if_not_empty(&rows, "[br]")? + } + + // TableRow -> gather cells separated by " | ". + md::Node::TableRow(md::TableRow { children, .. }) => { + let cells: Vec = children + .iter() + .filter_map(|cell| self.walk_node(cell, level)) + .collect(); + cells.join(" | ") + } + + // TableCell -> treat as inline. + md::Node::TableCell(md::TableCell { children, .. }) => { + self.walk_inline_nodes(children, level) + } + + // Raw HTML -> output as-is. + Html(md::Html { value, .. }) => value.clone(), + + // Hard line break -> single line break, with indentation if needed. + Break(_) => format!("[br]{}", " ".repeat(level * 4)), + + // Fallback: just walk children. + _ => { + let children = node.children()?; + self.walk_inline_nodes(children, level) + } + }; + + Some(result) + } + + /// Collects multiple sibling nodes that might be block-level (list items, etc.), + /// joining them with `[br]`. Ignores nodes that return `None`. If all nodes return + /// `None`, returns an empty string, as if the block was empty, since this function + /// is called when we expect a block of content, even if it's empty. + fn walk_nodes_as_block(&mut self, nodes: &[md::Node], level: usize) -> String { + let mut pieces = Vec::new(); + for node in nodes { + if let Some(s) = self.walk_node(node, level) { + pieces.push(s); + } + } + pieces.join("[br]") + } + + /// Gathers children as an inline sequence: no forced breaks between them. Ignores + /// nodes that return `None`. If all nodes return `None`, returns an empty string, + /// as if the block was empty, since this function is called when we expect a block + /// of content, even if it's empty. + fn walk_inline_nodes(&mut self, children: &[md::Node], level: usize) -> String { + let mut out = String::new(); + for child in children { + if let Some(s) = self.walk_node(child, level) { + out.push_str(&s); + } + } + out + } + + /// Convert a numeric index into a Unicode superscript (e.g. 123 -> ¹²³). + pub fn superscript(idx: usize) -> String { + const SUPS: &[char] = &['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹']; + idx.to_string() + .chars() + .filter_map(|c| c.to_digit(10).map(|d| SUPS[d as usize])) + .collect() + } } diff --git a/itest/rust/src/register_tests/register_docs_test.rs b/itest/rust/src/register_tests/register_docs_test.rs index 848e9ab95..a97b83756 100644 --- a/itest/rust/src/register_tests/register_docs_test.rs +++ b/itest/rust/src/register_tests/register_docs_test.rs @@ -12,6 +12,13 @@ use godot::prelude::*; /// *documented* ~ **documented** ~ [AABB] < [pr](https://github.com/godot-rust/gdext/pull/748) /// +/// This is a paragraph. It has some text in it. It's a paragraph. It's quite +/// long, and wraps multiple lines. It is describing the struct `Player`. Or +/// maybe perhaps it's describing the module. It's hard to say, really. It even +/// has some code in it: `let x = 5;`. And some more code: `let y = 6;`. And a +/// bunch of **bold** and *italic* text with _different_ ways to do it. Don't +/// forget about [links](https://example.com). +/// /// a few tests: /// /// headings: @@ -22,11 +29,17 @@ use godot::prelude::*; /// /// - lists /// - like this +/// - with sublists +/// that are multiline +/// - and subsublists +/// - and list items /// * maybe with `*` as well /// +/// [reference-style link][somelink] +/// /// links with back-references: /// -/// Blah blah [^foo] +/// Blah blah[^foo] Also same reference[^foo] /// [^foo]: https://example.org /// /// footnotes: @@ -34,6 +47,18 @@ use godot::prelude::*; /// We cannot florbinate the glorb[^florb] /// [^florb]: because the glorb doesn't flibble. /// +/// Third note in order of use[^1] and fourth [^bignote] +/// +/// [^1]: This is the third footnote in order of definition. +/// [^bignote]: Fourth footnote in order of definition. +/// [^biggernote]: This is the fifth footnote in order of definition. +/// +/// Fifth note in order of use. [^someothernote] +/// +/// [^someothernote]: sixth footnote in order of definition. +/// +/// Sixth footnote in order of use. [^biggernote] +/// /// task lists: /// /// We must ensure that we've completed @@ -48,7 +73,9 @@ use godot::prelude::*; /// /// images: /// -/// ![Image](http://url/a.png) +/// ![Image](https://godotengine.org/assets/press/logo_small_color_light.png) +/// +/// ![Image][image] /// /// blockquotes: /// @@ -58,6 +85,9 @@ use godot::prelude::*; /// /// 1. thing one /// 2. thing two +/// 1. thing two point one +/// 2. thing two point two +/// 3. thing two point three /// /// /// Something here < this is technically header syntax @@ -75,6 +105,23 @@ use godot::prelude::*; /// static main: u64 = 0x31c0678b10; /// ``` /// +/// ```gdscript +/// extends Node +/// +/// func _ready(): +/// print("Hello, world!") +/// ``` +/// +/// ```csharp +/// using Godot; +/// +/// public class Player : Node2D +/// { +/// [Export] +/// public float Speed = 400.0f; +/// } +/// ``` +/// /// Some HTML to make sure it's properly escaped: /// ///
<- this is inline HTML @@ -93,6 +140,9 @@ use godot::prelude::*; /// /// connect /// these +/// +/// [somelink]: https://example.com +/// [image]: https://godotengine.org/assets/press/logo_small_color_dark.png #[derive(GodotClass)] #[class(base=Node)] pub struct FairlyDocumented { diff --git a/itest/rust/src/register_tests/res/registered_docs.xml b/itest/rust/src/register_tests/res/registered_docs.xml index 55eefd5f8..0fab6c697 100644 --- a/itest/rust/src/register_tests/res/registered_docs.xml +++ b/itest/rust/src/register_tests/res/registered_docs.xml @@ -1,13 +1,21 @@ [i]documented[/i] ~ [b]documented[/b] ~ [AABB] < [url=https://github.com/godot-rust/gdext/pull/748]pr[/url] -[i]documented[/i] ~ [b]documented[/b] ~ [AABB] < [url=https://github.com/godot-rust/gdext/pull/748]pr[/url][br][br]a few tests:[br][br]headings:[br][br]Some heading[br][br]lists:[br][br][br][br][br][br]links with back-references:[br][br]Blah blah [br][br][br][br]footnotes:[br][br]We cannot florbinate the glorb[br][br][br][br]task lists:[br][br]We must ensure that we've completed[br][br][br][br]tables:[br][br][br][br]images:[br][br][img]http://url/a.png[/img][br][br]blockquotes:[br][br][br][br]ordered list:[br][br][br][br]Something here < this is technically header syntax[br][br]And here[br][br]smart punctuation[br][br]codeblocks:[br][br][codeblock]#![no_main] +[i]documented[/i] ~ [b]documented[/b] ~ [AABB] < [url=https://github.com/godot-rust/gdext/pull/748]pr[/url][br][br]This is a paragraph. It has some text in it. It's a paragraph. It's quite long, and wraps multiple lines. It is describing the struct [code]Player[/code]. Or maybe perhaps it's describing the module. It's hard to say, really. It even has some code in it: [code]let x = 5;[/code]. And some more code: [code]let y = 6;[/code]. And a bunch of [b]bold[/b] and [i]italic[/i] text with [i]different[/i] ways to do it. Don't forget about [url=https://example.com]links[/url].[br][br]a few tests:[br][br]headings:[br][br][b]Some heading[/b][br][br]lists:[br][br]• lists[br]• like this[br] • with sublists[br] that are multiline[br] • and subsublists[br]• and list items[br][br]• maybe with [code]*[/code] as well[br][br][url=https://example.com]reference-style link[/url][br][br]links with back-references:[br][br]Blah blah¹ Also same reference¹[br][br]footnotes:[br][br]We cannot florbinate the glorb²[br][br]Third note in order of use³ and fourth ⁴[br][br]Fifth note in order of use. ⁶[br][br]Sixth footnote in order of use. ⁵[br][br]task lists:[br][br]We must ensure that we've completed[br][br]• [ ] task 1[br]• [x] task 2[br][br]tables:[br][br]Header1 | Header2[br]abc | def[br][br]images:[br][br][url=https://godotengine.org/assets/press/logo_small_color_light.png]https://godotengine.org/assets/press/logo_small_color_light.png[/url][br][br][url=https://godotengine.org/assets/press/logo_small_color_dark.png]https://godotengine.org/assets/press/logo_small_color_dark.png[/url][br][br]blockquotes:[br][br]> Some cool thing[br][br]ordered list:[br][br]1. thing one[br]2. thing two[br] 1. thing two point one[br] 2. thing two point two[br] 3. thing two point three[br][br][b]Something here < this is technically header syntax[/b][br][br]And here[br][br]smart punctuation[br][br]codeblocks:[br][br][codeblock lang=rust]#![no_main] #[link_section=\".text\"] #[no_mangle] -static main: u64 = 0x31c0678b10;[/codeblock][br][br]Some HTML to make sure it's properly escaped:[br][br]<br/> <- this is inline HTML[br][br]<br/> <- not considered HTML (manually escaped)[br][br][code]inline<br/>code[/code][br][br][codeblock]<div> +static main: u64 = 0x31c0678b10;[/codeblock][br][br][codeblock lang=gdscript]extends Node + +func _ready(): + print(\"Hello, world!\")[/codeblock][br][br][codeblock lang=csharp]using Godot; + +public class Player : Node2D +{ + [Export] + public float Speed = 400.0f; +}[/codeblock][br][br]Some HTML to make sure it's properly escaped:[br][br]<br/> <- this is inline HTML[br][br]<br/> <- not considered HTML (manually escaped)[br][br][code]inline<br/>code[/code][br][br][codeblock lang=html]<div> code&nbsp;block -</div>[/codeblock][br][br][url=https://www.google.com/search?q=2+%2B+2+<+5]Google: 2 + 2 < 5[/url][br][br]connect -these +</div>[/codeblock][br][br][url=https://www.google.com/search?q=2+%2B+2+<+5]Google: 2 + 2 < 5[/url][br][br]connect these[br][br]¹ [url=https://example.org]https://example.org[/url][br]² because the glorb doesn't flibble.[br]³ This is the third footnote in order of definition.[br]⁴ Fourth footnote in order of definition.[br]⁵ This is the fifth footnote in order of definition.[br]⁶ sixth footnote in order of definition.