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 Zig documentation comments (///)
234/// Used for Zig functions, types, and declarations.
235pub fn emit_zig_doc(out: &mut String, doc: &str, indent: &str) {
236    if doc.is_empty() {
237        return;
238    }
239    for line in doc.lines() {
240        out.push_str(indent);
241        out.push_str("/// ");
242        out.push_str(line);
243        out.push('\n');
244    }
245}
246
247/// Escape Javadoc line: handle XML special chars and backtick code blocks.
248///
249/// HTML entities (`<`, `>`, `&`) are also escaped *inside* `{@code …}` blocks.
250/// Without that, content like `` `<pre><code>` `` would emit raw `<pre>`
251/// inside the Javadoc tag — Eclipse-formatter Spotless then treats it as a
252/// real `<pre>` block element and shatters the line across multiple `* `
253/// rows, breaking `alef-verify`'s embedded hash. Escaped content is
254/// rendered identically by Javadoc readers (the `{@code}` tag shows literal
255/// characters) and is stable under any post-formatter pass.
256fn escape_javadoc_line(s: &str) -> String {
257    let mut result = String::with_capacity(s.len());
258    let mut chars = s.chars().peekable();
259    while let Some(ch) = chars.next() {
260        if ch == '`' {
261            let mut code = String::new();
262            for c in chars.by_ref() {
263                if c == '`' {
264                    break;
265                }
266                code.push(c);
267            }
268            result.push_str("{@code ");
269            result.push_str(&escape_javadoc_html_entities(&code));
270            result.push('}');
271        } else if ch == '<' {
272            result.push_str("&lt;");
273        } else if ch == '>' {
274            result.push_str("&gt;");
275        } else if ch == '&' {
276            result.push_str("&amp;");
277        } else {
278            result.push(ch);
279        }
280    }
281    result
282}
283
284/// Escape only the HTML special characters that would otherwise be parsed by
285/// downstream Javadoc/Eclipse formatters as block-level HTML (e.g. `<pre>`).
286fn escape_javadoc_html_entities(s: &str) -> String {
287    let mut out = String::with_capacity(s.len());
288    for ch in s.chars() {
289        match ch {
290            '<' => out.push_str("&lt;"),
291            '>' => out.push_str("&gt;"),
292            '&' => out.push_str("&amp;"),
293            other => out.push(other),
294        }
295    }
296    out
297}
298
299/// A parsed rustdoc comment broken out into the sections binding emitters
300/// care about.
301///
302/// `summary` is the leading prose paragraph(s) before any `# Heading`.
303/// Sections are stored verbatim (without the `# Heading` line itself);
304/// each binding is responsible for translating bullet lists and code
305/// fences into its host-native conventions.
306///
307/// Trailing/leading whitespace inside each field is trimmed so emitters
308/// can concatenate without producing `* ` lines containing only spaces.
309#[derive(Debug, Default, Clone, PartialEq, Eq)]
310pub struct RustdocSections {
311    /// Prose before the first `# Section` heading.
312    pub summary: String,
313    /// Body of the `# Arguments` section, if present.
314    pub arguments: Option<String>,
315    /// Body of the `# Returns` section, if present.
316    pub returns: Option<String>,
317    /// Body of the `# Errors` section, if present.
318    pub errors: Option<String>,
319    /// Body of the `# Panics` section, if present.
320    pub panics: Option<String>,
321    /// Body of the `# Safety` section, if present.
322    pub safety: Option<String>,
323    /// Body of the `# Example` / `# Examples` section, if present.
324    pub example: Option<String>,
325}
326
327/// Parse a rustdoc string into [`RustdocSections`].
328///
329/// Recognises level-1 ATX headings whose name matches one of the standard
330/// rustdoc section names (`Arguments`, `Returns`, `Errors`, `Panics`,
331/// `Safety`, `Example`, `Examples`). Anything before the first heading
332/// becomes `summary`. Unrecognised headings are folded into the
333/// preceding section verbatim, so unconventional rustdoc isn't lost.
334///
335/// The input is expected to already have rustdoc-hidden lines stripped
336/// and intra-doc-link syntax rewritten by
337/// [`crate::extractor::helpers::normalize_rustdoc`].
338pub fn parse_rustdoc_sections(doc: &str) -> RustdocSections {
339    if doc.trim().is_empty() {
340        return RustdocSections::default();
341    }
342    let mut summary = String::new();
343    let mut arguments: Option<String> = None;
344    let mut returns: Option<String> = None;
345    let mut errors: Option<String> = None;
346    let mut panics: Option<String> = None;
347    let mut safety: Option<String> = None;
348    let mut example: Option<String> = None;
349    let mut current: Option<&'static str> = None;
350    let mut buf = String::new();
351    let mut in_fence = false;
352    let flush = |target: Option<&'static str>,
353                 buf: &mut String,
354                 summary: &mut String,
355                 arguments: &mut Option<String>,
356                 returns: &mut Option<String>,
357                 errors: &mut Option<String>,
358                 panics: &mut Option<String>,
359                 safety: &mut Option<String>,
360                 example: &mut Option<String>| {
361        let body = std::mem::take(buf).trim().to_string();
362        if body.is_empty() {
363            return;
364        }
365        match target {
366            None => {
367                if !summary.is_empty() {
368                    summary.push('\n');
369                }
370                summary.push_str(&body);
371            }
372            Some("arguments") => *arguments = Some(body),
373            Some("returns") => *returns = Some(body),
374            Some("errors") => *errors = Some(body),
375            Some("panics") => *panics = Some(body),
376            Some("safety") => *safety = Some(body),
377            Some("example") => *example = Some(body),
378            _ => {}
379        }
380    };
381    for line in doc.lines() {
382        let trimmed = line.trim_start();
383        if trimmed.starts_with("```") {
384            in_fence = !in_fence;
385            buf.push_str(line);
386            buf.push('\n');
387            continue;
388        }
389        if !in_fence {
390            if let Some(rest) = trimmed.strip_prefix("# ") {
391                let head = rest.trim().to_ascii_lowercase();
392                let target = match head.as_str() {
393                    "arguments" | "args" => Some("arguments"),
394                    "returns" => Some("returns"),
395                    "errors" => Some("errors"),
396                    "panics" => Some("panics"),
397                    "safety" => Some("safety"),
398                    "example" | "examples" => Some("example"),
399                    _ => None,
400                };
401                if target.is_some() {
402                    flush(
403                        current,
404                        &mut buf,
405                        &mut summary,
406                        &mut arguments,
407                        &mut returns,
408                        &mut errors,
409                        &mut panics,
410                        &mut safety,
411                        &mut example,
412                    );
413                    current = target;
414                    continue;
415                }
416            }
417        }
418        buf.push_str(line);
419        buf.push('\n');
420    }
421    flush(
422        current,
423        &mut buf,
424        &mut summary,
425        &mut arguments,
426        &mut returns,
427        &mut errors,
428        &mut panics,
429        &mut safety,
430        &mut example,
431    );
432    RustdocSections {
433        summary,
434        arguments,
435        returns,
436        errors,
437        panics,
438        safety,
439        example,
440    }
441}
442
443/// Parse `# Arguments` body into `(name, description)` pairs.
444///
445/// Recognises both Markdown bullet styles `*` and `-`, with optional
446/// backticks around the name: `* `name` - description` or
447/// `- name: description`. Continuation lines indented under a bullet
448/// are appended to the previous entry's description.
449///
450/// Used by emitters that translate to per-parameter documentation tags
451/// (`@param`, `<param>`, `\param`).
452pub fn parse_arguments_bullets(body: &str) -> Vec<(String, String)> {
453    let mut out: Vec<(String, String)> = Vec::new();
454    for raw in body.lines() {
455        let line = raw.trim_end();
456        let trimmed = line.trim_start();
457        let is_bullet = trimmed.starts_with("* ") || trimmed.starts_with("- ");
458        if is_bullet {
459            let after = &trimmed[2..];
460            // Accept `name`, `name:` or `name -` separator forms.
461            let (name, desc) = if let Some(idx) = after.find(" - ") {
462                (after[..idx].trim(), after[idx + 3..].trim())
463            } else if let Some(idx) = after.find(": ") {
464                (after[..idx].trim(), after[idx + 2..].trim())
465            } else if let Some(idx) = after.find(' ') {
466                (after[..idx].trim(), after[idx + 1..].trim())
467            } else {
468                (after.trim(), "")
469            };
470            let name = name.trim_matches('`').trim_matches('*').to_string();
471            out.push((name, desc.to_string()));
472        } else if !trimmed.is_empty() {
473            if let Some(last) = out.last_mut() {
474                if !last.1.is_empty() {
475                    last.1.push(' ');
476                }
477                last.1.push_str(trimmed);
478            }
479        }
480    }
481    out
482}
483
484/// Strip a single ` ```lang ` fence pair from `body`, returning the inner
485/// code lines. Replaces the leading ` ```rust ` (or any other tag) with
486/// `lang_replacement`, leaving the rest of the body unchanged.
487///
488/// When no fence is present the body is returned unchanged. Used by
489/// emitters that need to convert ` ```rust ` examples into
490/// ` ```typescript ` / ` ```python ` / ` ```swift ` etc.
491pub fn replace_fence_lang(body: &str, lang_replacement: &str) -> String {
492    let mut out = String::with_capacity(body.len());
493    for line in body.lines() {
494        let trimmed = line.trim_start();
495        if let Some(rest) = trimmed.strip_prefix("```") {
496            // Replace the language tag (everything up to the next comma or
497            // end of line). Preserve indentation.
498            let indent = &line[..line.len() - trimmed.len()];
499            let after_lang = rest.find(',').map(|i| &rest[i..]).unwrap_or("");
500            out.push_str(indent);
501            out.push_str("```");
502            out.push_str(lang_replacement);
503            out.push_str(after_lang);
504            out.push('\n');
505        } else {
506            out.push_str(line);
507            out.push('\n');
508        }
509    }
510    out.trim_end_matches('\n').to_string()
511}
512
513/// Render `RustdocSections` as a JSDoc comment body (without the `/**` /
514/// ` */` wrappers — those are added by the caller's emitter, which knows
515/// the indent/escape conventions).
516///
517/// - `# Arguments` → `@param name - desc`
518/// - `# Returns`   → `@returns desc`
519/// - `# Errors`    → `@throws desc`
520/// - `# Example`   → `@example` block. Replaces ` ```rust ` fences with
521///   ` ```typescript ` so the example highlights properly in TypeDoc.
522///
523/// Output is a plain string with `\n` separators; emitters wrap each line
524/// in ` * ` themselves.
525pub fn render_jsdoc_sections(sections: &RustdocSections) -> String {
526    let mut out = String::new();
527    if !sections.summary.is_empty() {
528        out.push_str(&sections.summary);
529    }
530    if let Some(args) = sections.arguments.as_deref() {
531        for (name, desc) in parse_arguments_bullets(args) {
532            if !out.is_empty() {
533                out.push('\n');
534            }
535            if desc.is_empty() {
536                out.push_str(&crate::template_env::render(
537                    "doc_jsdoc_param.jinja",
538                    minijinja::context! { name => &name },
539                ));
540            } else {
541                out.push_str(&crate::template_env::render(
542                    "doc_jsdoc_param_desc.jinja",
543                    minijinja::context! { name => &name, desc => &desc },
544                ));
545            }
546        }
547    }
548    if let Some(ret) = sections.returns.as_deref() {
549        if !out.is_empty() {
550            out.push('\n');
551        }
552        out.push_str(&crate::template_env::render(
553            "doc_jsdoc_returns.jinja",
554            minijinja::context! { content => ret.trim() },
555        ));
556    }
557    if let Some(err) = sections.errors.as_deref() {
558        if !out.is_empty() {
559            out.push('\n');
560        }
561        out.push_str(&crate::template_env::render(
562            "doc_jsdoc_throws.jinja",
563            minijinja::context! { content => err.trim() },
564        ));
565    }
566    if let Some(example) = sections.example.as_deref() {
567        if !out.is_empty() {
568            out.push('\n');
569        }
570        out.push_str("@example\n");
571        out.push_str(&replace_fence_lang(example.trim(), "typescript"));
572    }
573    out
574}
575
576/// Render `RustdocSections` as a JavaDoc comment body.
577///
578/// - `# Arguments` → `@param name desc` (one per param)
579/// - `# Returns`   → `@return desc`
580/// - `# Errors`    → `@throws KreuzbergRsException desc`
581/// - `# Example`   → `<pre>{@code ...}</pre>` block.
582///
583/// `throws_class` is the FQN/simple name of the exception class to use in
584/// the `@throws` tag (e.g. `"KreuzbergRsException"`).
585pub fn render_javadoc_sections(sections: &RustdocSections, throws_class: &str) -> String {
586    let mut out = String::new();
587    if !sections.summary.is_empty() {
588        out.push_str(&sections.summary);
589    }
590    if let Some(args) = sections.arguments.as_deref() {
591        for (name, desc) in parse_arguments_bullets(args) {
592            if !out.is_empty() {
593                out.push('\n');
594            }
595            if desc.is_empty() {
596                out.push_str(&crate::template_env::render(
597                    "doc_javadoc_param.jinja",
598                    minijinja::context! { name => &name },
599                ));
600            } else {
601                out.push_str(&crate::template_env::render(
602                    "doc_javadoc_param_desc.jinja",
603                    minijinja::context! { name => &name, desc => &desc },
604                ));
605            }
606        }
607    }
608    if let Some(ret) = sections.returns.as_deref() {
609        if !out.is_empty() {
610            out.push('\n');
611        }
612        out.push_str(&crate::template_env::render(
613            "doc_javadoc_return.jinja",
614            minijinja::context! { content => ret.trim() },
615        ));
616    }
617    if let Some(err) = sections.errors.as_deref() {
618        if !out.is_empty() {
619            out.push('\n');
620        }
621        out.push_str(&crate::template_env::render(
622            "doc_javadoc_throws.jinja",
623            minijinja::context! { throws_class => throws_class, content => err.trim() },
624        ));
625    }
626    out
627}
628
629/// Render `RustdocSections` as a C# XML doc comment body (without the
630/// `/// ` line prefixes — the emitter adds those).
631///
632/// - summary  → `<summary>...</summary>`
633/// - args     → `<param name="x">desc</param>` (one per arg)
634/// - returns  → `<returns>desc</returns>`
635/// - errors   → `<exception cref="KreuzbergException">desc</exception>`
636/// - example  → `<example><code language="csharp">...</code></example>`
637pub fn render_csharp_xml_sections(sections: &RustdocSections, exception_class: &str) -> String {
638    let mut out = String::new();
639    out.push_str("<summary>\n");
640    let summary = if sections.summary.is_empty() {
641        ""
642    } else {
643        sections.summary.as_str()
644    };
645    for line in summary.lines() {
646        out.push_str(line);
647        out.push('\n');
648    }
649    out.push_str("</summary>");
650    if let Some(args) = sections.arguments.as_deref() {
651        for (name, desc) in parse_arguments_bullets(args) {
652            out.push('\n');
653            if desc.is_empty() {
654                out.push_str(&crate::template_env::render(
655                    "doc_csharp_param.jinja",
656                    minijinja::context! { name => &name },
657                ));
658            } else {
659                out.push_str(&crate::template_env::render(
660                    "doc_csharp_param_desc.jinja",
661                    minijinja::context! { name => &name, desc => &desc },
662                ));
663            }
664        }
665    }
666    if let Some(ret) = sections.returns.as_deref() {
667        out.push('\n');
668        out.push_str(&crate::template_env::render(
669            "doc_csharp_returns.jinja",
670            minijinja::context! { content => ret.trim() },
671        ));
672    }
673    if let Some(err) = sections.errors.as_deref() {
674        out.push('\n');
675        out.push_str(&crate::template_env::render(
676            "doc_csharp_exception.jinja",
677            minijinja::context! {
678                exception_class => exception_class,
679                content => err.trim(),
680            },
681        ));
682    }
683    if let Some(example) = sections.example.as_deref() {
684        out.push('\n');
685        out.push_str("<example><code language=\"csharp\">\n");
686        // Drop fence markers, keep code.
687        for line in example.lines() {
688            let t = line.trim_start();
689            if t.starts_with("```") {
690                continue;
691            }
692            out.push_str(line);
693            out.push('\n');
694        }
695        out.push_str("</code></example>");
696    }
697    out
698}
699
700/// Render `RustdocSections` as a PHPDoc comment body.
701///
702/// - `# Arguments` → `@param mixed $name desc`
703/// - `# Returns`   → `@return desc`
704/// - `# Errors`    → `@throws KreuzbergException desc`
705/// - `# Example`   → ` ```php ` fence (replaces ` ```rust `).
706pub fn render_phpdoc_sections(sections: &RustdocSections, throws_class: &str) -> String {
707    let mut out = String::new();
708    if !sections.summary.is_empty() {
709        out.push_str(&sections.summary);
710    }
711    if let Some(args) = sections.arguments.as_deref() {
712        for (name, desc) in parse_arguments_bullets(args) {
713            if !out.is_empty() {
714                out.push('\n');
715            }
716            if desc.is_empty() {
717                out.push_str(&crate::template_env::render(
718                    "doc_phpdoc_param.jinja",
719                    minijinja::context! { name => &name },
720                ));
721            } else {
722                out.push_str(&crate::template_env::render(
723                    "doc_phpdoc_param_desc.jinja",
724                    minijinja::context! { name => &name, desc => &desc },
725                ));
726            }
727        }
728    }
729    if let Some(ret) = sections.returns.as_deref() {
730        if !out.is_empty() {
731            out.push('\n');
732        }
733        out.push_str(&crate::template_env::render(
734            "doc_phpdoc_return.jinja",
735            minijinja::context! { content => ret.trim() },
736        ));
737    }
738    if let Some(err) = sections.errors.as_deref() {
739        if !out.is_empty() {
740            out.push('\n');
741        }
742        out.push_str(&crate::template_env::render(
743            "doc_phpdoc_throws.jinja",
744            minijinja::context! { throws_class => throws_class, content => err.trim() },
745        ));
746    }
747    if let Some(example) = sections.example.as_deref() {
748        if !out.is_empty() {
749            out.push('\n');
750        }
751        out.push_str(&replace_fence_lang(example.trim(), "php"));
752    }
753    out
754}
755
756/// Render `RustdocSections` as a Doxygen comment body for the C header.
757///
758/// - args    → `\param name desc`
759/// - returns → `\return desc`
760/// - errors  → prose paragraph (Doxygen has no semantic tag for FFI errors)
761/// - example → `\code` ... `\endcode`
762pub fn render_doxygen_sections(sections: &RustdocSections) -> String {
763    let mut out = String::new();
764    if !sections.summary.is_empty() {
765        out.push_str(&sections.summary);
766    }
767    if let Some(args) = sections.arguments.as_deref() {
768        for (name, desc) in parse_arguments_bullets(args) {
769            if !out.is_empty() {
770                out.push('\n');
771            }
772            if desc.is_empty() {
773                out.push_str(&crate::template_env::render(
774                    "doc_doxygen_param.jinja",
775                    minijinja::context! { name => &name },
776                ));
777            } else {
778                out.push_str(&crate::template_env::render(
779                    "doc_doxygen_param_desc.jinja",
780                    minijinja::context! { name => &name, desc => &desc },
781                ));
782            }
783        }
784    }
785    if let Some(ret) = sections.returns.as_deref() {
786        if !out.is_empty() {
787            out.push('\n');
788        }
789        out.push_str(&crate::template_env::render(
790            "doc_doxygen_return.jinja",
791            minijinja::context! { content => ret.trim() },
792        ));
793    }
794    if let Some(err) = sections.errors.as_deref() {
795        if !out.is_empty() {
796            out.push('\n');
797        }
798        out.push_str(&crate::template_env::render(
799            "doc_doxygen_errors.jinja",
800            minijinja::context! { content => err.trim() },
801        ));
802    }
803    if let Some(example) = sections.example.as_deref() {
804        if !out.is_empty() {
805            out.push('\n');
806        }
807        out.push_str("\\code\n");
808        for line in example.lines() {
809            let t = line.trim_start();
810            if t.starts_with("```") {
811                continue;
812            }
813            out.push_str(line);
814            out.push('\n');
815        }
816        out.push_str("\\endcode");
817    }
818    out
819}
820
821/// Return the first paragraph of a doc comment as a single joined line.
822///
823/// Collects lines until the first blank line, trims each, then joins with a
824/// space. This handles wrapped sentences like:
825///
826/// ```text
827/// Convert HTML to Markdown, returning
828/// a `ConversionResult`.
829/// ```
830///
831/// which would otherwise be truncated at the comma when callers use
832/// `.lines().next()`.
833pub fn doc_first_paragraph_joined(doc: &str) -> String {
834    doc.lines()
835        .take_while(|l| !l.trim().is_empty())
836        .map(str::trim)
837        .collect::<Vec<_>>()
838        .join(" ")
839}
840
841#[cfg(test)]
842mod tests {
843    use super::*;
844
845    #[test]
846    fn test_emit_phpdoc() {
847        let mut out = String::new();
848        emit_phpdoc(&mut out, "Simple documentation", "    ", "TestException");
849        assert!(out.contains("/**"));
850        assert!(out.contains("Simple documentation"));
851        assert!(out.contains("*/"));
852    }
853
854    #[test]
855    fn test_phpdoc_escaping() {
856        let mut out = String::new();
857        emit_phpdoc(&mut out, "Handle */ sequences", "", "TestException");
858        assert!(out.contains("Handle * / sequences"));
859    }
860
861    #[test]
862    fn test_emit_csharp_doc() {
863        let mut out = String::new();
864        emit_csharp_doc(&mut out, "C# documentation", "    ", "TestException");
865        assert!(out.contains("<summary>"));
866        assert!(out.contains("C# documentation"));
867        assert!(out.contains("</summary>"));
868    }
869
870    #[test]
871    fn test_csharp_xml_escaping() {
872        let mut out = String::new();
873        emit_csharp_doc(&mut out, "foo < bar & baz > qux", "", "TestException");
874        assert!(out.contains("foo &lt; bar &amp; baz &gt; qux"));
875    }
876
877    #[test]
878    fn test_emit_elixir_doc() {
879        let mut out = String::new();
880        emit_elixir_doc(&mut out, "Elixir documentation");
881        assert!(out.contains("@doc \"\"\""));
882        assert!(out.contains("Elixir documentation"));
883        assert!(out.contains("\"\"\""));
884    }
885
886    #[test]
887    fn test_elixir_heredoc_escaping() {
888        let mut out = String::new();
889        emit_elixir_doc(&mut out, "Handle \"\"\" sequences");
890        assert!(out.contains("Handle \"\" \" sequences"));
891    }
892
893    #[test]
894    fn test_emit_roxygen() {
895        let mut out = String::new();
896        emit_roxygen(&mut out, "R documentation");
897        assert!(out.contains("#' R documentation"));
898    }
899
900    #[test]
901    fn test_emit_swift_doc() {
902        let mut out = String::new();
903        emit_swift_doc(&mut out, "Swift documentation", "    ");
904        assert!(out.contains("/// Swift documentation"));
905    }
906
907    #[test]
908    fn test_emit_javadoc() {
909        let mut out = String::new();
910        emit_javadoc(&mut out, "Java documentation", "    ");
911        assert!(out.contains("/**"));
912        assert!(out.contains("Java documentation"));
913        assert!(out.contains("*/"));
914    }
915
916    #[test]
917    fn test_emit_kdoc() {
918        let mut out = String::new();
919        emit_kdoc(&mut out, "Kotlin documentation", "    ");
920        assert!(out.contains("/**"));
921        assert!(out.contains("Kotlin documentation"));
922        assert!(out.contains("*/"));
923    }
924
925    #[test]
926    fn test_emit_dartdoc() {
927        let mut out = String::new();
928        emit_dartdoc(&mut out, "Dart documentation", "    ");
929        assert!(out.contains("/// Dart documentation"));
930    }
931
932    #[test]
933    fn test_emit_gleam_doc() {
934        let mut out = String::new();
935        emit_gleam_doc(&mut out, "Gleam documentation", "    ");
936        assert!(out.contains("/// Gleam documentation"));
937    }
938
939    #[test]
940    fn test_emit_zig_doc() {
941        let mut out = String::new();
942        emit_zig_doc(&mut out, "Zig documentation", "    ");
943        assert!(out.contains("/// Zig documentation"));
944    }
945
946    #[test]
947    fn test_empty_doc_skipped() {
948        let mut out = String::new();
949        emit_phpdoc(&mut out, "", "", "TestException");
950        emit_csharp_doc(&mut out, "", "", "TestException");
951        emit_elixir_doc(&mut out, "");
952        emit_roxygen(&mut out, "");
953        emit_kdoc(&mut out, "", "");
954        emit_dartdoc(&mut out, "", "");
955        emit_gleam_doc(&mut out, "", "");
956        emit_zig_doc(&mut out, "", "");
957        assert!(out.is_empty());
958    }
959
960    #[test]
961    fn test_doc_first_paragraph_joined_single_line() {
962        assert_eq!(doc_first_paragraph_joined("Simple doc."), "Simple doc.");
963    }
964
965    #[test]
966    fn test_doc_first_paragraph_joined_wrapped_sentence() {
967        // Simulates a docstring like convert's: "Convert HTML to Markdown,\nreturning a result."
968        let doc = "Convert HTML to Markdown,\nreturning a result.";
969        assert_eq!(
970            doc_first_paragraph_joined(doc),
971            "Convert HTML to Markdown, returning a result."
972        );
973    }
974
975    #[test]
976    fn test_doc_first_paragraph_joined_stops_at_blank_line() {
977        let doc = "First paragraph.\nStill first.\n\nSecond paragraph.";
978        assert_eq!(doc_first_paragraph_joined(doc), "First paragraph. Still first.");
979    }
980
981    #[test]
982    fn test_doc_first_paragraph_joined_empty() {
983        assert_eq!(doc_first_paragraph_joined(""), "");
984    }
985
986    #[test]
987    fn test_parse_rustdoc_sections_basic() {
988        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.";
989        let sections = parse_rustdoc_sections(doc);
990        assert_eq!(sections.summary, "Extracts text from a file.");
991        assert_eq!(sections.arguments.as_deref(), Some("* `path` - The file path."));
992        assert_eq!(sections.returns.as_deref(), Some("The extracted text."));
993        assert_eq!(sections.errors.as_deref(), Some("Returns `KreuzbergError` on failure."));
994        assert!(sections.panics.is_none());
995    }
996
997    #[test]
998    fn test_parse_rustdoc_sections_example_with_fence() {
999        let doc = "Run the thing.\n\n# Example\n\n```rust\nlet x = run();\n```";
1000        let sections = parse_rustdoc_sections(doc);
1001        assert_eq!(sections.summary, "Run the thing.");
1002        assert!(sections.example.as_ref().unwrap().contains("```rust"));
1003        assert!(sections.example.as_ref().unwrap().contains("let x = run();"));
1004    }
1005
1006    #[test]
1007    fn test_parse_rustdoc_sections_pound_inside_fence_is_not_a_heading() {
1008        // Even though we get rustdoc-hidden lines pre-stripped, a literal
1009        // `# foo` inside a non-rust fence (e.g. shell example) must not
1010        // start a new section.
1011        let doc = "Summary.\n\n# Example\n\n```bash\n# install deps\nrun --foo\n```";
1012        let sections = parse_rustdoc_sections(doc);
1013        assert_eq!(sections.summary, "Summary.");
1014        assert!(sections.example.as_ref().unwrap().contains("# install deps"));
1015    }
1016
1017    #[test]
1018    fn test_parse_arguments_bullets_dash_separator() {
1019        let body = "* `path` - The file path.\n* `config` - Optional configuration.";
1020        let pairs = parse_arguments_bullets(body);
1021        assert_eq!(pairs.len(), 2);
1022        assert_eq!(pairs[0], ("path".to_string(), "The file path.".to_string()));
1023        assert_eq!(pairs[1], ("config".to_string(), "Optional configuration.".to_string()));
1024    }
1025
1026    #[test]
1027    fn test_parse_arguments_bullets_continuation_line() {
1028        let body = "* `path` - The file path,\n  resolved relative to cwd.\n* `mode` - Open mode.";
1029        let pairs = parse_arguments_bullets(body);
1030        assert_eq!(pairs.len(), 2);
1031        assert_eq!(pairs[0].1, "The file path, resolved relative to cwd.");
1032    }
1033
1034    #[test]
1035    fn test_replace_fence_lang_rust_to_typescript() {
1036        let body = "```rust\nlet x = run();\n```";
1037        let out = replace_fence_lang(body, "typescript");
1038        assert!(out.starts_with("```typescript"));
1039        assert!(out.contains("let x = run();"));
1040    }
1041
1042    #[test]
1043    fn test_replace_fence_lang_preserves_attrs() {
1044        let body = "```rust,no_run\nlet x = run();\n```";
1045        let out = replace_fence_lang(body, "typescript");
1046        assert!(out.starts_with("```typescript,no_run"));
1047    }
1048
1049    #[test]
1050    fn test_replace_fence_lang_no_fence_unchanged() {
1051        let body = "Plain prose with `inline code`.";
1052        let out = replace_fence_lang(body, "typescript");
1053        assert_eq!(out, "Plain prose with `inline code`.");
1054    }
1055
1056    fn fixture_sections() -> RustdocSections {
1057        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```";
1058        parse_rustdoc_sections(doc)
1059    }
1060
1061    #[test]
1062    fn test_render_jsdoc_sections() {
1063        let sections = fixture_sections();
1064        let out = render_jsdoc_sections(&sections);
1065        assert!(out.starts_with("Extracts text from a file."));
1066        assert!(out.contains("@param path - The file path."));
1067        assert!(out.contains("@param config - Optional configuration."));
1068        assert!(out.contains("@returns The extracted text and metadata."));
1069        assert!(out.contains("@throws Returns an error when the file is unreadable."));
1070        assert!(out.contains("@example"));
1071        assert!(out.contains("```typescript"));
1072        assert!(!out.contains("```rust"));
1073    }
1074
1075    #[test]
1076    fn test_render_javadoc_sections() {
1077        let sections = fixture_sections();
1078        let out = render_javadoc_sections(&sections, "KreuzbergRsException");
1079        assert!(out.contains("@param path The file path."));
1080        assert!(out.contains("@return The extracted text and metadata."));
1081        assert!(out.contains("@throws KreuzbergRsException Returns an error when the file is unreadable."));
1082        // Java rendering omits the example block (handled separately by emit_javadoc which
1083        // wraps code in `<pre>{@code}</pre>`); we just confirm summary survives.
1084        assert!(out.starts_with("Extracts text from a file."));
1085    }
1086
1087    #[test]
1088    fn test_render_csharp_xml_sections() {
1089        let sections = fixture_sections();
1090        let out = render_csharp_xml_sections(&sections, "KreuzbergException");
1091        assert!(out.contains("<summary>\nExtracts text from a file.\n</summary>"));
1092        assert!(out.contains("<param name=\"path\">The file path.</param>"));
1093        assert!(out.contains("<returns>The extracted text and metadata.</returns>"));
1094        assert!(out.contains("<exception cref=\"KreuzbergException\">"));
1095        assert!(out.contains("<example><code language=\"csharp\">"));
1096        assert!(out.contains("let result = extract"));
1097    }
1098
1099    #[test]
1100    fn test_render_phpdoc_sections() {
1101        let sections = fixture_sections();
1102        let out = render_phpdoc_sections(&sections, "KreuzbergException");
1103        assert!(out.contains("@param mixed $path The file path."));
1104        assert!(out.contains("@return The extracted text and metadata."));
1105        assert!(out.contains("@throws KreuzbergException"));
1106        assert!(out.contains("```php"));
1107    }
1108
1109    #[test]
1110    fn test_render_doxygen_sections() {
1111        let sections = fixture_sections();
1112        let out = render_doxygen_sections(&sections);
1113        assert!(out.contains("\\param path The file path."));
1114        assert!(out.contains("\\return The extracted text and metadata."));
1115        assert!(out.contains("\\code"));
1116        assert!(out.contains("\\endcode"));
1117    }
1118}