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