Skip to main content

alef_codegen/
doc_emission.rs

1//! Language-native documentation comment emission.
2//! Provides standardized functions for emitting doc comments in different languages.
3
4/// Emit PHPDoc-style comments (/** ... */)
5/// Used for PHP classes, methods, and properties.
6///
7/// Translates rustdoc sections (`# Arguments` → `@param`,
8/// `# Returns` → `@return`, `# Errors` → `@throws`,
9/// `# Example` → ` ```php ` fence) via [`render_phpdoc_sections`].
10///
11/// `exception_class` is the PHP exception class name to use in `@throws` tags.
12pub fn emit_phpdoc(out: &mut String, doc: &str, indent: &str, exception_class: &str) {
13    if doc.is_empty() {
14        return;
15    }
16    let sections = parse_rustdoc_sections(doc);
17    let any_section = sections.arguments.is_some()
18        || sections.returns.is_some()
19        || sections.errors.is_some()
20        || sections.example.is_some();
21    let body = if any_section {
22        render_phpdoc_sections(&sections, exception_class)
23    } else {
24        doc.to_string()
25    };
26    out.push_str(indent);
27    out.push_str("/**\n");
28    for line in body.lines() {
29        out.push_str(indent);
30        out.push_str(" * ");
31        out.push_str(&escape_phpdoc_line(line));
32        out.push('\n');
33    }
34    out.push_str(indent);
35    out.push_str(" */\n");
36}
37
38/// Escape PHPDoc line: handle */ sequences that could close the comment early.
39fn escape_phpdoc_line(s: &str) -> String {
40    s.replace("*/", "* /")
41}
42
43/// Emit C# XML documentation comments (/// <summary> ... </summary>)
44/// Used for C# classes, structs, methods, and properties.
45///
46/// Translates rustdoc sections (`# Arguments` → `<param>`,
47/// `# Returns` → `<returns>`, `# Errors` → `<exception>`,
48/// `# Example` → `<example><code>`) via [`render_csharp_xml_sections`].
49///
50/// `exception_class` is the C# exception class name to use in `<exception cref="...">` tags.
51pub fn emit_csharp_doc(out: &mut String, doc: &str, indent: &str, exception_class: &str) {
52    if doc.is_empty() {
53        return;
54    }
55    let sections = parse_rustdoc_sections(doc);
56    let any_section = sections.arguments.is_some()
57        || sections.returns.is_some()
58        || sections.errors.is_some()
59        || sections.example.is_some();
60    if !any_section {
61        // Backwards-compatible path: plain `<summary>` for prose-only docs.
62        out.push_str(indent);
63        out.push_str("/// <summary>\n");
64        for line in doc.lines() {
65            out.push_str(indent);
66            out.push_str("/// ");
67            out.push_str(&escape_csharp_doc_line(line));
68            out.push('\n');
69        }
70        out.push_str(indent);
71        out.push_str("/// </summary>\n");
72        return;
73    }
74    let rendered = render_csharp_xml_sections(&sections, exception_class);
75    for line in rendered.lines() {
76        out.push_str(indent);
77        out.push_str("/// ");
78        // The rendered tags already contain the canonical chars; we only
79        // escape XML special chars that aren't part of our tag syntax. Since
80        // render_csharp_xml_sections produces well-formed XML, raw passthrough
81        // is correct.
82        out.push_str(line);
83        out.push('\n');
84    }
85}
86
87/// Escape C# XML doc line: handle XML special characters.
88fn escape_csharp_doc_line(s: &str) -> String {
89    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
90}
91
92/// Emit Elixir documentation comments (@doc)
93/// Used for Elixir modules and functions.
94pub fn emit_elixir_doc(out: &mut String, doc: &str) {
95    if doc.is_empty() {
96        return;
97    }
98    out.push_str("@doc \"\"\"\n");
99    for line in doc.lines() {
100        out.push_str(&escape_elixir_doc_line(line));
101        out.push('\n');
102    }
103    out.push_str("\"\"\"\n");
104}
105
106/// Emit Rust `///` documentation comments.
107///
108/// Used by alef backends that emit Rust source (e.g., the Rustler NIF crate,
109/// the swift-bridge wrapper crate, the FRB Dart bridge crate). Distinct from
110/// `emit_swift_doc` only by intent — the syntax is identical (`/// ` per line).
111pub fn emit_rustdoc(out: &mut String, doc: &str, indent: &str) {
112    if doc.is_empty() {
113        return;
114    }
115    for line in doc.lines() {
116        out.push_str(indent);
117        out.push_str("/// ");
118        out.push_str(line);
119        out.push('\n');
120    }
121}
122
123/// Escape Elixir doc line: handle triple-quote sequences that could close the heredoc early.
124fn escape_elixir_doc_line(s: &str) -> String {
125    s.replace("\"\"\"", "\"\" \"")
126}
127
128/// Emit R roxygen2-style documentation comments (#')
129/// Used for R functions.
130pub fn emit_roxygen(out: &mut String, doc: &str) {
131    if doc.is_empty() {
132        return;
133    }
134    for line in doc.lines() {
135        out.push_str("#' ");
136        out.push_str(line);
137        out.push('\n');
138    }
139}
140
141/// Emit Swift-style documentation comments (///)
142/// Used for Swift structs, enums, and functions.
143pub fn emit_swift_doc(out: &mut String, doc: &str, indent: &str) {
144    if doc.is_empty() {
145        return;
146    }
147    for line in doc.lines() {
148        out.push_str(indent);
149        out.push_str("/// ");
150        out.push_str(line);
151        out.push('\n');
152    }
153}
154
155/// Emit Javadoc-style documentation comments (/** ... */)
156/// Used for Java classes, methods, and fields.
157/// Handles XML escaping and Javadoc tag formatting.
158pub fn emit_javadoc(out: &mut String, doc: &str, indent: &str) {
159    if doc.is_empty() {
160        return;
161    }
162    out.push_str(indent);
163    out.push_str("/**\n");
164    for line in doc.lines() {
165        let escaped = escape_javadoc_line(line);
166        let trimmed = escaped.trim_end();
167        if trimmed.is_empty() {
168            out.push_str(indent);
169            out.push_str(" *\n");
170        } else {
171            out.push_str(indent);
172            out.push_str(" * ");
173            out.push_str(trimmed);
174            out.push('\n');
175        }
176    }
177    out.push_str(indent);
178    out.push_str(" */\n");
179}
180
181/// Emit KDoc-style documentation comments (/** ... */)
182/// Used for Kotlin classes, methods, and properties.
183pub fn emit_kdoc(out: &mut String, doc: &str, indent: &str) {
184    if doc.is_empty() {
185        return;
186    }
187    out.push_str(indent);
188    out.push_str("/**\n");
189    for line in doc.lines() {
190        let trimmed = line.trim_end();
191        if trimmed.is_empty() {
192            out.push_str(indent);
193            out.push_str(" *\n");
194        } else {
195            out.push_str(indent);
196            out.push_str(" * ");
197            out.push_str(trimmed);
198            out.push('\n');
199        }
200    }
201    out.push_str(indent);
202    out.push_str(" */\n");
203}
204
205/// Emit Dartdoc-style documentation comments (///)
206/// Used for Dart classes, methods, and properties.
207pub fn emit_dartdoc(out: &mut String, doc: &str, indent: &str) {
208    if doc.is_empty() {
209        return;
210    }
211    for line in doc.lines() {
212        out.push_str(indent);
213        out.push_str("/// ");
214        out.push_str(line);
215        out.push('\n');
216    }
217}
218
219/// Emit Gleam documentation comments (///)
220/// Used for Gleam functions and types.
221pub fn emit_gleam_doc(out: &mut String, doc: &str, indent: &str) {
222    if doc.is_empty() {
223        return;
224    }
225    for line in doc.lines() {
226        out.push_str(indent);
227        out.push_str("/// ");
228        out.push_str(line);
229        out.push('\n');
230    }
231}
232
233/// Emit Doxygen-style C documentation comments using `///`-prefixed lines.
234///
235/// Used by `alef-backend-ffi` above every `extern "C" fn`, the `*_len()`
236/// companion, opaque-handle typedef, and (post-cbindgen) the type/enum
237/// declarations cbindgen surfaces in the generated `.h`. cbindgen translates
238/// `///` source lines into a single `/** ... */` Doxygen block per item, so we
239/// only need to emit per-line `///` content here.
240///
241/// Translates rustdoc sections via [`render_doxygen_sections`]:
242///
243/// - `# Arguments` → `\param <name> <description>` (one per arg).
244/// - `# Returns`   → `\return <description>`.
245/// - `# Errors`    → `\note <description>` (Doxygen has no `\throws` for C;
246///   `\note` is the convention).
247/// - `# Safety`    → `\note SAFETY: <description>`.
248/// - `# Example`   → `\code` ... `\endcode` block.
249///
250/// Markdown links (`[text](url)`) are flattened to `text (url)`. Body lines
251/// are word-wrapped at ~100 columns so the rendered `/** */` block stays
252/// readable in IDE tooltips and terminal viewers.
253pub fn emit_c_doxygen(out: &mut String, doc: &str, indent: &str) {
254    if doc.trim().is_empty() {
255        return;
256    }
257    let sections = parse_rustdoc_sections(doc);
258    let any_section = sections.arguments.is_some()
259        || sections.returns.is_some()
260        || sections.errors.is_some()
261        || sections.safety.is_some()
262        || sections.example.is_some();
263    let mut body = if any_section {
264        render_doxygen_sections_with_notes(&sections)
265    } else {
266        sections.summary.clone()
267    };
268    body = strip_markdown_links(&body);
269    let wrapped = word_wrap(&body, DOXYGEN_WRAP_WIDTH);
270    for line in wrapped.lines() {
271        out.push_str(indent);
272        out.push_str("/// ");
273        out.push_str(line);
274        out.push('\n');
275    }
276}
277
278const DOXYGEN_WRAP_WIDTH: usize = 100;
279
280/// Render `RustdocSections` as a Doxygen body but route `# Errors` and
281/// `# Safety` to `\note` lines instead of plain prose. This is the variant
282/// `emit_c_doxygen` uses; the public `render_doxygen_sections` keeps its
283/// long-standing plain-prose semantics so existing callers don't shift.
284fn render_doxygen_sections_with_notes(sections: &RustdocSections) -> String {
285    let mut out = String::new();
286    if !sections.summary.is_empty() {
287        out.push_str(&sections.summary);
288    }
289    if let Some(args) = sections.arguments.as_deref() {
290        for (name, desc) in parse_arguments_bullets(args) {
291            if !out.is_empty() {
292                out.push('\n');
293            }
294            if desc.is_empty() {
295                out.push_str("\\param ");
296                out.push_str(&name);
297            } else {
298                out.push_str("\\param ");
299                out.push_str(&name);
300                out.push(' ');
301                out.push_str(&desc);
302            }
303        }
304    }
305    if let Some(ret) = sections.returns.as_deref() {
306        if !out.is_empty() {
307            out.push('\n');
308        }
309        out.push_str("\\return ");
310        out.push_str(ret.trim());
311    }
312    if let Some(err) = sections.errors.as_deref() {
313        if !out.is_empty() {
314            out.push('\n');
315        }
316        out.push_str("\\note ");
317        out.push_str(err.trim());
318    }
319    if let Some(safety) = sections.safety.as_deref() {
320        if !out.is_empty() {
321            out.push('\n');
322        }
323        out.push_str("\\note SAFETY: ");
324        out.push_str(safety.trim());
325    }
326    if let Some(example) = sections.example.as_deref() {
327        if !out.is_empty() {
328            out.push('\n');
329        }
330        out.push_str("\\code\n");
331        for line in example.lines() {
332            let t = line.trim_start();
333            if t.starts_with("```") {
334                continue;
335            }
336            out.push_str(line);
337            out.push('\n');
338        }
339        out.push_str("\\endcode");
340    }
341    out
342}
343
344/// Flatten Markdown inline links `[text](url)` to `text (url)` so the rendered
345/// Doxygen block stays readable when consumed without a Markdown filter.
346fn strip_markdown_links(s: &str) -> String {
347    let mut out = String::with_capacity(s.len());
348    let bytes = s.as_bytes();
349    let mut i = 0;
350    while i < bytes.len() {
351        if bytes[i] == b'[' {
352            // Find matching closing bracket on the same logical span (no nested brackets).
353            if let Some(close) = bytes[i + 1..].iter().position(|&b| b == b']') {
354                let text_end = i + 1 + close;
355                if text_end + 1 < bytes.len() && bytes[text_end + 1] == b'(' {
356                    if let Some(paren_close) = bytes[text_end + 2..].iter().position(|&b| b == b')') {
357                        let url_start = text_end + 2;
358                        let url_end = url_start + paren_close;
359                        let text = &s[i + 1..text_end];
360                        let url = &s[url_start..url_end];
361                        out.push_str(text);
362                        out.push_str(" (");
363                        out.push_str(url);
364                        out.push(')');
365                        i = url_end + 1;
366                        continue;
367                    }
368                }
369            }
370        }
371        out.push(bytes[i] as char);
372        i += 1;
373    }
374    out
375}
376
377/// Word-wrap each input line at `width` columns. Lines starting with `\code`
378/// or contained between `\code`/`\endcode` markers, as well as Markdown fence
379/// blocks, are passed through verbatim to preserve example formatting.
380fn word_wrap(input: &str, width: usize) -> String {
381    let mut out = String::with_capacity(input.len());
382    let mut in_code = false;
383    for raw in input.lines() {
384        let trimmed = raw.trim_start();
385        if trimmed.starts_with("\\code") {
386            in_code = true;
387            out.push_str(raw);
388            out.push('\n');
389            continue;
390        }
391        if trimmed.starts_with("\\endcode") {
392            in_code = false;
393            out.push_str(raw);
394            out.push('\n');
395            continue;
396        }
397        if in_code || trimmed.starts_with("```") {
398            out.push_str(raw);
399            out.push('\n');
400            continue;
401        }
402        if raw.len() <= width {
403            out.push_str(raw);
404            out.push('\n');
405            continue;
406        }
407        let mut current = String::with_capacity(width);
408        for word in raw.split_whitespace() {
409            if current.is_empty() {
410                current.push_str(word);
411            } else if current.len() + 1 + word.len() > width {
412                out.push_str(&current);
413                out.push('\n');
414                current.clear();
415                current.push_str(word);
416            } else {
417                current.push(' ');
418                current.push_str(word);
419            }
420        }
421        if !current.is_empty() {
422            out.push_str(&current);
423            out.push('\n');
424        }
425    }
426    out.trim_end_matches('\n').to_string()
427}
428
429/// Emit Zig documentation comments (///)
430/// Used for Zig functions, types, and declarations.
431pub fn emit_zig_doc(out: &mut String, doc: &str, indent: &str) {
432    if doc.is_empty() {
433        return;
434    }
435    for line in doc.lines() {
436        out.push_str(indent);
437        out.push_str("/// ");
438        out.push_str(line);
439        out.push('\n');
440    }
441}
442
443/// Emit YARD documentation comments for Ruby.
444/// Used for Ruby classes, methods, and attributes.
445///
446/// YARD syntax: each line prefixed with `# ` (with space). Translates rustdoc
447/// sections (`# Arguments` → `@param`, `# Returns` → `@return`, `# Errors` → `@raise`)
448/// via [`render_yard_sections`].
449pub fn emit_yard_doc(out: &mut String, doc: &str, indent: &str) {
450    if doc.is_empty() {
451        return;
452    }
453    let sections = parse_rustdoc_sections(doc);
454    let any_section = sections.arguments.is_some()
455        || sections.returns.is_some()
456        || sections.errors.is_some()
457        || sections.example.is_some();
458    let body = if any_section {
459        render_yard_sections(&sections)
460    } else {
461        doc.to_string()
462    };
463    for line in body.lines() {
464        out.push_str(indent);
465        out.push_str("# ");
466        out.push_str(line);
467        out.push('\n');
468    }
469}
470
471/// Render `RustdocSections` as YARD documentation comment body.
472///
473/// - `# Arguments` → `@param name desc` (one per arg)
474/// - `# Returns`   → `@return desc`
475/// - `# Errors`    → `@raise desc`
476/// - `# Example`   → `@example` block.
477///
478/// Output is a plain string with `\n` separators; the emitter wraps each line
479/// in `# ` itself.
480pub fn render_yard_sections(sections: &RustdocSections) -> String {
481    let mut out = String::new();
482    if !sections.summary.is_empty() {
483        out.push_str(&sections.summary);
484    }
485    if let Some(args) = sections.arguments.as_deref() {
486        for (name, desc) in parse_arguments_bullets(args) {
487            if !out.is_empty() {
488                out.push('\n');
489            }
490            if desc.is_empty() {
491                out.push_str("@param ");
492                out.push_str(&name);
493            } else {
494                out.push_str("@param ");
495                out.push_str(&name);
496                out.push(' ');
497                out.push_str(&desc);
498            }
499        }
500    }
501    if let Some(ret) = sections.returns.as_deref() {
502        if !out.is_empty() {
503            out.push('\n');
504        }
505        out.push_str("@return ");
506        out.push_str(ret.trim());
507    }
508    if let Some(err) = sections.errors.as_deref() {
509        if !out.is_empty() {
510            out.push('\n');
511        }
512        out.push_str("@raise ");
513        out.push_str(err.trim());
514    }
515    if let Some(example) = sections.example.as_deref() {
516        if let Some(body) = example_for_target(example, "ruby") {
517            if !out.is_empty() {
518                out.push('\n');
519            }
520            out.push_str("@example\n");
521            out.push_str(&body);
522        }
523    }
524    out
525}
526
527/// Escape Javadoc line: handle XML special chars and backtick code blocks.
528///
529/// HTML entities (`<`, `>`, `&`) are also escaped *inside* `{@code …}` blocks.
530/// Without that, content like `` `<pre><code>` `` would emit raw `<pre>`
531/// inside the Javadoc tag — Eclipse-formatter Spotless then treats it as a
532/// real `<pre>` block element and shatters the line across multiple `* `
533/// rows, breaking `alef-verify`'s embedded hash. Escaped content is
534/// rendered identically by Javadoc readers (the `{@code}` tag shows literal
535/// characters) and is stable under any post-formatter pass.
536fn escape_javadoc_line(s: &str) -> String {
537    let mut result = String::with_capacity(s.len());
538    let mut chars = s.chars().peekable();
539    while let Some(ch) = chars.next() {
540        if ch == '`' {
541            let mut code = String::new();
542            for c in chars.by_ref() {
543                if c == '`' {
544                    break;
545                }
546                code.push(c);
547            }
548            result.push_str("{@code ");
549            result.push_str(&escape_javadoc_html_entities(&code));
550            result.push('}');
551        } else if ch == '<' {
552            result.push_str("&lt;");
553        } else if ch == '>' {
554            result.push_str("&gt;");
555        } else if ch == '&' {
556            result.push_str("&amp;");
557        } else {
558            result.push(ch);
559        }
560    }
561    result
562}
563
564/// Escape only the HTML special characters that would otherwise be parsed by
565/// downstream Javadoc/Eclipse formatters as block-level HTML (e.g. `<pre>`).
566fn escape_javadoc_html_entities(s: &str) -> String {
567    let mut out = String::with_capacity(s.len());
568    for ch in s.chars() {
569        match ch {
570            '<' => out.push_str("&lt;"),
571            '>' => out.push_str("&gt;"),
572            '&' => out.push_str("&amp;"),
573            other => out.push(other),
574        }
575    }
576    out
577}
578
579/// A parsed rustdoc comment broken out into the sections binding emitters
580/// care about.
581///
582/// `summary` is the leading prose paragraph(s) before any `# Heading`.
583/// Sections are stored verbatim (without the `# Heading` line itself);
584/// each binding is responsible for translating bullet lists and code
585/// fences into its host-native conventions.
586///
587/// Trailing/leading whitespace inside each field is trimmed so emitters
588/// can concatenate without producing `* ` lines containing only spaces.
589#[derive(Debug, Default, Clone, PartialEq, Eq)]
590pub struct RustdocSections {
591    /// Prose before the first `# Section` heading.
592    pub summary: String,
593    /// Body of the `# Arguments` section, if present.
594    pub arguments: Option<String>,
595    /// Body of the `# Returns` section, if present.
596    pub returns: Option<String>,
597    /// Body of the `# Errors` section, if present.
598    pub errors: Option<String>,
599    /// Body of the `# Panics` section, if present.
600    pub panics: Option<String>,
601    /// Body of the `# Safety` section, if present.
602    pub safety: Option<String>,
603    /// Body of the `# Example` / `# Examples` section, if present.
604    pub example: Option<String>,
605}
606
607/// Parse a rustdoc string into [`RustdocSections`].
608///
609/// Recognises level-1 ATX headings whose name matches one of the standard
610/// rustdoc section names (`Arguments`, `Returns`, `Errors`, `Panics`,
611/// `Safety`, `Example`, `Examples`). Anything before the first heading
612/// becomes `summary`. Unrecognised headings are folded into the
613/// preceding section verbatim, so unconventional rustdoc isn't lost.
614///
615/// The input is expected to already have rustdoc-hidden lines stripped
616/// and intra-doc-link syntax rewritten by
617/// [`crate::extractor::helpers::normalize_rustdoc`].
618pub fn parse_rustdoc_sections(doc: &str) -> RustdocSections {
619    if doc.trim().is_empty() {
620        return RustdocSections::default();
621    }
622    let mut summary = String::new();
623    let mut arguments: Option<String> = None;
624    let mut returns: Option<String> = None;
625    let mut errors: Option<String> = None;
626    let mut panics: Option<String> = None;
627    let mut safety: Option<String> = None;
628    let mut example: Option<String> = None;
629    let mut current: Option<&'static str> = None;
630    let mut buf = String::new();
631    let mut in_fence = false;
632    let flush = |target: Option<&'static str>,
633                 buf: &mut String,
634                 summary: &mut String,
635                 arguments: &mut Option<String>,
636                 returns: &mut Option<String>,
637                 errors: &mut Option<String>,
638                 panics: &mut Option<String>,
639                 safety: &mut Option<String>,
640                 example: &mut Option<String>| {
641        let body = std::mem::take(buf).trim().to_string();
642        if body.is_empty() {
643            return;
644        }
645        match target {
646            None => {
647                if !summary.is_empty() {
648                    summary.push('\n');
649                }
650                summary.push_str(&body);
651            }
652            Some("arguments") => *arguments = Some(body),
653            Some("returns") => *returns = Some(body),
654            Some("errors") => *errors = Some(body),
655            Some("panics") => *panics = Some(body),
656            Some("safety") => *safety = Some(body),
657            Some("example") => *example = Some(body),
658            _ => {}
659        }
660    };
661    for line in doc.lines() {
662        let trimmed = line.trim_start();
663        if trimmed.starts_with("```") {
664            in_fence = !in_fence;
665            buf.push_str(line);
666            buf.push('\n');
667            continue;
668        }
669        if !in_fence {
670            if let Some(rest) = trimmed.strip_prefix("# ") {
671                let head = rest.trim().to_ascii_lowercase();
672                let target = match head.as_str() {
673                    "arguments" | "args" => Some("arguments"),
674                    "returns" => Some("returns"),
675                    "errors" => Some("errors"),
676                    "panics" => Some("panics"),
677                    "safety" => Some("safety"),
678                    "example" | "examples" => Some("example"),
679                    _ => None,
680                };
681                if target.is_some() {
682                    flush(
683                        current,
684                        &mut buf,
685                        &mut summary,
686                        &mut arguments,
687                        &mut returns,
688                        &mut errors,
689                        &mut panics,
690                        &mut safety,
691                        &mut example,
692                    );
693                    current = target;
694                    continue;
695                }
696            }
697        }
698        buf.push_str(line);
699        buf.push('\n');
700    }
701    flush(
702        current,
703        &mut buf,
704        &mut summary,
705        &mut arguments,
706        &mut returns,
707        &mut errors,
708        &mut panics,
709        &mut safety,
710        &mut example,
711    );
712    RustdocSections {
713        summary,
714        arguments,
715        returns,
716        errors,
717        panics,
718        safety,
719        example,
720    }
721}
722
723/// Parse `# Arguments` body into `(name, description)` pairs.
724///
725/// Recognises both Markdown bullet styles `*` and `-`, with optional
726/// backticks around the name: `* `name` - description` or
727/// `- name: description`. Continuation lines indented under a bullet
728/// are appended to the previous entry's description.
729///
730/// Used by emitters that translate to per-parameter documentation tags
731/// (`@param`, `<param>`, `\param`).
732pub fn parse_arguments_bullets(body: &str) -> Vec<(String, String)> {
733    let mut out: Vec<(String, String)> = Vec::new();
734    for raw in body.lines() {
735        let line = raw.trim_end();
736        let trimmed = line.trim_start();
737        let is_bullet = trimmed.starts_with("* ") || trimmed.starts_with("- ");
738        if is_bullet {
739            let after = &trimmed[2..];
740            // Accept `name`, `name:` or `name -` separator forms.
741            let (name, desc) = if let Some(idx) = after.find(" - ") {
742                (after[..idx].trim(), after[idx + 3..].trim())
743            } else if let Some(idx) = after.find(": ") {
744                (after[..idx].trim(), after[idx + 2..].trim())
745            } else if let Some(idx) = after.find(' ') {
746                (after[..idx].trim(), after[idx + 1..].trim())
747            } else {
748                (after.trim(), "")
749            };
750            let name = name.trim_matches('`').trim_matches('*').to_string();
751            out.push((name, desc.to_string()));
752        } else if !trimmed.is_empty() {
753            if let Some(last) = out.last_mut() {
754                if !last.1.is_empty() {
755                    last.1.push(' ');
756                }
757                last.1.push_str(trimmed);
758            }
759        }
760    }
761    out
762}
763
764/// Detect the language tag on the first code fence in `body`.
765///
766/// Scans `body` for the first line that starts with ` ``` ` and returns the
767/// tag that follows (e.g. `"rust"`, `"php"`, `"typescript"`). A bare ` ``` `
768/// with no tag returns `"rust"` because rustdoc treats unlabelled fences as
769/// Rust by default. Returns `"rust"` when no fence is found at all.
770fn detect_first_fence_lang(body: &str) -> &str {
771    for line in body.lines() {
772        let trimmed = line.trim_start();
773        if let Some(rest) = trimmed.strip_prefix("```") {
774            let tag = rest.split(',').next().unwrap_or("").trim();
775            return if tag.is_empty() { "rust" } else { tag };
776        }
777    }
778    "rust"
779}
780
781/// Return `Some(transformed_example)` if the example should be emitted for
782/// `target_lang`, or `None` when the example is Rust source that would be
783/// meaningless in the foreign language.
784///
785/// When the original fence language is `rust` (including bare ` ``` ` which
786/// rustdoc defaults to Rust) and the target is not `rust`, the example is
787/// suppressed entirely — better absent than misleading. Cross-language
788/// transliteration of example bodies is intentionally out of scope.
789pub fn example_for_target(example: &str, target_lang: &str) -> Option<String> {
790    let trimmed = example.trim();
791    let source_lang = detect_first_fence_lang(trimmed);
792    if source_lang == "rust" && target_lang != "rust" {
793        None
794    } else {
795        Some(replace_fence_lang(trimmed, target_lang))
796    }
797}
798
799/// Strip a single ` ```lang ` fence pair from `body`, returning the inner
800/// code lines. Replaces the leading ` ```rust ` (or any other tag) with
801/// `lang_replacement`, leaving the rest of the body unchanged.
802///
803/// When no fence is present the body is returned unchanged. Used by
804/// emitters that need to convert ` ```rust ` examples into
805/// ` ```typescript ` / ` ```python ` / ` ```swift ` etc.
806pub fn replace_fence_lang(body: &str, lang_replacement: &str) -> String {
807    let mut out = String::with_capacity(body.len());
808    for line in body.lines() {
809        let trimmed = line.trim_start();
810        if let Some(rest) = trimmed.strip_prefix("```") {
811            // Replace the language tag (everything up to the next comma or
812            // end of line). Preserve indentation.
813            let indent = &line[..line.len() - trimmed.len()];
814            let after_lang = rest.find(',').map(|i| &rest[i..]).unwrap_or("");
815            out.push_str(indent);
816            out.push_str("```");
817            out.push_str(lang_replacement);
818            out.push_str(after_lang);
819            out.push('\n');
820        } else {
821            out.push_str(line);
822            out.push('\n');
823        }
824    }
825    out.trim_end_matches('\n').to_string()
826}
827
828/// Render `RustdocSections` as a JSDoc comment body (without the `/**` /
829/// ` */` wrappers — those are added by the caller's emitter, which knows
830/// the indent/escape conventions).
831///
832/// - `# Arguments` → `@param name - desc`
833/// - `# Returns`   → `@returns desc`
834/// - `# Errors`    → `@throws desc`
835/// - `# Example`   → `@example` block. Replaces ` ```rust ` fences with
836///   ` ```typescript ` so the example highlights properly in TypeDoc.
837///
838/// Output is a plain string with `\n` separators; emitters wrap each line
839/// in ` * ` themselves.
840pub fn render_jsdoc_sections(sections: &RustdocSections) -> String {
841    let mut out = String::new();
842    if !sections.summary.is_empty() {
843        out.push_str(&sections.summary);
844    }
845    if let Some(args) = sections.arguments.as_deref() {
846        for (name, desc) in parse_arguments_bullets(args) {
847            if !out.is_empty() {
848                out.push('\n');
849            }
850            if desc.is_empty() {
851                out.push_str(&crate::template_env::render(
852                    "doc_jsdoc_param.jinja",
853                    minijinja::context! { name => &name },
854                ));
855            } else {
856                out.push_str(&crate::template_env::render(
857                    "doc_jsdoc_param_desc.jinja",
858                    minijinja::context! { name => &name, desc => &desc },
859                ));
860            }
861        }
862    }
863    if let Some(ret) = sections.returns.as_deref() {
864        if !out.is_empty() {
865            out.push('\n');
866        }
867        out.push_str(&crate::template_env::render(
868            "doc_jsdoc_returns.jinja",
869            minijinja::context! { content => ret.trim() },
870        ));
871    }
872    if let Some(err) = sections.errors.as_deref() {
873        if !out.is_empty() {
874            out.push('\n');
875        }
876        out.push_str(&crate::template_env::render(
877            "doc_jsdoc_throws.jinja",
878            minijinja::context! { content => err.trim() },
879        ));
880    }
881    if let Some(example) = sections.example.as_deref() {
882        if let Some(body) = example_for_target(example, "typescript") {
883            if !out.is_empty() {
884                out.push('\n');
885            }
886            out.push_str("@example\n");
887            out.push_str(&body);
888        }
889    }
890    out
891}
892
893/// Render `RustdocSections` as a JavaDoc comment body.
894///
895/// - `# Arguments` → `@param name desc` (one per param)
896/// - `# Returns`   → `@return desc`
897/// - `# Errors`    → `@throws KreuzbergRsException desc`
898/// - `# Example`   → `<pre>{@code ...}</pre>` block.
899///
900/// `throws_class` is the FQN/simple name of the exception class to use in
901/// the `@throws` tag (e.g. `"KreuzbergRsException"`).
902pub fn render_javadoc_sections(sections: &RustdocSections, throws_class: &str) -> String {
903    let mut out = String::new();
904    if !sections.summary.is_empty() {
905        out.push_str(&sections.summary);
906    }
907    if let Some(args) = sections.arguments.as_deref() {
908        for (name, desc) in parse_arguments_bullets(args) {
909            if !out.is_empty() {
910                out.push('\n');
911            }
912            if desc.is_empty() {
913                out.push_str(&crate::template_env::render(
914                    "doc_javadoc_param.jinja",
915                    minijinja::context! { name => &name },
916                ));
917            } else {
918                out.push_str(&crate::template_env::render(
919                    "doc_javadoc_param_desc.jinja",
920                    minijinja::context! { name => &name, desc => &desc },
921                ));
922            }
923        }
924    }
925    if let Some(ret) = sections.returns.as_deref() {
926        if !out.is_empty() {
927            out.push('\n');
928        }
929        out.push_str(&crate::template_env::render(
930            "doc_javadoc_return.jinja",
931            minijinja::context! { content => ret.trim() },
932        ));
933    }
934    if let Some(err) = sections.errors.as_deref() {
935        if !out.is_empty() {
936            out.push('\n');
937        }
938        out.push_str(&crate::template_env::render(
939            "doc_javadoc_throws.jinja",
940            minijinja::context! { throws_class => throws_class, content => err.trim() },
941        ));
942    }
943    out
944}
945
946/// Render `RustdocSections` as a C# XML doc comment body (without the
947/// `/// ` line prefixes — the emitter adds those).
948///
949/// - summary  → `<summary>...</summary>`
950/// - args     → `<param name="x">desc</param>` (one per arg)
951/// - returns  → `<returns>desc</returns>`
952/// - errors   → `<exception cref="KreuzbergException">desc</exception>`
953/// - example  → `<example><code language="csharp">...</code></example>`
954pub fn render_csharp_xml_sections(sections: &RustdocSections, exception_class: &str) -> String {
955    let mut out = String::new();
956    out.push_str("<summary>\n");
957    let summary = if sections.summary.is_empty() {
958        ""
959    } else {
960        sections.summary.as_str()
961    };
962    for line in summary.lines() {
963        out.push_str(line);
964        out.push('\n');
965    }
966    out.push_str("</summary>");
967    if let Some(args) = sections.arguments.as_deref() {
968        for (name, desc) in parse_arguments_bullets(args) {
969            out.push('\n');
970            if desc.is_empty() {
971                out.push_str(&crate::template_env::render(
972                    "doc_csharp_param.jinja",
973                    minijinja::context! { name => &name },
974                ));
975            } else {
976                out.push_str(&crate::template_env::render(
977                    "doc_csharp_param_desc.jinja",
978                    minijinja::context! { name => &name, desc => &desc },
979                ));
980            }
981        }
982    }
983    if let Some(ret) = sections.returns.as_deref() {
984        out.push('\n');
985        out.push_str(&crate::template_env::render(
986            "doc_csharp_returns.jinja",
987            minijinja::context! { content => ret.trim() },
988        ));
989    }
990    if let Some(err) = sections.errors.as_deref() {
991        out.push('\n');
992        out.push_str(&crate::template_env::render(
993            "doc_csharp_exception.jinja",
994            minijinja::context! {
995                exception_class => exception_class,
996                content => err.trim(),
997            },
998        ));
999    }
1000    if let Some(example) = sections.example.as_deref() {
1001        out.push('\n');
1002        out.push_str("<example><code language=\"csharp\">\n");
1003        // Drop fence markers, keep code.
1004        for line in example.lines() {
1005            let t = line.trim_start();
1006            if t.starts_with("```") {
1007                continue;
1008            }
1009            out.push_str(line);
1010            out.push('\n');
1011        }
1012        out.push_str("</code></example>");
1013    }
1014    out
1015}
1016
1017/// Render `RustdocSections` as a PHPDoc comment body.
1018///
1019/// - `# Arguments` → `@param mixed $name desc`
1020/// - `# Returns`   → `@return desc`
1021/// - `# Errors`    → `@throws KreuzbergException desc`
1022/// - `# Example`   → ` ```php ` fence (replaces ` ```rust `).
1023pub fn render_phpdoc_sections(sections: &RustdocSections, throws_class: &str) -> String {
1024    let mut out = String::new();
1025    if !sections.summary.is_empty() {
1026        out.push_str(&sections.summary);
1027    }
1028    if let Some(args) = sections.arguments.as_deref() {
1029        for (name, desc) in parse_arguments_bullets(args) {
1030            if !out.is_empty() {
1031                out.push('\n');
1032            }
1033            if desc.is_empty() {
1034                out.push_str(&crate::template_env::render(
1035                    "doc_phpdoc_param.jinja",
1036                    minijinja::context! { name => &name },
1037                ));
1038            } else {
1039                out.push_str(&crate::template_env::render(
1040                    "doc_phpdoc_param_desc.jinja",
1041                    minijinja::context! { name => &name, desc => &desc },
1042                ));
1043            }
1044        }
1045    }
1046    if let Some(ret) = sections.returns.as_deref() {
1047        if !out.is_empty() {
1048            out.push('\n');
1049        }
1050        out.push_str(&crate::template_env::render(
1051            "doc_phpdoc_return.jinja",
1052            minijinja::context! { content => ret.trim() },
1053        ));
1054    }
1055    if let Some(err) = sections.errors.as_deref() {
1056        if !out.is_empty() {
1057            out.push('\n');
1058        }
1059        out.push_str(&crate::template_env::render(
1060            "doc_phpdoc_throws.jinja",
1061            minijinja::context! { throws_class => throws_class, content => err.trim() },
1062        ));
1063    }
1064    if let Some(example) = sections.example.as_deref() {
1065        if let Some(body) = example_for_target(example, "php") {
1066            if !out.is_empty() {
1067                out.push('\n');
1068            }
1069            out.push_str(&body);
1070        }
1071    }
1072    out
1073}
1074
1075/// Render `RustdocSections` as a Doxygen comment body for the C header.
1076///
1077/// - args    → `\param name desc`
1078/// - returns → `\return desc`
1079/// - errors  → prose paragraph (Doxygen has no semantic tag for FFI errors)
1080/// - example → `\code` ... `\endcode`
1081pub fn render_doxygen_sections(sections: &RustdocSections) -> String {
1082    let mut out = String::new();
1083    if !sections.summary.is_empty() {
1084        out.push_str(&sections.summary);
1085    }
1086    if let Some(args) = sections.arguments.as_deref() {
1087        for (name, desc) in parse_arguments_bullets(args) {
1088            if !out.is_empty() {
1089                out.push('\n');
1090            }
1091            if desc.is_empty() {
1092                out.push_str(&crate::template_env::render(
1093                    "doc_doxygen_param.jinja",
1094                    minijinja::context! { name => &name },
1095                ));
1096            } else {
1097                out.push_str(&crate::template_env::render(
1098                    "doc_doxygen_param_desc.jinja",
1099                    minijinja::context! { name => &name, desc => &desc },
1100                ));
1101            }
1102        }
1103    }
1104    if let Some(ret) = sections.returns.as_deref() {
1105        if !out.is_empty() {
1106            out.push('\n');
1107        }
1108        out.push_str(&crate::template_env::render(
1109            "doc_doxygen_return.jinja",
1110            minijinja::context! { content => ret.trim() },
1111        ));
1112    }
1113    if let Some(err) = sections.errors.as_deref() {
1114        if !out.is_empty() {
1115            out.push('\n');
1116        }
1117        out.push_str(&crate::template_env::render(
1118            "doc_doxygen_errors.jinja",
1119            minijinja::context! { content => err.trim() },
1120        ));
1121    }
1122    if let Some(example) = sections.example.as_deref() {
1123        if !out.is_empty() {
1124            out.push('\n');
1125        }
1126        out.push_str("\\code\n");
1127        for line in example.lines() {
1128            let t = line.trim_start();
1129            if t.starts_with("```") {
1130                continue;
1131            }
1132            out.push_str(line);
1133            out.push('\n');
1134        }
1135        out.push_str("\\endcode");
1136    }
1137    out
1138}
1139
1140/// Return the first paragraph of a doc comment as a single joined line.
1141///
1142/// Collects lines until the first blank line, trims each, then joins with a
1143/// space. This handles wrapped sentences like:
1144///
1145/// ```text
1146/// Convert HTML to Markdown, returning
1147/// a `ConversionResult`.
1148/// ```
1149///
1150/// which would otherwise be truncated at the comma when callers use
1151/// `.lines().next()`.
1152pub fn doc_first_paragraph_joined(doc: &str) -> String {
1153    doc.lines()
1154        .take_while(|l| !l.trim().is_empty())
1155        .map(str::trim)
1156        .collect::<Vec<_>>()
1157        .join(" ")
1158}
1159
1160#[cfg(test)]
1161mod tests {
1162    use super::*;
1163
1164    #[test]
1165    fn test_emit_phpdoc() {
1166        let mut out = String::new();
1167        emit_phpdoc(&mut out, "Simple documentation", "    ", "TestException");
1168        assert!(out.contains("/**"));
1169        assert!(out.contains("Simple documentation"));
1170        assert!(out.contains("*/"));
1171    }
1172
1173    #[test]
1174    fn test_phpdoc_escaping() {
1175        let mut out = String::new();
1176        emit_phpdoc(&mut out, "Handle */ sequences", "", "TestException");
1177        assert!(out.contains("Handle * / sequences"));
1178    }
1179
1180    #[test]
1181    fn test_emit_csharp_doc() {
1182        let mut out = String::new();
1183        emit_csharp_doc(&mut out, "C# documentation", "    ", "TestException");
1184        assert!(out.contains("<summary>"));
1185        assert!(out.contains("C# documentation"));
1186        assert!(out.contains("</summary>"));
1187    }
1188
1189    #[test]
1190    fn test_csharp_xml_escaping() {
1191        let mut out = String::new();
1192        emit_csharp_doc(&mut out, "foo < bar & baz > qux", "", "TestException");
1193        assert!(out.contains("foo &lt; bar &amp; baz &gt; qux"));
1194    }
1195
1196    #[test]
1197    fn test_emit_elixir_doc() {
1198        let mut out = String::new();
1199        emit_elixir_doc(&mut out, "Elixir documentation");
1200        assert!(out.contains("@doc \"\"\""));
1201        assert!(out.contains("Elixir documentation"));
1202        assert!(out.contains("\"\"\""));
1203    }
1204
1205    #[test]
1206    fn test_elixir_heredoc_escaping() {
1207        let mut out = String::new();
1208        emit_elixir_doc(&mut out, "Handle \"\"\" sequences");
1209        assert!(out.contains("Handle \"\" \" sequences"));
1210    }
1211
1212    #[test]
1213    fn test_emit_roxygen() {
1214        let mut out = String::new();
1215        emit_roxygen(&mut out, "R documentation");
1216        assert!(out.contains("#' R documentation"));
1217    }
1218
1219    #[test]
1220    fn test_emit_swift_doc() {
1221        let mut out = String::new();
1222        emit_swift_doc(&mut out, "Swift documentation", "    ");
1223        assert!(out.contains("/// Swift documentation"));
1224    }
1225
1226    #[test]
1227    fn test_emit_javadoc() {
1228        let mut out = String::new();
1229        emit_javadoc(&mut out, "Java documentation", "    ");
1230        assert!(out.contains("/**"));
1231        assert!(out.contains("Java documentation"));
1232        assert!(out.contains("*/"));
1233    }
1234
1235    #[test]
1236    fn test_emit_kdoc() {
1237        let mut out = String::new();
1238        emit_kdoc(&mut out, "Kotlin documentation", "    ");
1239        assert!(out.contains("/**"));
1240        assert!(out.contains("Kotlin documentation"));
1241        assert!(out.contains("*/"));
1242    }
1243
1244    #[test]
1245    fn test_emit_dartdoc() {
1246        let mut out = String::new();
1247        emit_dartdoc(&mut out, "Dart documentation", "    ");
1248        assert!(out.contains("/// Dart documentation"));
1249    }
1250
1251    #[test]
1252    fn test_emit_gleam_doc() {
1253        let mut out = String::new();
1254        emit_gleam_doc(&mut out, "Gleam documentation", "    ");
1255        assert!(out.contains("/// Gleam documentation"));
1256    }
1257
1258    #[test]
1259    fn test_emit_zig_doc() {
1260        let mut out = String::new();
1261        emit_zig_doc(&mut out, "Zig documentation", "    ");
1262        assert!(out.contains("/// Zig documentation"));
1263    }
1264
1265    #[test]
1266    fn test_empty_doc_skipped() {
1267        let mut out = String::new();
1268        emit_phpdoc(&mut out, "", "", "TestException");
1269        emit_csharp_doc(&mut out, "", "", "TestException");
1270        emit_elixir_doc(&mut out, "");
1271        emit_roxygen(&mut out, "");
1272        emit_kdoc(&mut out, "", "");
1273        emit_dartdoc(&mut out, "", "");
1274        emit_gleam_doc(&mut out, "", "");
1275        emit_zig_doc(&mut out, "", "");
1276        assert!(out.is_empty());
1277    }
1278
1279    #[test]
1280    fn test_doc_first_paragraph_joined_single_line() {
1281        assert_eq!(doc_first_paragraph_joined("Simple doc."), "Simple doc.");
1282    }
1283
1284    #[test]
1285    fn test_doc_first_paragraph_joined_wrapped_sentence() {
1286        // Simulates a docstring like convert's: "Convert HTML to Markdown,\nreturning a result."
1287        let doc = "Convert HTML to Markdown,\nreturning a result.";
1288        assert_eq!(
1289            doc_first_paragraph_joined(doc),
1290            "Convert HTML to Markdown, returning a result."
1291        );
1292    }
1293
1294    #[test]
1295    fn test_doc_first_paragraph_joined_stops_at_blank_line() {
1296        let doc = "First paragraph.\nStill first.\n\nSecond paragraph.";
1297        assert_eq!(doc_first_paragraph_joined(doc), "First paragraph. Still first.");
1298    }
1299
1300    #[test]
1301    fn test_doc_first_paragraph_joined_empty() {
1302        assert_eq!(doc_first_paragraph_joined(""), "");
1303    }
1304
1305    #[test]
1306    fn test_parse_rustdoc_sections_basic() {
1307        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.";
1308        let sections = parse_rustdoc_sections(doc);
1309        assert_eq!(sections.summary, "Extracts text from a file.");
1310        assert_eq!(sections.arguments.as_deref(), Some("* `path` - The file path."));
1311        assert_eq!(sections.returns.as_deref(), Some("The extracted text."));
1312        assert_eq!(sections.errors.as_deref(), Some("Returns `KreuzbergError` on failure."));
1313        assert!(sections.panics.is_none());
1314    }
1315
1316    #[test]
1317    fn test_parse_rustdoc_sections_example_with_fence() {
1318        let doc = "Run the thing.\n\n# Example\n\n```rust\nlet x = run();\n```";
1319        let sections = parse_rustdoc_sections(doc);
1320        assert_eq!(sections.summary, "Run the thing.");
1321        assert!(sections.example.as_ref().unwrap().contains("```rust"));
1322        assert!(sections.example.as_ref().unwrap().contains("let x = run();"));
1323    }
1324
1325    #[test]
1326    fn test_parse_rustdoc_sections_pound_inside_fence_is_not_a_heading() {
1327        // Even though we get rustdoc-hidden lines pre-stripped, a literal
1328        // `# foo` inside a non-rust fence (e.g. shell example) must not
1329        // start a new section.
1330        let doc = "Summary.\n\n# Example\n\n```bash\n# install deps\nrun --foo\n```";
1331        let sections = parse_rustdoc_sections(doc);
1332        assert_eq!(sections.summary, "Summary.");
1333        assert!(sections.example.as_ref().unwrap().contains("# install deps"));
1334    }
1335
1336    #[test]
1337    fn test_parse_arguments_bullets_dash_separator() {
1338        let body = "* `path` - The file path.\n* `config` - Optional configuration.";
1339        let pairs = parse_arguments_bullets(body);
1340        assert_eq!(pairs.len(), 2);
1341        assert_eq!(pairs[0], ("path".to_string(), "The file path.".to_string()));
1342        assert_eq!(pairs[1], ("config".to_string(), "Optional configuration.".to_string()));
1343    }
1344
1345    #[test]
1346    fn test_parse_arguments_bullets_continuation_line() {
1347        let body = "* `path` - The file path,\n  resolved relative to cwd.\n* `mode` - Open mode.";
1348        let pairs = parse_arguments_bullets(body);
1349        assert_eq!(pairs.len(), 2);
1350        assert_eq!(pairs[0].1, "The file path, resolved relative to cwd.");
1351    }
1352
1353    #[test]
1354    fn test_replace_fence_lang_rust_to_typescript() {
1355        let body = "```rust\nlet x = run();\n```";
1356        let out = replace_fence_lang(body, "typescript");
1357        assert!(out.starts_with("```typescript"));
1358        assert!(out.contains("let x = run();"));
1359    }
1360
1361    #[test]
1362    fn test_replace_fence_lang_preserves_attrs() {
1363        let body = "```rust,no_run\nlet x = run();\n```";
1364        let out = replace_fence_lang(body, "typescript");
1365        assert!(out.starts_with("```typescript,no_run"));
1366    }
1367
1368    #[test]
1369    fn test_replace_fence_lang_no_fence_unchanged() {
1370        let body = "Plain prose with `inline code`.";
1371        let out = replace_fence_lang(body, "typescript");
1372        assert_eq!(out, "Plain prose with `inline code`.");
1373    }
1374
1375    fn fixture_sections() -> RustdocSections {
1376        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```";
1377        parse_rustdoc_sections(doc)
1378    }
1379
1380    #[test]
1381    fn test_render_jsdoc_sections() {
1382        let sections = fixture_sections();
1383        let out = render_jsdoc_sections(&sections);
1384        assert!(out.starts_with("Extracts text from a file."));
1385        assert!(out.contains("@param path - The file path."));
1386        assert!(out.contains("@param config - Optional configuration."));
1387        assert!(out.contains("@returns The extracted text and metadata."));
1388        assert!(out.contains("@throws Returns an error when the file is unreadable."));
1389        // fixture example is ```rust — stripped when target is TypeScript
1390        assert!(!out.contains("@example"), "Rust example must not appear in TSDoc");
1391        assert!(!out.contains("```typescript"));
1392        assert!(!out.contains("```rust"));
1393    }
1394
1395    #[test]
1396    fn test_render_jsdoc_sections_preserves_typescript_example() {
1397        let doc = "Do something.\n\n# Example\n\n```typescript\nconst x = doSomething();\n```";
1398        let sections = parse_rustdoc_sections(doc);
1399        let out = render_jsdoc_sections(&sections);
1400        assert!(out.contains("@example"), "TypeScript example must be preserved");
1401        assert!(out.contains("```typescript"));
1402    }
1403
1404    #[test]
1405    fn test_render_javadoc_sections() {
1406        let sections = fixture_sections();
1407        let out = render_javadoc_sections(&sections, "KreuzbergRsException");
1408        assert!(out.contains("@param path The file path."));
1409        assert!(out.contains("@return The extracted text and metadata."));
1410        assert!(out.contains("@throws KreuzbergRsException Returns an error when the file is unreadable."));
1411        // Java rendering omits the example block (handled separately by emit_javadoc which
1412        // wraps code in `<pre>{@code}</pre>`); we just confirm summary survives.
1413        assert!(out.starts_with("Extracts text from a file."));
1414    }
1415
1416    #[test]
1417    fn test_render_csharp_xml_sections() {
1418        let sections = fixture_sections();
1419        let out = render_csharp_xml_sections(&sections, "KreuzbergException");
1420        assert!(out.contains("<summary>\nExtracts text from a file.\n</summary>"));
1421        assert!(out.contains("<param name=\"path\">The file path.</param>"));
1422        assert!(out.contains("<returns>The extracted text and metadata.</returns>"));
1423        assert!(out.contains("<exception cref=\"KreuzbergException\">"));
1424        assert!(out.contains("<example><code language=\"csharp\">"));
1425        assert!(out.contains("let result = extract"));
1426    }
1427
1428    #[test]
1429    fn test_render_phpdoc_sections() {
1430        let sections = fixture_sections();
1431        let out = render_phpdoc_sections(&sections, "KreuzbergException");
1432        assert!(out.contains("@param mixed $path The file path."));
1433        assert!(out.contains("@return The extracted text and metadata."));
1434        assert!(out.contains("@throws KreuzbergException"));
1435        // fixture example is ```rust — stripped when target is PHP
1436        assert!(!out.contains("```php"), "Rust example must not appear in PHPDoc");
1437        assert!(!out.contains("```rust"));
1438    }
1439
1440    #[test]
1441    fn test_render_phpdoc_sections_preserves_php_example() {
1442        let doc = "Do something.\n\n# Example\n\n```php\n$x = doSomething();\n```";
1443        let sections = parse_rustdoc_sections(doc);
1444        let out = render_phpdoc_sections(&sections, "MyException");
1445        assert!(out.contains("```php"), "PHP example must be preserved");
1446    }
1447
1448    #[test]
1449    fn test_render_doxygen_sections() {
1450        let sections = fixture_sections();
1451        let out = render_doxygen_sections(&sections);
1452        assert!(out.contains("\\param path The file path."));
1453        assert!(out.contains("\\return The extracted text and metadata."));
1454        assert!(out.contains("\\code"));
1455        assert!(out.contains("\\endcode"));
1456    }
1457
1458    #[test]
1459    fn test_emit_yard_doc_simple() {
1460        let mut out = String::new();
1461        emit_yard_doc(&mut out, "Simple Ruby documentation", "    ");
1462        assert!(out.contains("# Simple Ruby documentation"));
1463    }
1464
1465    #[test]
1466    fn test_emit_yard_doc_empty() {
1467        let mut out = String::new();
1468        emit_yard_doc(&mut out, "", "    ");
1469        assert!(out.is_empty());
1470    }
1471
1472    #[test]
1473    fn test_emit_yard_doc_with_sections() {
1474        let mut out = String::new();
1475        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 error on failure.";
1476        emit_yard_doc(&mut out, doc, "  ");
1477        assert!(out.contains("# Extracts text from a file."));
1478        assert!(out.contains("# @param path The file path."));
1479        assert!(out.contains("# @return The extracted text."));
1480        assert!(out.contains("# @raise Returns error on failure."));
1481    }
1482
1483    #[test]
1484    fn test_emit_c_doxygen_simple_prose() {
1485        let mut out = String::new();
1486        emit_c_doxygen(&mut out, "Free a string.", "");
1487        assert!(out.contains("/// Free a string."), "got: {out}");
1488    }
1489
1490    #[test]
1491    fn test_emit_c_doxygen_with_sections() {
1492        let mut out = String::new();
1493        let doc = "Extract content from a file.\n\n# Arguments\n\n* `path` - Path to the file.\n* `mode` - Read mode.\n\n# Returns\n\nA newly allocated string the caller owns.\n\n# Errors\n\nReturns null when the file is unreadable.";
1494        emit_c_doxygen(&mut out, doc, "");
1495        assert!(out.contains("/// Extract content from a file."));
1496        assert!(out.contains("/// \\param path Path to the file."));
1497        assert!(out.contains("/// \\param mode Read mode."));
1498        assert!(out.contains("/// \\return A newly allocated string the caller owns."));
1499        assert!(out.contains("/// \\note Returns null when the file is unreadable."));
1500    }
1501
1502    #[test]
1503    fn test_emit_c_doxygen_safety_section_maps_to_note() {
1504        let mut out = String::new();
1505        let doc = "Free a buffer.\n\n# Safety\n\nPointer must have been returned by this library.";
1506        emit_c_doxygen(&mut out, doc, "");
1507        assert!(out.contains("/// \\note SAFETY: Pointer must have been returned by this library."));
1508    }
1509
1510    #[test]
1511    fn test_emit_c_doxygen_example_renders_code_fence() {
1512        let mut out = String::new();
1513        let doc = "Demo.\n\n# Example\n\n```rust\nlet x = run();\n```";
1514        emit_c_doxygen(&mut out, doc, "");
1515        assert!(out.contains("/// \\code"));
1516        assert!(out.contains("/// \\endcode"));
1517        assert!(out.contains("let x = run();"));
1518    }
1519
1520    #[test]
1521    fn test_emit_c_doxygen_strips_markdown_links() {
1522        let mut out = String::new();
1523        let doc = "See [the docs](https://example.com/x) for details.";
1524        emit_c_doxygen(&mut out, doc, "");
1525        assert!(
1526            out.contains("the docs (https://example.com/x)"),
1527            "expected flattened link, got: {out}"
1528        );
1529        assert!(!out.contains("](https://"));
1530    }
1531
1532    #[test]
1533    fn test_emit_c_doxygen_word_wraps_long_lines() {
1534        let mut out = String::new();
1535        let long = "a ".repeat(80);
1536        emit_c_doxygen(&mut out, long.trim(), "");
1537        for line in out.lines() {
1538            // Each emitted prefix is "/// " (4 chars); the body after that
1539            // should be ≤ 100 chars per `DOXYGEN_WRAP_WIDTH`.
1540            let body = line.trim_start_matches("/// ");
1541            assert!(body.len() <= 100, "line too long ({}): {line}", body.len());
1542        }
1543    }
1544
1545    #[test]
1546    fn test_emit_c_doxygen_empty_input_is_noop() {
1547        let mut out = String::new();
1548        emit_c_doxygen(&mut out, "", "");
1549        emit_c_doxygen(&mut out, "   \n\t  ", "");
1550        assert!(out.is_empty());
1551    }
1552
1553    #[test]
1554    fn test_emit_c_doxygen_indent_applied() {
1555        let mut out = String::new();
1556        emit_c_doxygen(&mut out, "Hello.", "    ");
1557        assert!(out.starts_with("    /// Hello."));
1558    }
1559
1560    #[test]
1561    fn test_render_yard_sections() {
1562        let sections = fixture_sections();
1563        let out = render_yard_sections(&sections);
1564        assert!(out.contains("@param path The file path."));
1565        assert!(out.contains("@return The extracted text and metadata."));
1566        assert!(out.contains("@raise Returns an error when the file is unreadable."));
1567        // fixture example is ```rust — stripped when target is Ruby
1568        assert!(!out.contains("@example"), "Rust example must not appear in YARD");
1569        assert!(!out.contains("```ruby"));
1570        assert!(!out.contains("```rust"));
1571    }
1572
1573    #[test]
1574    fn test_render_yard_sections_preserves_ruby_example() {
1575        let doc = "Do something.\n\n# Example\n\n```ruby\nputs :hi\n```";
1576        let sections = parse_rustdoc_sections(doc);
1577        let out = render_yard_sections(&sections);
1578        assert!(out.contains("@example"), "Ruby example must be preserved");
1579        assert!(out.contains("```ruby"));
1580    }
1581
1582    // --- M1: example_for_target unit tests ---
1583
1584    #[test]
1585    fn example_for_target_rust_fenced_suppressed_for_php() {
1586        let example = "```rust\nlet x = 1;\n```";
1587        assert_eq!(
1588            example_for_target(example, "php"),
1589            None,
1590            "rust-fenced example must be omitted for PHP target"
1591        );
1592    }
1593
1594    #[test]
1595    fn example_for_target_bare_fence_defaults_to_rust_suppressed_for_ruby() {
1596        let example = "```\nlet x = 1;\n```";
1597        assert_eq!(
1598            example_for_target(example, "ruby"),
1599            None,
1600            "bare fence is treated as Rust and must be omitted for Ruby target"
1601        );
1602    }
1603
1604    #[test]
1605    fn example_for_target_php_example_preserved_for_php() {
1606        let example = "```php\n$x = 1;\n```";
1607        let result = example_for_target(example, "php");
1608        assert!(result.is_some(), "PHP example must be preserved for PHP target");
1609        assert!(result.unwrap().contains("```php"));
1610    }
1611
1612    #[test]
1613    fn example_for_target_ruby_example_preserved_for_ruby() {
1614        let example = "```ruby\nputs :hi\n```";
1615        let result = example_for_target(example, "ruby");
1616        assert!(result.is_some(), "Ruby example must be preserved for Ruby target");
1617        assert!(result.unwrap().contains("```ruby"));
1618    }
1619
1620    #[test]
1621    fn render_phpdoc_sections_with_rust_example_emits_no_at_example_block() {
1622        let doc = "Convert HTML.\n\n# Arguments\n\n* `html` - The HTML input.\n\n# Example\n\n```rust\nlet result = convert(html, None)?;\n```";
1623        let sections = parse_rustdoc_sections(doc);
1624        let out = render_phpdoc_sections(&sections, "HtmlToMarkdownException");
1625        assert!(!out.contains("```php"), "no PHP @example block for Rust source");
1626        assert!(!out.contains("```rust"), "raw Rust must not leak into PHPDoc");
1627        assert!(out.contains("@param"), "other sections must still be emitted");
1628    }
1629}