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