1pub 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(§ions, 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
38fn escape_phpdoc_line(s: &str) -> String {
40 s.replace("*/", "* /")
41}
42
43pub 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 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(§ions, exception_class);
75 for line in rendered.lines() {
76 out.push_str(indent);
77 out.push_str("/// ");
78 out.push_str(line);
83 out.push('\n');
84 }
85}
86
87fn escape_csharp_doc_line(s: &str) -> String {
89 s.replace('&', "&").replace('<', "<").replace('>', ">")
90}
91
92pub 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
106pub 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
123fn escape_elixir_doc_line(s: &str) -> String {
125 s.replace("\"\"\"", "\"\" \"")
126}
127
128pub 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
141pub 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
155pub 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
181pub 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
205pub 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
219pub 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
233pub 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
247fn escape_javadoc_line(s: &str) -> String {
257 let mut result = String::with_capacity(s.len());
258 let mut chars = s.chars().peekable();
259 while let Some(ch) = chars.next() {
260 if ch == '`' {
261 let mut code = String::new();
262 for c in chars.by_ref() {
263 if c == '`' {
264 break;
265 }
266 code.push(c);
267 }
268 result.push_str("{@code ");
269 result.push_str(&escape_javadoc_html_entities(&code));
270 result.push('}');
271 } else if ch == '<' {
272 result.push_str("<");
273 } else if ch == '>' {
274 result.push_str(">");
275 } else if ch == '&' {
276 result.push_str("&");
277 } else {
278 result.push(ch);
279 }
280 }
281 result
282}
283
284fn escape_javadoc_html_entities(s: &str) -> String {
287 let mut out = String::with_capacity(s.len());
288 for ch in s.chars() {
289 match ch {
290 '<' => out.push_str("<"),
291 '>' => out.push_str(">"),
292 '&' => out.push_str("&"),
293 other => out.push(other),
294 }
295 }
296 out
297}
298
299#[derive(Debug, Default, Clone, PartialEq, Eq)]
310pub struct RustdocSections {
311 pub summary: String,
313 pub arguments: Option<String>,
315 pub returns: Option<String>,
317 pub errors: Option<String>,
319 pub panics: Option<String>,
321 pub safety: Option<String>,
323 pub example: Option<String>,
325}
326
327pub fn parse_rustdoc_sections(doc: &str) -> RustdocSections {
339 if doc.trim().is_empty() {
340 return RustdocSections::default();
341 }
342 let mut summary = String::new();
343 let mut arguments: Option<String> = None;
344 let mut returns: Option<String> = None;
345 let mut errors: Option<String> = None;
346 let mut panics: Option<String> = None;
347 let mut safety: Option<String> = None;
348 let mut example: Option<String> = None;
349 let mut current: Option<&'static str> = None;
350 let mut buf = String::new();
351 let mut in_fence = false;
352 let flush = |target: Option<&'static str>,
353 buf: &mut String,
354 summary: &mut String,
355 arguments: &mut Option<String>,
356 returns: &mut Option<String>,
357 errors: &mut Option<String>,
358 panics: &mut Option<String>,
359 safety: &mut Option<String>,
360 example: &mut Option<String>| {
361 let body = std::mem::take(buf).trim().to_string();
362 if body.is_empty() {
363 return;
364 }
365 match target {
366 None => {
367 if !summary.is_empty() {
368 summary.push('\n');
369 }
370 summary.push_str(&body);
371 }
372 Some("arguments") => *arguments = Some(body),
373 Some("returns") => *returns = Some(body),
374 Some("errors") => *errors = Some(body),
375 Some("panics") => *panics = Some(body),
376 Some("safety") => *safety = Some(body),
377 Some("example") => *example = Some(body),
378 _ => {}
379 }
380 };
381 for line in doc.lines() {
382 let trimmed = line.trim_start();
383 if trimmed.starts_with("```") {
384 in_fence = !in_fence;
385 buf.push_str(line);
386 buf.push('\n');
387 continue;
388 }
389 if !in_fence {
390 if let Some(rest) = trimmed.strip_prefix("# ") {
391 let head = rest.trim().to_ascii_lowercase();
392 let target = match head.as_str() {
393 "arguments" | "args" => Some("arguments"),
394 "returns" => Some("returns"),
395 "errors" => Some("errors"),
396 "panics" => Some("panics"),
397 "safety" => Some("safety"),
398 "example" | "examples" => Some("example"),
399 _ => None,
400 };
401 if target.is_some() {
402 flush(
403 current,
404 &mut buf,
405 &mut summary,
406 &mut arguments,
407 &mut returns,
408 &mut errors,
409 &mut panics,
410 &mut safety,
411 &mut example,
412 );
413 current = target;
414 continue;
415 }
416 }
417 }
418 buf.push_str(line);
419 buf.push('\n');
420 }
421 flush(
422 current,
423 &mut buf,
424 &mut summary,
425 &mut arguments,
426 &mut returns,
427 &mut errors,
428 &mut panics,
429 &mut safety,
430 &mut example,
431 );
432 RustdocSections {
433 summary,
434 arguments,
435 returns,
436 errors,
437 panics,
438 safety,
439 example,
440 }
441}
442
443pub fn parse_arguments_bullets(body: &str) -> Vec<(String, String)> {
453 let mut out: Vec<(String, String)> = Vec::new();
454 for raw in body.lines() {
455 let line = raw.trim_end();
456 let trimmed = line.trim_start();
457 let is_bullet = trimmed.starts_with("* ") || trimmed.starts_with("- ");
458 if is_bullet {
459 let after = &trimmed[2..];
460 let (name, desc) = if let Some(idx) = after.find(" - ") {
462 (after[..idx].trim(), after[idx + 3..].trim())
463 } else if let Some(idx) = after.find(": ") {
464 (after[..idx].trim(), after[idx + 2..].trim())
465 } else if let Some(idx) = after.find(' ') {
466 (after[..idx].trim(), after[idx + 1..].trim())
467 } else {
468 (after.trim(), "")
469 };
470 let name = name.trim_matches('`').trim_matches('*').to_string();
471 out.push((name, desc.to_string()));
472 } else if !trimmed.is_empty() {
473 if let Some(last) = out.last_mut() {
474 if !last.1.is_empty() {
475 last.1.push(' ');
476 }
477 last.1.push_str(trimmed);
478 }
479 }
480 }
481 out
482}
483
484pub fn replace_fence_lang(body: &str, lang_replacement: &str) -> String {
492 let mut out = String::with_capacity(body.len());
493 for line in body.lines() {
494 let trimmed = line.trim_start();
495 if let Some(rest) = trimmed.strip_prefix("```") {
496 let indent = &line[..line.len() - trimmed.len()];
499 let after_lang = rest.find(',').map(|i| &rest[i..]).unwrap_or("");
500 out.push_str(indent);
501 out.push_str("```");
502 out.push_str(lang_replacement);
503 out.push_str(after_lang);
504 out.push('\n');
505 } else {
506 out.push_str(line);
507 out.push('\n');
508 }
509 }
510 out.trim_end_matches('\n').to_string()
511}
512
513pub fn render_jsdoc_sections(sections: &RustdocSections) -> String {
526 let mut out = String::new();
527 if !sections.summary.is_empty() {
528 out.push_str(§ions.summary);
529 }
530 if let Some(args) = sections.arguments.as_deref() {
531 for (name, desc) in parse_arguments_bullets(args) {
532 if !out.is_empty() {
533 out.push('\n');
534 }
535 if desc.is_empty() {
536 out.push_str(&crate::template_env::render(
537 "doc_jsdoc_param.jinja",
538 minijinja::context! { name => &name },
539 ));
540 } else {
541 out.push_str(&crate::template_env::render(
542 "doc_jsdoc_param_desc.jinja",
543 minijinja::context! { name => &name, desc => &desc },
544 ));
545 }
546 }
547 }
548 if let Some(ret) = sections.returns.as_deref() {
549 if !out.is_empty() {
550 out.push('\n');
551 }
552 out.push_str(&crate::template_env::render(
553 "doc_jsdoc_returns.jinja",
554 minijinja::context! { content => ret.trim() },
555 ));
556 }
557 if let Some(err) = sections.errors.as_deref() {
558 if !out.is_empty() {
559 out.push('\n');
560 }
561 out.push_str(&crate::template_env::render(
562 "doc_jsdoc_throws.jinja",
563 minijinja::context! { content => err.trim() },
564 ));
565 }
566 if let Some(example) = sections.example.as_deref() {
567 if !out.is_empty() {
568 out.push('\n');
569 }
570 out.push_str("@example\n");
571 out.push_str(&replace_fence_lang(example.trim(), "typescript"));
572 }
573 out
574}
575
576pub fn render_javadoc_sections(sections: &RustdocSections, throws_class: &str) -> String {
586 let mut out = String::new();
587 if !sections.summary.is_empty() {
588 out.push_str(§ions.summary);
589 }
590 if let Some(args) = sections.arguments.as_deref() {
591 for (name, desc) in parse_arguments_bullets(args) {
592 if !out.is_empty() {
593 out.push('\n');
594 }
595 if desc.is_empty() {
596 out.push_str(&crate::template_env::render(
597 "doc_javadoc_param.jinja",
598 minijinja::context! { name => &name },
599 ));
600 } else {
601 out.push_str(&crate::template_env::render(
602 "doc_javadoc_param_desc.jinja",
603 minijinja::context! { name => &name, desc => &desc },
604 ));
605 }
606 }
607 }
608 if let Some(ret) = sections.returns.as_deref() {
609 if !out.is_empty() {
610 out.push('\n');
611 }
612 out.push_str(&crate::template_env::render(
613 "doc_javadoc_return.jinja",
614 minijinja::context! { content => ret.trim() },
615 ));
616 }
617 if let Some(err) = sections.errors.as_deref() {
618 if !out.is_empty() {
619 out.push('\n');
620 }
621 out.push_str(&crate::template_env::render(
622 "doc_javadoc_throws.jinja",
623 minijinja::context! { throws_class => throws_class, content => err.trim() },
624 ));
625 }
626 out
627}
628
629pub fn render_csharp_xml_sections(sections: &RustdocSections, exception_class: &str) -> String {
638 let mut out = String::new();
639 out.push_str("<summary>\n");
640 let summary = if sections.summary.is_empty() {
641 ""
642 } else {
643 sections.summary.as_str()
644 };
645 for line in summary.lines() {
646 out.push_str(line);
647 out.push('\n');
648 }
649 out.push_str("</summary>");
650 if let Some(args) = sections.arguments.as_deref() {
651 for (name, desc) in parse_arguments_bullets(args) {
652 out.push('\n');
653 if desc.is_empty() {
654 out.push_str(&crate::template_env::render(
655 "doc_csharp_param.jinja",
656 minijinja::context! { name => &name },
657 ));
658 } else {
659 out.push_str(&crate::template_env::render(
660 "doc_csharp_param_desc.jinja",
661 minijinja::context! { name => &name, desc => &desc },
662 ));
663 }
664 }
665 }
666 if let Some(ret) = sections.returns.as_deref() {
667 out.push('\n');
668 out.push_str(&crate::template_env::render(
669 "doc_csharp_returns.jinja",
670 minijinja::context! { content => ret.trim() },
671 ));
672 }
673 if let Some(err) = sections.errors.as_deref() {
674 out.push('\n');
675 out.push_str(&crate::template_env::render(
676 "doc_csharp_exception.jinja",
677 minijinja::context! {
678 exception_class => exception_class,
679 content => err.trim(),
680 },
681 ));
682 }
683 if let Some(example) = sections.example.as_deref() {
684 out.push('\n');
685 out.push_str("<example><code language=\"csharp\">\n");
686 for line in example.lines() {
688 let t = line.trim_start();
689 if t.starts_with("```") {
690 continue;
691 }
692 out.push_str(line);
693 out.push('\n');
694 }
695 out.push_str("</code></example>");
696 }
697 out
698}
699
700pub fn render_phpdoc_sections(sections: &RustdocSections, throws_class: &str) -> String {
707 let mut out = String::new();
708 if !sections.summary.is_empty() {
709 out.push_str(§ions.summary);
710 }
711 if let Some(args) = sections.arguments.as_deref() {
712 for (name, desc) in parse_arguments_bullets(args) {
713 if !out.is_empty() {
714 out.push('\n');
715 }
716 if desc.is_empty() {
717 out.push_str(&crate::template_env::render(
718 "doc_phpdoc_param.jinja",
719 minijinja::context! { name => &name },
720 ));
721 } else {
722 out.push_str(&crate::template_env::render(
723 "doc_phpdoc_param_desc.jinja",
724 minijinja::context! { name => &name, desc => &desc },
725 ));
726 }
727 }
728 }
729 if let Some(ret) = sections.returns.as_deref() {
730 if !out.is_empty() {
731 out.push('\n');
732 }
733 out.push_str(&crate::template_env::render(
734 "doc_phpdoc_return.jinja",
735 minijinja::context! { content => ret.trim() },
736 ));
737 }
738 if let Some(err) = sections.errors.as_deref() {
739 if !out.is_empty() {
740 out.push('\n');
741 }
742 out.push_str(&crate::template_env::render(
743 "doc_phpdoc_throws.jinja",
744 minijinja::context! { throws_class => throws_class, content => err.trim() },
745 ));
746 }
747 if let Some(example) = sections.example.as_deref() {
748 if !out.is_empty() {
749 out.push('\n');
750 }
751 out.push_str(&replace_fence_lang(example.trim(), "php"));
752 }
753 out
754}
755
756pub fn render_doxygen_sections(sections: &RustdocSections) -> String {
763 let mut out = String::new();
764 if !sections.summary.is_empty() {
765 out.push_str(§ions.summary);
766 }
767 if let Some(args) = sections.arguments.as_deref() {
768 for (name, desc) in parse_arguments_bullets(args) {
769 if !out.is_empty() {
770 out.push('\n');
771 }
772 if desc.is_empty() {
773 out.push_str(&crate::template_env::render(
774 "doc_doxygen_param.jinja",
775 minijinja::context! { name => &name },
776 ));
777 } else {
778 out.push_str(&crate::template_env::render(
779 "doc_doxygen_param_desc.jinja",
780 minijinja::context! { name => &name, desc => &desc },
781 ));
782 }
783 }
784 }
785 if let Some(ret) = sections.returns.as_deref() {
786 if !out.is_empty() {
787 out.push('\n');
788 }
789 out.push_str(&crate::template_env::render(
790 "doc_doxygen_return.jinja",
791 minijinja::context! { content => ret.trim() },
792 ));
793 }
794 if let Some(err) = sections.errors.as_deref() {
795 if !out.is_empty() {
796 out.push('\n');
797 }
798 out.push_str(&crate::template_env::render(
799 "doc_doxygen_errors.jinja",
800 minijinja::context! { content => err.trim() },
801 ));
802 }
803 if let Some(example) = sections.example.as_deref() {
804 if !out.is_empty() {
805 out.push('\n');
806 }
807 out.push_str("\\code\n");
808 for line in example.lines() {
809 let t = line.trim_start();
810 if t.starts_with("```") {
811 continue;
812 }
813 out.push_str(line);
814 out.push('\n');
815 }
816 out.push_str("\\endcode");
817 }
818 out
819}
820
821pub fn doc_first_paragraph_joined(doc: &str) -> String {
834 doc.lines()
835 .take_while(|l| !l.trim().is_empty())
836 .map(str::trim)
837 .collect::<Vec<_>>()
838 .join(" ")
839}
840
841#[cfg(test)]
842mod tests {
843 use super::*;
844
845 #[test]
846 fn test_emit_phpdoc() {
847 let mut out = String::new();
848 emit_phpdoc(&mut out, "Simple documentation", " ", "TestException");
849 assert!(out.contains("/**"));
850 assert!(out.contains("Simple documentation"));
851 assert!(out.contains("*/"));
852 }
853
854 #[test]
855 fn test_phpdoc_escaping() {
856 let mut out = String::new();
857 emit_phpdoc(&mut out, "Handle */ sequences", "", "TestException");
858 assert!(out.contains("Handle * / sequences"));
859 }
860
861 #[test]
862 fn test_emit_csharp_doc() {
863 let mut out = String::new();
864 emit_csharp_doc(&mut out, "C# documentation", " ", "TestException");
865 assert!(out.contains("<summary>"));
866 assert!(out.contains("C# documentation"));
867 assert!(out.contains("</summary>"));
868 }
869
870 #[test]
871 fn test_csharp_xml_escaping() {
872 let mut out = String::new();
873 emit_csharp_doc(&mut out, "foo < bar & baz > qux", "", "TestException");
874 assert!(out.contains("foo < bar & baz > qux"));
875 }
876
877 #[test]
878 fn test_emit_elixir_doc() {
879 let mut out = String::new();
880 emit_elixir_doc(&mut out, "Elixir documentation");
881 assert!(out.contains("@doc \"\"\""));
882 assert!(out.contains("Elixir documentation"));
883 assert!(out.contains("\"\"\""));
884 }
885
886 #[test]
887 fn test_elixir_heredoc_escaping() {
888 let mut out = String::new();
889 emit_elixir_doc(&mut out, "Handle \"\"\" sequences");
890 assert!(out.contains("Handle \"\" \" sequences"));
891 }
892
893 #[test]
894 fn test_emit_roxygen() {
895 let mut out = String::new();
896 emit_roxygen(&mut out, "R documentation");
897 assert!(out.contains("#' R documentation"));
898 }
899
900 #[test]
901 fn test_emit_swift_doc() {
902 let mut out = String::new();
903 emit_swift_doc(&mut out, "Swift documentation", " ");
904 assert!(out.contains("/// Swift documentation"));
905 }
906
907 #[test]
908 fn test_emit_javadoc() {
909 let mut out = String::new();
910 emit_javadoc(&mut out, "Java documentation", " ");
911 assert!(out.contains("/**"));
912 assert!(out.contains("Java documentation"));
913 assert!(out.contains("*/"));
914 }
915
916 #[test]
917 fn test_emit_kdoc() {
918 let mut out = String::new();
919 emit_kdoc(&mut out, "Kotlin documentation", " ");
920 assert!(out.contains("/**"));
921 assert!(out.contains("Kotlin documentation"));
922 assert!(out.contains("*/"));
923 }
924
925 #[test]
926 fn test_emit_dartdoc() {
927 let mut out = String::new();
928 emit_dartdoc(&mut out, "Dart documentation", " ");
929 assert!(out.contains("/// Dart documentation"));
930 }
931
932 #[test]
933 fn test_emit_gleam_doc() {
934 let mut out = String::new();
935 emit_gleam_doc(&mut out, "Gleam documentation", " ");
936 assert!(out.contains("/// Gleam documentation"));
937 }
938
939 #[test]
940 fn test_emit_zig_doc() {
941 let mut out = String::new();
942 emit_zig_doc(&mut out, "Zig documentation", " ");
943 assert!(out.contains("/// Zig documentation"));
944 }
945
946 #[test]
947 fn test_empty_doc_skipped() {
948 let mut out = String::new();
949 emit_phpdoc(&mut out, "", "", "TestException");
950 emit_csharp_doc(&mut out, "", "", "TestException");
951 emit_elixir_doc(&mut out, "");
952 emit_roxygen(&mut out, "");
953 emit_kdoc(&mut out, "", "");
954 emit_dartdoc(&mut out, "", "");
955 emit_gleam_doc(&mut out, "", "");
956 emit_zig_doc(&mut out, "", "");
957 assert!(out.is_empty());
958 }
959
960 #[test]
961 fn test_doc_first_paragraph_joined_single_line() {
962 assert_eq!(doc_first_paragraph_joined("Simple doc."), "Simple doc.");
963 }
964
965 #[test]
966 fn test_doc_first_paragraph_joined_wrapped_sentence() {
967 let doc = "Convert HTML to Markdown,\nreturning a result.";
969 assert_eq!(
970 doc_first_paragraph_joined(doc),
971 "Convert HTML to Markdown, returning a result."
972 );
973 }
974
975 #[test]
976 fn test_doc_first_paragraph_joined_stops_at_blank_line() {
977 let doc = "First paragraph.\nStill first.\n\nSecond paragraph.";
978 assert_eq!(doc_first_paragraph_joined(doc), "First paragraph. Still first.");
979 }
980
981 #[test]
982 fn test_doc_first_paragraph_joined_empty() {
983 assert_eq!(doc_first_paragraph_joined(""), "");
984 }
985
986 #[test]
987 fn test_parse_rustdoc_sections_basic() {
988 let doc = "Extracts text from a file.\n\n# Arguments\n\n* `path` - The file path.\n\n# Returns\n\nThe extracted text.\n\n# Errors\n\nReturns `KreuzbergError` on failure.";
989 let sections = parse_rustdoc_sections(doc);
990 assert_eq!(sections.summary, "Extracts text from a file.");
991 assert_eq!(sections.arguments.as_deref(), Some("* `path` - The file path."));
992 assert_eq!(sections.returns.as_deref(), Some("The extracted text."));
993 assert_eq!(sections.errors.as_deref(), Some("Returns `KreuzbergError` on failure."));
994 assert!(sections.panics.is_none());
995 }
996
997 #[test]
998 fn test_parse_rustdoc_sections_example_with_fence() {
999 let doc = "Run the thing.\n\n# Example\n\n```rust\nlet x = run();\n```";
1000 let sections = parse_rustdoc_sections(doc);
1001 assert_eq!(sections.summary, "Run the thing.");
1002 assert!(sections.example.as_ref().unwrap().contains("```rust"));
1003 assert!(sections.example.as_ref().unwrap().contains("let x = run();"));
1004 }
1005
1006 #[test]
1007 fn test_parse_rustdoc_sections_pound_inside_fence_is_not_a_heading() {
1008 let doc = "Summary.\n\n# Example\n\n```bash\n# install deps\nrun --foo\n```";
1012 let sections = parse_rustdoc_sections(doc);
1013 assert_eq!(sections.summary, "Summary.");
1014 assert!(sections.example.as_ref().unwrap().contains("# install deps"));
1015 }
1016
1017 #[test]
1018 fn test_parse_arguments_bullets_dash_separator() {
1019 let body = "* `path` - The file path.\n* `config` - Optional configuration.";
1020 let pairs = parse_arguments_bullets(body);
1021 assert_eq!(pairs.len(), 2);
1022 assert_eq!(pairs[0], ("path".to_string(), "The file path.".to_string()));
1023 assert_eq!(pairs[1], ("config".to_string(), "Optional configuration.".to_string()));
1024 }
1025
1026 #[test]
1027 fn test_parse_arguments_bullets_continuation_line() {
1028 let body = "* `path` - The file path,\n resolved relative to cwd.\n* `mode` - Open mode.";
1029 let pairs = parse_arguments_bullets(body);
1030 assert_eq!(pairs.len(), 2);
1031 assert_eq!(pairs[0].1, "The file path, resolved relative to cwd.");
1032 }
1033
1034 #[test]
1035 fn test_replace_fence_lang_rust_to_typescript() {
1036 let body = "```rust\nlet x = run();\n```";
1037 let out = replace_fence_lang(body, "typescript");
1038 assert!(out.starts_with("```typescript"));
1039 assert!(out.contains("let x = run();"));
1040 }
1041
1042 #[test]
1043 fn test_replace_fence_lang_preserves_attrs() {
1044 let body = "```rust,no_run\nlet x = run();\n```";
1045 let out = replace_fence_lang(body, "typescript");
1046 assert!(out.starts_with("```typescript,no_run"));
1047 }
1048
1049 #[test]
1050 fn test_replace_fence_lang_no_fence_unchanged() {
1051 let body = "Plain prose with `inline code`.";
1052 let out = replace_fence_lang(body, "typescript");
1053 assert_eq!(out, "Plain prose with `inline code`.");
1054 }
1055
1056 fn fixture_sections() -> RustdocSections {
1057 let doc = "Extracts text from a file.\n\n# Arguments\n\n* `path` - The file path.\n* `config` - Optional configuration.\n\n# Returns\n\nThe extracted text and metadata.\n\n# Errors\n\nReturns an error when the file is unreadable.\n\n# Example\n\n```rust\nlet result = extract(\"file.pdf\")?;\n```";
1058 parse_rustdoc_sections(doc)
1059 }
1060
1061 #[test]
1062 fn test_render_jsdoc_sections() {
1063 let sections = fixture_sections();
1064 let out = render_jsdoc_sections(§ions);
1065 assert!(out.starts_with("Extracts text from a file."));
1066 assert!(out.contains("@param path - The file path."));
1067 assert!(out.contains("@param config - Optional configuration."));
1068 assert!(out.contains("@returns The extracted text and metadata."));
1069 assert!(out.contains("@throws Returns an error when the file is unreadable."));
1070 assert!(out.contains("@example"));
1071 assert!(out.contains("```typescript"));
1072 assert!(!out.contains("```rust"));
1073 }
1074
1075 #[test]
1076 fn test_render_javadoc_sections() {
1077 let sections = fixture_sections();
1078 let out = render_javadoc_sections(§ions, "KreuzbergRsException");
1079 assert!(out.contains("@param path The file path."));
1080 assert!(out.contains("@return The extracted text and metadata."));
1081 assert!(out.contains("@throws KreuzbergRsException Returns an error when the file is unreadable."));
1082 assert!(out.starts_with("Extracts text from a file."));
1085 }
1086
1087 #[test]
1088 fn test_render_csharp_xml_sections() {
1089 let sections = fixture_sections();
1090 let out = render_csharp_xml_sections(§ions, "KreuzbergException");
1091 assert!(out.contains("<summary>\nExtracts text from a file.\n</summary>"));
1092 assert!(out.contains("<param name=\"path\">The file path.</param>"));
1093 assert!(out.contains("<returns>The extracted text and metadata.</returns>"));
1094 assert!(out.contains("<exception cref=\"KreuzbergException\">"));
1095 assert!(out.contains("<example><code language=\"csharp\">"));
1096 assert!(out.contains("let result = extract"));
1097 }
1098
1099 #[test]
1100 fn test_render_phpdoc_sections() {
1101 let sections = fixture_sections();
1102 let out = render_phpdoc_sections(§ions, "KreuzbergException");
1103 assert!(out.contains("@param mixed $path The file path."));
1104 assert!(out.contains("@return The extracted text and metadata."));
1105 assert!(out.contains("@throws KreuzbergException"));
1106 assert!(out.contains("```php"));
1107 }
1108
1109 #[test]
1110 fn test_render_doxygen_sections() {
1111 let sections = fixture_sections();
1112 let out = render_doxygen_sections(§ions);
1113 assert!(out.contains("\\param path The file path."));
1114 assert!(out.contains("\\return The extracted text and metadata."));
1115 assert!(out.contains("\\code"));
1116 assert!(out.contains("\\endcode"));
1117 }
1118}