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(&format!("@param {name}"));
533            } else {
534                out.push_str(&format!("@param {name} - {desc}"));
535            }
536        }
537    }
538    if let Some(ret) = sections.returns.as_deref() {
539        if !out.is_empty() {
540            out.push('\n');
541        }
542        out.push_str(&format!("@returns {}", ret.trim()));
543    }
544    if let Some(err) = sections.errors.as_deref() {
545        if !out.is_empty() {
546            out.push('\n');
547        }
548        out.push_str(&format!("@throws {}", err.trim()));
549    }
550    if let Some(example) = sections.example.as_deref() {
551        if !out.is_empty() {
552            out.push('\n');
553        }
554        out.push_str("@example\n");
555        out.push_str(&replace_fence_lang(example.trim(), "typescript"));
556    }
557    out
558}
559
560/// Render `RustdocSections` as a JavaDoc comment body.
561///
562/// - `# Arguments` → `@param name desc` (one per param)
563/// - `# Returns`   → `@return desc`
564/// - `# Errors`    → `@throws KreuzbergRsException desc`
565/// - `# Example`   → `<pre>{@code ...}</pre>` block.
566///
567/// `throws_class` is the FQN/simple name of the exception class to use in
568/// the `@throws` tag (e.g. `"KreuzbergRsException"`).
569pub fn render_javadoc_sections(sections: &RustdocSections, throws_class: &str) -> String {
570    let mut out = String::new();
571    if !sections.summary.is_empty() {
572        out.push_str(&sections.summary);
573    }
574    if let Some(args) = sections.arguments.as_deref() {
575        for (name, desc) in parse_arguments_bullets(args) {
576            if !out.is_empty() {
577                out.push('\n');
578            }
579            if desc.is_empty() {
580                out.push_str(&format!("@param {name}"));
581            } else {
582                out.push_str(&format!("@param {name} {desc}"));
583            }
584        }
585    }
586    if let Some(ret) = sections.returns.as_deref() {
587        if !out.is_empty() {
588            out.push('\n');
589        }
590        out.push_str(&format!("@return {}", ret.trim()));
591    }
592    if let Some(err) = sections.errors.as_deref() {
593        if !out.is_empty() {
594            out.push('\n');
595        }
596        out.push_str(&format!("@throws {throws_class} {}", err.trim()));
597    }
598    out
599}
600
601/// Render `RustdocSections` as a C# XML doc comment body (without the
602/// `/// ` line prefixes — the emitter adds those).
603///
604/// - summary  → `<summary>...</summary>`
605/// - args     → `<param name="x">desc</param>` (one per arg)
606/// - returns  → `<returns>desc</returns>`
607/// - errors   → `<exception cref="KreuzbergException">desc</exception>`
608/// - example  → `<example><code language="csharp">...</code></example>`
609pub fn render_csharp_xml_sections(sections: &RustdocSections, exception_class: &str) -> String {
610    let mut out = String::new();
611    out.push_str("<summary>\n");
612    let summary = if sections.summary.is_empty() {
613        ""
614    } else {
615        sections.summary.as_str()
616    };
617    for line in summary.lines() {
618        out.push_str(line);
619        out.push('\n');
620    }
621    out.push_str("</summary>");
622    if let Some(args) = sections.arguments.as_deref() {
623        for (name, desc) in parse_arguments_bullets(args) {
624            out.push('\n');
625            if desc.is_empty() {
626                out.push_str(&format!("<param name=\"{name}\"></param>"));
627            } else {
628                out.push_str(&format!("<param name=\"{name}\">{desc}</param>"));
629            }
630        }
631    }
632    if let Some(ret) = sections.returns.as_deref() {
633        out.push('\n');
634        out.push_str(&format!("<returns>{}</returns>", ret.trim()));
635    }
636    if let Some(err) = sections.errors.as_deref() {
637        out.push('\n');
638        out.push_str(&format!(
639            "<exception cref=\"{exception_class}\">{}</exception>",
640            err.trim()
641        ));
642    }
643    if let Some(example) = sections.example.as_deref() {
644        out.push('\n');
645        out.push_str("<example><code language=\"csharp\">\n");
646        // Drop fence markers, keep code.
647        for line in example.lines() {
648            let t = line.trim_start();
649            if t.starts_with("```") {
650                continue;
651            }
652            out.push_str(line);
653            out.push('\n');
654        }
655        out.push_str("</code></example>");
656    }
657    out
658}
659
660/// Render `RustdocSections` as a PHPDoc comment body.
661///
662/// - `# Arguments` → `@param mixed $name desc`
663/// - `# Returns`   → `@return desc`
664/// - `# Errors`    → `@throws KreuzbergException desc`
665/// - `# Example`   → ` ```php ` fence (replaces ` ```rust `).
666pub fn render_phpdoc_sections(sections: &RustdocSections, throws_class: &str) -> String {
667    let mut out = String::new();
668    if !sections.summary.is_empty() {
669        out.push_str(&sections.summary);
670    }
671    if let Some(args) = sections.arguments.as_deref() {
672        for (name, desc) in parse_arguments_bullets(args) {
673            if !out.is_empty() {
674                out.push('\n');
675            }
676            if desc.is_empty() {
677                out.push_str(&format!("@param mixed ${name}"));
678            } else {
679                out.push_str(&format!("@param mixed ${name} {desc}"));
680            }
681        }
682    }
683    if let Some(ret) = sections.returns.as_deref() {
684        if !out.is_empty() {
685            out.push('\n');
686        }
687        out.push_str(&format!("@return {}", ret.trim()));
688    }
689    if let Some(err) = sections.errors.as_deref() {
690        if !out.is_empty() {
691            out.push('\n');
692        }
693        out.push_str(&format!("@throws {throws_class} {}", err.trim()));
694    }
695    if let Some(example) = sections.example.as_deref() {
696        if !out.is_empty() {
697            out.push('\n');
698        }
699        out.push_str(&replace_fence_lang(example.trim(), "php"));
700    }
701    out
702}
703
704/// Render `RustdocSections` as a Doxygen comment body for the C header.
705///
706/// - args    → `\param name desc`
707/// - returns → `\return desc`
708/// - errors  → prose paragraph (Doxygen has no semantic tag for FFI errors)
709/// - example → `\code` ... `\endcode`
710pub fn render_doxygen_sections(sections: &RustdocSections) -> String {
711    let mut out = String::new();
712    if !sections.summary.is_empty() {
713        out.push_str(&sections.summary);
714    }
715    if let Some(args) = sections.arguments.as_deref() {
716        for (name, desc) in parse_arguments_bullets(args) {
717            if !out.is_empty() {
718                out.push('\n');
719            }
720            if desc.is_empty() {
721                out.push_str(&format!("\\param {name}"));
722            } else {
723                out.push_str(&format!("\\param {name} {desc}"));
724            }
725        }
726    }
727    if let Some(ret) = sections.returns.as_deref() {
728        if !out.is_empty() {
729            out.push('\n');
730        }
731        out.push_str(&format!("\\return {}", ret.trim()));
732    }
733    if let Some(err) = sections.errors.as_deref() {
734        if !out.is_empty() {
735            out.push('\n');
736        }
737        out.push_str(&format!("Errors: {}", err.trim()));
738    }
739    if let Some(example) = sections.example.as_deref() {
740        if !out.is_empty() {
741            out.push('\n');
742        }
743        out.push_str("\\code\n");
744        for line in example.lines() {
745            let t = line.trim_start();
746            if t.starts_with("```") {
747                continue;
748            }
749            out.push_str(line);
750            out.push('\n');
751        }
752        out.push_str("\\endcode");
753    }
754    out
755}
756
757/// Return the first paragraph of a doc comment as a single joined line.
758///
759/// Collects lines until the first blank line, trims each, then joins with a
760/// space. This handles wrapped sentences like:
761///
762/// ```text
763/// Convert HTML to Markdown, returning
764/// a `ConversionResult`.
765/// ```
766///
767/// which would otherwise be truncated at the comma when callers use
768/// `.lines().next()`.
769pub fn doc_first_paragraph_joined(doc: &str) -> String {
770    doc.lines()
771        .take_while(|l| !l.trim().is_empty())
772        .map(str::trim)
773        .collect::<Vec<_>>()
774        .join(" ")
775}
776
777#[cfg(test)]
778mod tests {
779    use super::*;
780
781    #[test]
782    fn test_emit_phpdoc() {
783        let mut out = String::new();
784        emit_phpdoc(&mut out, "Simple documentation", "    ");
785        assert!(out.contains("/**"));
786        assert!(out.contains("Simple documentation"));
787        assert!(out.contains("*/"));
788    }
789
790    #[test]
791    fn test_phpdoc_escaping() {
792        let mut out = String::new();
793        emit_phpdoc(&mut out, "Handle */ sequences", "");
794        assert!(out.contains("Handle * / sequences"));
795    }
796
797    #[test]
798    fn test_emit_csharp_doc() {
799        let mut out = String::new();
800        emit_csharp_doc(&mut out, "C# documentation", "    ");
801        assert!(out.contains("<summary>"));
802        assert!(out.contains("C# documentation"));
803        assert!(out.contains("</summary>"));
804    }
805
806    #[test]
807    fn test_csharp_xml_escaping() {
808        let mut out = String::new();
809        emit_csharp_doc(&mut out, "foo < bar & baz > qux", "");
810        assert!(out.contains("foo &lt; bar &amp; baz &gt; qux"));
811    }
812
813    #[test]
814    fn test_emit_elixir_doc() {
815        let mut out = String::new();
816        emit_elixir_doc(&mut out, "Elixir documentation");
817        assert!(out.contains("@doc \"\"\""));
818        assert!(out.contains("Elixir documentation"));
819        assert!(out.contains("\"\"\""));
820    }
821
822    #[test]
823    fn test_elixir_heredoc_escaping() {
824        let mut out = String::new();
825        emit_elixir_doc(&mut out, "Handle \"\"\" sequences");
826        assert!(out.contains("Handle \"\" \" sequences"));
827    }
828
829    #[test]
830    fn test_emit_roxygen() {
831        let mut out = String::new();
832        emit_roxygen(&mut out, "R documentation");
833        assert!(out.contains("#' R documentation"));
834    }
835
836    #[test]
837    fn test_emit_swift_doc() {
838        let mut out = String::new();
839        emit_swift_doc(&mut out, "Swift documentation", "    ");
840        assert!(out.contains("/// Swift documentation"));
841    }
842
843    #[test]
844    fn test_emit_javadoc() {
845        let mut out = String::new();
846        emit_javadoc(&mut out, "Java documentation", "    ");
847        assert!(out.contains("/**"));
848        assert!(out.contains("Java documentation"));
849        assert!(out.contains("*/"));
850    }
851
852    #[test]
853    fn test_emit_kdoc() {
854        let mut out = String::new();
855        emit_kdoc(&mut out, "Kotlin documentation", "    ");
856        assert!(out.contains("/**"));
857        assert!(out.contains("Kotlin documentation"));
858        assert!(out.contains("*/"));
859    }
860
861    #[test]
862    fn test_emit_dartdoc() {
863        let mut out = String::new();
864        emit_dartdoc(&mut out, "Dart documentation", "    ");
865        assert!(out.contains("/// Dart documentation"));
866    }
867
868    #[test]
869    fn test_emit_gleam_doc() {
870        let mut out = String::new();
871        emit_gleam_doc(&mut out, "Gleam documentation", "    ");
872        assert!(out.contains("/// Gleam documentation"));
873    }
874
875    #[test]
876    fn test_emit_zig_doc() {
877        let mut out = String::new();
878        emit_zig_doc(&mut out, "Zig documentation", "    ");
879        assert!(out.contains("/// Zig documentation"));
880    }
881
882    #[test]
883    fn test_empty_doc_skipped() {
884        let mut out = String::new();
885        emit_phpdoc(&mut out, "", "");
886        emit_csharp_doc(&mut out, "", "");
887        emit_elixir_doc(&mut out, "");
888        emit_roxygen(&mut out, "");
889        emit_kdoc(&mut out, "", "");
890        emit_dartdoc(&mut out, "", "");
891        emit_gleam_doc(&mut out, "", "");
892        emit_zig_doc(&mut out, "", "");
893        assert!(out.is_empty());
894    }
895
896    #[test]
897    fn test_doc_first_paragraph_joined_single_line() {
898        assert_eq!(doc_first_paragraph_joined("Simple doc."), "Simple doc.");
899    }
900
901    #[test]
902    fn test_doc_first_paragraph_joined_wrapped_sentence() {
903        // Simulates a docstring like convert's: "Convert HTML to Markdown,\nreturning a result."
904        let doc = "Convert HTML to Markdown,\nreturning a result.";
905        assert_eq!(
906            doc_first_paragraph_joined(doc),
907            "Convert HTML to Markdown, returning a result."
908        );
909    }
910
911    #[test]
912    fn test_doc_first_paragraph_joined_stops_at_blank_line() {
913        let doc = "First paragraph.\nStill first.\n\nSecond paragraph.";
914        assert_eq!(doc_first_paragraph_joined(doc), "First paragraph. Still first.");
915    }
916
917    #[test]
918    fn test_doc_first_paragraph_joined_empty() {
919        assert_eq!(doc_first_paragraph_joined(""), "");
920    }
921
922    #[test]
923    fn test_parse_rustdoc_sections_basic() {
924        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.";
925        let sections = parse_rustdoc_sections(doc);
926        assert_eq!(sections.summary, "Extracts text from a file.");
927        assert_eq!(sections.arguments.as_deref(), Some("* `path` - The file path."));
928        assert_eq!(sections.returns.as_deref(), Some("The extracted text."));
929        assert_eq!(sections.errors.as_deref(), Some("Returns `KreuzbergError` on failure."));
930        assert!(sections.panics.is_none());
931    }
932
933    #[test]
934    fn test_parse_rustdoc_sections_example_with_fence() {
935        let doc = "Run the thing.\n\n# Example\n\n```rust\nlet x = run();\n```";
936        let sections = parse_rustdoc_sections(doc);
937        assert_eq!(sections.summary, "Run the thing.");
938        assert!(sections.example.as_ref().unwrap().contains("```rust"));
939        assert!(sections.example.as_ref().unwrap().contains("let x = run();"));
940    }
941
942    #[test]
943    fn test_parse_rustdoc_sections_pound_inside_fence_is_not_a_heading() {
944        // Even though we get rustdoc-hidden lines pre-stripped, a literal
945        // `# foo` inside a non-rust fence (e.g. shell example) must not
946        // start a new section.
947        let doc = "Summary.\n\n# Example\n\n```bash\n# install deps\nrun --foo\n```";
948        let sections = parse_rustdoc_sections(doc);
949        assert_eq!(sections.summary, "Summary.");
950        assert!(sections.example.as_ref().unwrap().contains("# install deps"));
951    }
952
953    #[test]
954    fn test_parse_arguments_bullets_dash_separator() {
955        let body = "* `path` - The file path.\n* `config` - Optional configuration.";
956        let pairs = parse_arguments_bullets(body);
957        assert_eq!(pairs.len(), 2);
958        assert_eq!(pairs[0], ("path".to_string(), "The file path.".to_string()));
959        assert_eq!(pairs[1], ("config".to_string(), "Optional configuration.".to_string()));
960    }
961
962    #[test]
963    fn test_parse_arguments_bullets_continuation_line() {
964        let body = "* `path` - The file path,\n  resolved relative to cwd.\n* `mode` - Open mode.";
965        let pairs = parse_arguments_bullets(body);
966        assert_eq!(pairs.len(), 2);
967        assert_eq!(pairs[0].1, "The file path, resolved relative to cwd.");
968    }
969
970    #[test]
971    fn test_replace_fence_lang_rust_to_typescript() {
972        let body = "```rust\nlet x = run();\n```";
973        let out = replace_fence_lang(body, "typescript");
974        assert!(out.starts_with("```typescript"));
975        assert!(out.contains("let x = run();"));
976    }
977
978    #[test]
979    fn test_replace_fence_lang_preserves_attrs() {
980        let body = "```rust,no_run\nlet x = run();\n```";
981        let out = replace_fence_lang(body, "typescript");
982        assert!(out.starts_with("```typescript,no_run"));
983    }
984
985    #[test]
986    fn test_replace_fence_lang_no_fence_unchanged() {
987        let body = "Plain prose with `inline code`.";
988        let out = replace_fence_lang(body, "typescript");
989        assert_eq!(out, "Plain prose with `inline code`.");
990    }
991
992    fn fixture_sections() -> RustdocSections {
993        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```";
994        parse_rustdoc_sections(doc)
995    }
996
997    #[test]
998    fn test_render_jsdoc_sections() {
999        let sections = fixture_sections();
1000        let out = render_jsdoc_sections(&sections);
1001        assert!(out.starts_with("Extracts text from a file."));
1002        assert!(out.contains("@param path - The file path."));
1003        assert!(out.contains("@param config - Optional configuration."));
1004        assert!(out.contains("@returns The extracted text and metadata."));
1005        assert!(out.contains("@throws Returns an error when the file is unreadable."));
1006        assert!(out.contains("@example"));
1007        assert!(out.contains("```typescript"));
1008        assert!(!out.contains("```rust"));
1009    }
1010
1011    #[test]
1012    fn test_render_javadoc_sections() {
1013        let sections = fixture_sections();
1014        let out = render_javadoc_sections(&sections, "KreuzbergRsException");
1015        assert!(out.contains("@param path The file path."));
1016        assert!(out.contains("@return The extracted text and metadata."));
1017        assert!(out.contains("@throws KreuzbergRsException Returns an error when the file is unreadable."));
1018        // Java rendering omits the example block (handled separately by emit_javadoc which
1019        // wraps code in `<pre>{@code}</pre>`); we just confirm summary survives.
1020        assert!(out.starts_with("Extracts text from a file."));
1021    }
1022
1023    #[test]
1024    fn test_render_csharp_xml_sections() {
1025        let sections = fixture_sections();
1026        let out = render_csharp_xml_sections(&sections, "KreuzbergException");
1027        assert!(out.contains("<summary>\nExtracts text from a file.\n</summary>"));
1028        assert!(out.contains("<param name=\"path\">The file path.</param>"));
1029        assert!(out.contains("<returns>The extracted text and metadata.</returns>"));
1030        assert!(out.contains("<exception cref=\"KreuzbergException\">"));
1031        assert!(out.contains("<example><code language=\"csharp\">"));
1032        assert!(out.contains("let result = extract"));
1033    }
1034
1035    #[test]
1036    fn test_render_phpdoc_sections() {
1037        let sections = fixture_sections();
1038        let out = render_phpdoc_sections(&sections, "KreuzbergException");
1039        assert!(out.contains("@param mixed $path The file path."));
1040        assert!(out.contains("@return The extracted text and metadata."));
1041        assert!(out.contains("@throws KreuzbergException"));
1042        assert!(out.contains("```php"));
1043    }
1044
1045    #[test]
1046    fn test_render_doxygen_sections() {
1047        let sections = fixture_sections();
1048        let out = render_doxygen_sections(&sections);
1049        assert!(out.contains("\\param path The file path."));
1050        assert!(out.contains("\\return The extracted text and metadata."));
1051        assert!(out.contains("\\code"));
1052        assert!(out.contains("\\endcode"));
1053    }
1054}