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