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