pub fn emit_phpdoc(out: &mut String, doc: &str, indent: &str) {
if doc.is_empty() {
return;
}
let sections = parse_rustdoc_sections(doc);
let any_section = sections.arguments.is_some()
|| sections.returns.is_some()
|| sections.errors.is_some()
|| sections.example.is_some();
let body = if any_section {
render_phpdoc_sections(§ions, "KreuzbergException")
} else {
doc.to_string()
};
out.push_str(indent);
out.push_str("/**\n");
for line in body.lines() {
out.push_str(indent);
out.push_str(" * ");
out.push_str(&escape_phpdoc_line(line));
out.push('\n');
}
out.push_str(indent);
out.push_str(" */\n");
}
fn escape_phpdoc_line(s: &str) -> String {
s.replace("*/", "* /")
}
pub fn emit_csharp_doc(out: &mut String, doc: &str, indent: &str) {
if doc.is_empty() {
return;
}
let sections = parse_rustdoc_sections(doc);
let any_section = sections.arguments.is_some()
|| sections.returns.is_some()
|| sections.errors.is_some()
|| sections.example.is_some();
if !any_section {
out.push_str(indent);
out.push_str("/// <summary>\n");
for line in doc.lines() {
out.push_str(indent);
out.push_str("/// ");
out.push_str(&escape_csharp_doc_line(line));
out.push('\n');
}
out.push_str(indent);
out.push_str("/// </summary>\n");
return;
}
let rendered = render_csharp_xml_sections(§ions, "KreuzbergException");
for line in rendered.lines() {
out.push_str(indent);
out.push_str("/// ");
out.push_str(line);
out.push('\n');
}
}
fn escape_csharp_doc_line(s: &str) -> String {
s.replace('&', "&").replace('<', "<").replace('>', ">")
}
pub fn emit_elixir_doc(out: &mut String, doc: &str) {
if doc.is_empty() {
return;
}
out.push_str("@doc \"\"\"\n");
for line in doc.lines() {
out.push_str(&escape_elixir_doc_line(line));
out.push('\n');
}
out.push_str("\"\"\"\n");
}
pub fn emit_rustdoc(out: &mut String, doc: &str, indent: &str) {
if doc.is_empty() {
return;
}
for line in doc.lines() {
out.push_str(indent);
out.push_str("/// ");
out.push_str(line);
out.push('\n');
}
}
fn escape_elixir_doc_line(s: &str) -> String {
s.replace("\"\"\"", "\"\" \"")
}
pub fn emit_roxygen(out: &mut String, doc: &str) {
if doc.is_empty() {
return;
}
for line in doc.lines() {
out.push_str("#' ");
out.push_str(line);
out.push('\n');
}
}
pub fn emit_swift_doc(out: &mut String, doc: &str, indent: &str) {
if doc.is_empty() {
return;
}
for line in doc.lines() {
out.push_str(indent);
out.push_str("/// ");
out.push_str(line);
out.push('\n');
}
}
pub fn emit_javadoc(out: &mut String, doc: &str, indent: &str) {
if doc.is_empty() {
return;
}
out.push_str(indent);
out.push_str("/**\n");
for line in doc.lines() {
let escaped = escape_javadoc_line(line);
let trimmed = escaped.trim_end();
if trimmed.is_empty() {
out.push_str(indent);
out.push_str(" *\n");
} else {
out.push_str(indent);
out.push_str(" * ");
out.push_str(trimmed);
out.push('\n');
}
}
out.push_str(indent);
out.push_str(" */\n");
}
pub fn emit_kdoc(out: &mut String, doc: &str, indent: &str) {
if doc.is_empty() {
return;
}
out.push_str(indent);
out.push_str("/**\n");
for line in doc.lines() {
let trimmed = line.trim_end();
if trimmed.is_empty() {
out.push_str(indent);
out.push_str(" *\n");
} else {
out.push_str(indent);
out.push_str(" * ");
out.push_str(trimmed);
out.push('\n');
}
}
out.push_str(indent);
out.push_str(" */\n");
}
pub fn emit_dartdoc(out: &mut String, doc: &str, indent: &str) {
if doc.is_empty() {
return;
}
for line in doc.lines() {
out.push_str(indent);
out.push_str("/// ");
out.push_str(line);
out.push('\n');
}
}
pub fn emit_gleam_doc(out: &mut String, doc: &str, indent: &str) {
if doc.is_empty() {
return;
}
for line in doc.lines() {
out.push_str(indent);
out.push_str("/// ");
out.push_str(line);
out.push('\n');
}
}
pub fn emit_zig_doc(out: &mut String, doc: &str, indent: &str) {
if doc.is_empty() {
return;
}
for line in doc.lines() {
out.push_str(indent);
out.push_str("/// ");
out.push_str(line);
out.push('\n');
}
}
fn escape_javadoc_line(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '`' {
let mut code = String::new();
for c in chars.by_ref() {
if c == '`' {
break;
}
code.push(c);
}
result.push_str("{@code ");
result.push_str(&escape_javadoc_html_entities(&code));
result.push('}');
} else if ch == '<' {
result.push_str("<");
} else if ch == '>' {
result.push_str(">");
} else if ch == '&' {
result.push_str("&");
} else {
result.push(ch);
}
}
result
}
fn escape_javadoc_html_entities(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'&' => out.push_str("&"),
other => out.push(other),
}
}
out
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct RustdocSections {
pub summary: String,
pub arguments: Option<String>,
pub returns: Option<String>,
pub errors: Option<String>,
pub panics: Option<String>,
pub safety: Option<String>,
pub example: Option<String>,
}
pub fn parse_rustdoc_sections(doc: &str) -> RustdocSections {
if doc.trim().is_empty() {
return RustdocSections::default();
}
let mut summary = String::new();
let mut arguments: Option<String> = None;
let mut returns: Option<String> = None;
let mut errors: Option<String> = None;
let mut panics: Option<String> = None;
let mut safety: Option<String> = None;
let mut example: Option<String> = None;
let mut current: Option<&'static str> = None;
let mut buf = String::new();
let mut in_fence = false;
let flush = |target: Option<&'static str>,
buf: &mut String,
summary: &mut String,
arguments: &mut Option<String>,
returns: &mut Option<String>,
errors: &mut Option<String>,
panics: &mut Option<String>,
safety: &mut Option<String>,
example: &mut Option<String>| {
let body = std::mem::take(buf).trim().to_string();
if body.is_empty() {
return;
}
match target {
None => {
if !summary.is_empty() {
summary.push('\n');
}
summary.push_str(&body);
}
Some("arguments") => *arguments = Some(body),
Some("returns") => *returns = Some(body),
Some("errors") => *errors = Some(body),
Some("panics") => *panics = Some(body),
Some("safety") => *safety = Some(body),
Some("example") => *example = Some(body),
_ => {}
}
};
for line in doc.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("```") {
in_fence = !in_fence;
buf.push_str(line);
buf.push('\n');
continue;
}
if !in_fence {
if let Some(rest) = trimmed.strip_prefix("# ") {
let head = rest.trim().to_ascii_lowercase();
let target = match head.as_str() {
"arguments" | "args" => Some("arguments"),
"returns" => Some("returns"),
"errors" => Some("errors"),
"panics" => Some("panics"),
"safety" => Some("safety"),
"example" | "examples" => Some("example"),
_ => None,
};
if target.is_some() {
flush(
current,
&mut buf,
&mut summary,
&mut arguments,
&mut returns,
&mut errors,
&mut panics,
&mut safety,
&mut example,
);
current = target;
continue;
}
}
}
buf.push_str(line);
buf.push('\n');
}
flush(
current,
&mut buf,
&mut summary,
&mut arguments,
&mut returns,
&mut errors,
&mut panics,
&mut safety,
&mut example,
);
RustdocSections {
summary,
arguments,
returns,
errors,
panics,
safety,
example,
}
}
pub fn parse_arguments_bullets(body: &str) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = Vec::new();
for raw in body.lines() {
let line = raw.trim_end();
let trimmed = line.trim_start();
let is_bullet = trimmed.starts_with("* ") || trimmed.starts_with("- ");
if is_bullet {
let after = &trimmed[2..];
let (name, desc) = if let Some(idx) = after.find(" - ") {
(after[..idx].trim(), after[idx + 3..].trim())
} else if let Some(idx) = after.find(": ") {
(after[..idx].trim(), after[idx + 2..].trim())
} else if let Some(idx) = after.find(' ') {
(after[..idx].trim(), after[idx + 1..].trim())
} else {
(after.trim(), "")
};
let name = name.trim_matches('`').trim_matches('*').to_string();
out.push((name, desc.to_string()));
} else if !trimmed.is_empty() {
if let Some(last) = out.last_mut() {
if !last.1.is_empty() {
last.1.push(' ');
}
last.1.push_str(trimmed);
}
}
}
out
}
pub fn replace_fence_lang(body: &str, lang_replacement: &str) -> String {
let mut out = String::with_capacity(body.len());
for line in body.lines() {
let trimmed = line.trim_start();
if let Some(rest) = trimmed.strip_prefix("```") {
let indent = &line[..line.len() - trimmed.len()];
let after_lang = rest.find(',').map(|i| &rest[i..]).unwrap_or("");
out.push_str(indent);
out.push_str("```");
out.push_str(lang_replacement);
out.push_str(after_lang);
out.push('\n');
} else {
out.push_str(line);
out.push('\n');
}
}
out.trim_end_matches('\n').to_string()
}
pub fn render_jsdoc_sections(sections: &RustdocSections) -> String {
let mut out = String::new();
if !sections.summary.is_empty() {
out.push_str(§ions.summary);
}
if let Some(args) = sections.arguments.as_deref() {
for (name, desc) in parse_arguments_bullets(args) {
if !out.is_empty() {
out.push('\n');
}
if desc.is_empty() {
out.push_str(&format!("@param {name}"));
} else {
out.push_str(&format!("@param {name} - {desc}"));
}
}
}
if let Some(ret) = sections.returns.as_deref() {
if !out.is_empty() {
out.push('\n');
}
out.push_str(&format!("@returns {}", ret.trim()));
}
if let Some(err) = sections.errors.as_deref() {
if !out.is_empty() {
out.push('\n');
}
out.push_str(&format!("@throws {}", err.trim()));
}
if let Some(example) = sections.example.as_deref() {
if !out.is_empty() {
out.push('\n');
}
out.push_str("@example\n");
out.push_str(&replace_fence_lang(example.trim(), "typescript"));
}
out
}
pub fn render_javadoc_sections(sections: &RustdocSections, throws_class: &str) -> String {
let mut out = String::new();
if !sections.summary.is_empty() {
out.push_str(§ions.summary);
}
if let Some(args) = sections.arguments.as_deref() {
for (name, desc) in parse_arguments_bullets(args) {
if !out.is_empty() {
out.push('\n');
}
if desc.is_empty() {
out.push_str(&format!("@param {name}"));
} else {
out.push_str(&format!("@param {name} {desc}"));
}
}
}
if let Some(ret) = sections.returns.as_deref() {
if !out.is_empty() {
out.push('\n');
}
out.push_str(&format!("@return {}", ret.trim()));
}
if let Some(err) = sections.errors.as_deref() {
if !out.is_empty() {
out.push('\n');
}
out.push_str(&format!("@throws {throws_class} {}", err.trim()));
}
out
}
pub fn render_csharp_xml_sections(sections: &RustdocSections, exception_class: &str) -> String {
let mut out = String::new();
out.push_str("<summary>\n");
let summary = if sections.summary.is_empty() {
""
} else {
sections.summary.as_str()
};
for line in summary.lines() {
out.push_str(line);
out.push('\n');
}
out.push_str("</summary>");
if let Some(args) = sections.arguments.as_deref() {
for (name, desc) in parse_arguments_bullets(args) {
out.push('\n');
if desc.is_empty() {
out.push_str(&format!("<param name=\"{name}\"></param>"));
} else {
out.push_str(&format!("<param name=\"{name}\">{desc}</param>"));
}
}
}
if let Some(ret) = sections.returns.as_deref() {
out.push('\n');
out.push_str(&format!("<returns>{}</returns>", ret.trim()));
}
if let Some(err) = sections.errors.as_deref() {
out.push('\n');
out.push_str(&format!(
"<exception cref=\"{exception_class}\">{}</exception>",
err.trim()
));
}
if let Some(example) = sections.example.as_deref() {
out.push('\n');
out.push_str("<example><code language=\"csharp\">\n");
for line in example.lines() {
let t = line.trim_start();
if t.starts_with("```") {
continue;
}
out.push_str(line);
out.push('\n');
}
out.push_str("</code></example>");
}
out
}
pub fn render_phpdoc_sections(sections: &RustdocSections, throws_class: &str) -> String {
let mut out = String::new();
if !sections.summary.is_empty() {
out.push_str(§ions.summary);
}
if let Some(args) = sections.arguments.as_deref() {
for (name, desc) in parse_arguments_bullets(args) {
if !out.is_empty() {
out.push('\n');
}
if desc.is_empty() {
out.push_str(&format!("@param mixed ${name}"));
} else {
out.push_str(&format!("@param mixed ${name} {desc}"));
}
}
}
if let Some(ret) = sections.returns.as_deref() {
if !out.is_empty() {
out.push('\n');
}
out.push_str(&format!("@return {}", ret.trim()));
}
if let Some(err) = sections.errors.as_deref() {
if !out.is_empty() {
out.push('\n');
}
out.push_str(&format!("@throws {throws_class} {}", err.trim()));
}
if let Some(example) = sections.example.as_deref() {
if !out.is_empty() {
out.push('\n');
}
out.push_str(&replace_fence_lang(example.trim(), "php"));
}
out
}
pub fn render_doxygen_sections(sections: &RustdocSections) -> String {
let mut out = String::new();
if !sections.summary.is_empty() {
out.push_str(§ions.summary);
}
if let Some(args) = sections.arguments.as_deref() {
for (name, desc) in parse_arguments_bullets(args) {
if !out.is_empty() {
out.push('\n');
}
if desc.is_empty() {
out.push_str(&format!("\\param {name}"));
} else {
out.push_str(&format!("\\param {name} {desc}"));
}
}
}
if let Some(ret) = sections.returns.as_deref() {
if !out.is_empty() {
out.push('\n');
}
out.push_str(&format!("\\return {}", ret.trim()));
}
if let Some(err) = sections.errors.as_deref() {
if !out.is_empty() {
out.push('\n');
}
out.push_str(&format!("Errors: {}", err.trim()));
}
if let Some(example) = sections.example.as_deref() {
if !out.is_empty() {
out.push('\n');
}
out.push_str("\\code\n");
for line in example.lines() {
let t = line.trim_start();
if t.starts_with("```") {
continue;
}
out.push_str(line);
out.push('\n');
}
out.push_str("\\endcode");
}
out
}
pub fn doc_first_paragraph_joined(doc: &str) -> String {
doc.lines()
.take_while(|l| !l.trim().is_empty())
.map(str::trim)
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_emit_phpdoc() {
let mut out = String::new();
emit_phpdoc(&mut out, "Simple documentation", " ");
assert!(out.contains("/**"));
assert!(out.contains("Simple documentation"));
assert!(out.contains("*/"));
}
#[test]
fn test_phpdoc_escaping() {
let mut out = String::new();
emit_phpdoc(&mut out, "Handle */ sequences", "");
assert!(out.contains("Handle * / sequences"));
}
#[test]
fn test_emit_csharp_doc() {
let mut out = String::new();
emit_csharp_doc(&mut out, "C# documentation", " ");
assert!(out.contains("<summary>"));
assert!(out.contains("C# documentation"));
assert!(out.contains("</summary>"));
}
#[test]
fn test_csharp_xml_escaping() {
let mut out = String::new();
emit_csharp_doc(&mut out, "foo < bar & baz > qux", "");
assert!(out.contains("foo < bar & baz > qux"));
}
#[test]
fn test_emit_elixir_doc() {
let mut out = String::new();
emit_elixir_doc(&mut out, "Elixir documentation");
assert!(out.contains("@doc \"\"\""));
assert!(out.contains("Elixir documentation"));
assert!(out.contains("\"\"\""));
}
#[test]
fn test_elixir_heredoc_escaping() {
let mut out = String::new();
emit_elixir_doc(&mut out, "Handle \"\"\" sequences");
assert!(out.contains("Handle \"\" \" sequences"));
}
#[test]
fn test_emit_roxygen() {
let mut out = String::new();
emit_roxygen(&mut out, "R documentation");
assert!(out.contains("#' R documentation"));
}
#[test]
fn test_emit_swift_doc() {
let mut out = String::new();
emit_swift_doc(&mut out, "Swift documentation", " ");
assert!(out.contains("/// Swift documentation"));
}
#[test]
fn test_emit_javadoc() {
let mut out = String::new();
emit_javadoc(&mut out, "Java documentation", " ");
assert!(out.contains("/**"));
assert!(out.contains("Java documentation"));
assert!(out.contains("*/"));
}
#[test]
fn test_emit_kdoc() {
let mut out = String::new();
emit_kdoc(&mut out, "Kotlin documentation", " ");
assert!(out.contains("/**"));
assert!(out.contains("Kotlin documentation"));
assert!(out.contains("*/"));
}
#[test]
fn test_emit_dartdoc() {
let mut out = String::new();
emit_dartdoc(&mut out, "Dart documentation", " ");
assert!(out.contains("/// Dart documentation"));
}
#[test]
fn test_emit_gleam_doc() {
let mut out = String::new();
emit_gleam_doc(&mut out, "Gleam documentation", " ");
assert!(out.contains("/// Gleam documentation"));
}
#[test]
fn test_emit_zig_doc() {
let mut out = String::new();
emit_zig_doc(&mut out, "Zig documentation", " ");
assert!(out.contains("/// Zig documentation"));
}
#[test]
fn test_empty_doc_skipped() {
let mut out = String::new();
emit_phpdoc(&mut out, "", "");
emit_csharp_doc(&mut out, "", "");
emit_elixir_doc(&mut out, "");
emit_roxygen(&mut out, "");
emit_kdoc(&mut out, "", "");
emit_dartdoc(&mut out, "", "");
emit_gleam_doc(&mut out, "", "");
emit_zig_doc(&mut out, "", "");
assert!(out.is_empty());
}
#[test]
fn test_doc_first_paragraph_joined_single_line() {
assert_eq!(doc_first_paragraph_joined("Simple doc."), "Simple doc.");
}
#[test]
fn test_doc_first_paragraph_joined_wrapped_sentence() {
let doc = "Convert HTML to Markdown,\nreturning a result.";
assert_eq!(
doc_first_paragraph_joined(doc),
"Convert HTML to Markdown, returning a result."
);
}
#[test]
fn test_doc_first_paragraph_joined_stops_at_blank_line() {
let doc = "First paragraph.\nStill first.\n\nSecond paragraph.";
assert_eq!(doc_first_paragraph_joined(doc), "First paragraph. Still first.");
}
#[test]
fn test_doc_first_paragraph_joined_empty() {
assert_eq!(doc_first_paragraph_joined(""), "");
}
#[test]
fn test_parse_rustdoc_sections_basic() {
let doc = "Extracts text from a file.\n\n# Arguments\n\n* `path` - The file path.\n\n# Returns\n\nThe extracted text.\n\n# Errors\n\nReturns `KreuzbergError` on failure.";
let sections = parse_rustdoc_sections(doc);
assert_eq!(sections.summary, "Extracts text from a file.");
assert_eq!(sections.arguments.as_deref(), Some("* `path` - The file path."));
assert_eq!(sections.returns.as_deref(), Some("The extracted text."));
assert_eq!(sections.errors.as_deref(), Some("Returns `KreuzbergError` on failure."));
assert!(sections.panics.is_none());
}
#[test]
fn test_parse_rustdoc_sections_example_with_fence() {
let doc = "Run the thing.\n\n# Example\n\n```rust\nlet x = run();\n```";
let sections = parse_rustdoc_sections(doc);
assert_eq!(sections.summary, "Run the thing.");
assert!(sections.example.as_ref().unwrap().contains("```rust"));
assert!(sections.example.as_ref().unwrap().contains("let x = run();"));
}
#[test]
fn test_parse_rustdoc_sections_pound_inside_fence_is_not_a_heading() {
let doc = "Summary.\n\n# Example\n\n```bash\n# install deps\nrun --foo\n```";
let sections = parse_rustdoc_sections(doc);
assert_eq!(sections.summary, "Summary.");
assert!(sections.example.as_ref().unwrap().contains("# install deps"));
}
#[test]
fn test_parse_arguments_bullets_dash_separator() {
let body = "* `path` - The file path.\n* `config` - Optional configuration.";
let pairs = parse_arguments_bullets(body);
assert_eq!(pairs.len(), 2);
assert_eq!(pairs[0], ("path".to_string(), "The file path.".to_string()));
assert_eq!(pairs[1], ("config".to_string(), "Optional configuration.".to_string()));
}
#[test]
fn test_parse_arguments_bullets_continuation_line() {
let body = "* `path` - The file path,\n resolved relative to cwd.\n* `mode` - Open mode.";
let pairs = parse_arguments_bullets(body);
assert_eq!(pairs.len(), 2);
assert_eq!(pairs[0].1, "The file path, resolved relative to cwd.");
}
#[test]
fn test_replace_fence_lang_rust_to_typescript() {
let body = "```rust\nlet x = run();\n```";
let out = replace_fence_lang(body, "typescript");
assert!(out.starts_with("```typescript"));
assert!(out.contains("let x = run();"));
}
#[test]
fn test_replace_fence_lang_preserves_attrs() {
let body = "```rust,no_run\nlet x = run();\n```";
let out = replace_fence_lang(body, "typescript");
assert!(out.starts_with("```typescript,no_run"));
}
#[test]
fn test_replace_fence_lang_no_fence_unchanged() {
let body = "Plain prose with `inline code`.";
let out = replace_fence_lang(body, "typescript");
assert_eq!(out, "Plain prose with `inline code`.");
}
fn fixture_sections() -> RustdocSections {
let doc = "Extracts text from a file.\n\n# Arguments\n\n* `path` - The file path.\n* `config` - Optional configuration.\n\n# Returns\n\nThe extracted text and metadata.\n\n# Errors\n\nReturns an error when the file is unreadable.\n\n# Example\n\n```rust\nlet result = extract(\"file.pdf\")?;\n```";
parse_rustdoc_sections(doc)
}
#[test]
fn test_render_jsdoc_sections() {
let sections = fixture_sections();
let out = render_jsdoc_sections(§ions);
assert!(out.starts_with("Extracts text from a file."));
assert!(out.contains("@param path - The file path."));
assert!(out.contains("@param config - Optional configuration."));
assert!(out.contains("@returns The extracted text and metadata."));
assert!(out.contains("@throws Returns an error when the file is unreadable."));
assert!(out.contains("@example"));
assert!(out.contains("```typescript"));
assert!(!out.contains("```rust"));
}
#[test]
fn test_render_javadoc_sections() {
let sections = fixture_sections();
let out = render_javadoc_sections(§ions, "KreuzbergRsException");
assert!(out.contains("@param path The file path."));
assert!(out.contains("@return The extracted text and metadata."));
assert!(out.contains("@throws KreuzbergRsException Returns an error when the file is unreadable."));
assert!(out.starts_with("Extracts text from a file."));
}
#[test]
fn test_render_csharp_xml_sections() {
let sections = fixture_sections();
let out = render_csharp_xml_sections(§ions, "KreuzbergException");
assert!(out.contains("<summary>\nExtracts text from a file.\n</summary>"));
assert!(out.contains("<param name=\"path\">The file path.</param>"));
assert!(out.contains("<returns>The extracted text and metadata.</returns>"));
assert!(out.contains("<exception cref=\"KreuzbergException\">"));
assert!(out.contains("<example><code language=\"csharp\">"));
assert!(out.contains("let result = extract"));
}
#[test]
fn test_render_phpdoc_sections() {
let sections = fixture_sections();
let out = render_phpdoc_sections(§ions, "KreuzbergException");
assert!(out.contains("@param mixed $path The file path."));
assert!(out.contains("@return The extracted text and metadata."));
assert!(out.contains("@throws KreuzbergException"));
assert!(out.contains("```php"));
}
#[test]
fn test_render_doxygen_sections() {
let sections = fixture_sections();
let out = render_doxygen_sections(§ions);
assert!(out.contains("\\param path The file path."));
assert!(out.contains("\\return The extracted text and metadata."));
assert!(out.contains("\\code"));
assert!(out.contains("\\endcode"));
}
}