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_c_doxygen(out: &mut String, doc: &str, indent: &str) {
254 if doc.trim().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.safety.is_some()
262 || sections.example.is_some();
263 let mut body = if any_section {
264 render_doxygen_sections_with_notes(§ions)
265 } else {
266 sections.summary.clone()
267 };
268 body = strip_markdown_links(&body);
269 let wrapped = word_wrap(&body, DOXYGEN_WRAP_WIDTH);
270 for line in wrapped.lines() {
271 out.push_str(indent);
272 out.push_str("/// ");
273 out.push_str(line);
274 out.push('\n');
275 }
276}
277
278const DOXYGEN_WRAP_WIDTH: usize = 100;
279
280fn render_doxygen_sections_with_notes(sections: &RustdocSections) -> String {
285 let mut out = String::new();
286 if !sections.summary.is_empty() {
287 out.push_str(§ions.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("\\note ");
317 out.push_str(err.trim());
318 }
319 if let Some(safety) = sections.safety.as_deref() {
320 if !out.is_empty() {
321 out.push('\n');
322 }
323 out.push_str("\\note SAFETY: ");
324 out.push_str(safety.trim());
325 }
326 if let Some(example) = sections.example.as_deref() {
327 if !out.is_empty() {
328 out.push('\n');
329 }
330 out.push_str("\\code\n");
331 for line in example.lines() {
332 let t = line.trim_start();
333 if t.starts_with("```") {
334 continue;
335 }
336 out.push_str(line);
337 out.push('\n');
338 }
339 out.push_str("\\endcode");
340 }
341 out
342}
343
344fn strip_markdown_links(s: &str) -> String {
347 let mut out = String::with_capacity(s.len());
348 let bytes = s.as_bytes();
349 let mut i = 0;
350 while i < bytes.len() {
351 if bytes[i] == b'[' {
352 if let Some(close) = bytes[i + 1..].iter().position(|&b| b == b']') {
354 let text_end = i + 1 + close;
355 if text_end + 1 < bytes.len() && bytes[text_end + 1] == b'(' {
356 if let Some(paren_close) = bytes[text_end + 2..].iter().position(|&b| b == b')') {
357 let url_start = text_end + 2;
358 let url_end = url_start + paren_close;
359 let text = &s[i + 1..text_end];
360 let url = &s[url_start..url_end];
361 out.push_str(text);
362 out.push_str(" (");
363 out.push_str(url);
364 out.push(')');
365 i = url_end + 1;
366 continue;
367 }
368 }
369 }
370 }
371 out.push(bytes[i] as char);
372 i += 1;
373 }
374 out
375}
376
377fn word_wrap(input: &str, width: usize) -> String {
381 let mut out = String::with_capacity(input.len());
382 let mut in_code = false;
383 for raw in input.lines() {
384 let trimmed = raw.trim_start();
385 if trimmed.starts_with("\\code") {
386 in_code = true;
387 out.push_str(raw);
388 out.push('\n');
389 continue;
390 }
391 if trimmed.starts_with("\\endcode") {
392 in_code = false;
393 out.push_str(raw);
394 out.push('\n');
395 continue;
396 }
397 if in_code || trimmed.starts_with("```") {
398 out.push_str(raw);
399 out.push('\n');
400 continue;
401 }
402 if raw.len() <= width {
403 out.push_str(raw);
404 out.push('\n');
405 continue;
406 }
407 let mut current = String::with_capacity(width);
408 for word in raw.split_whitespace() {
409 if current.is_empty() {
410 current.push_str(word);
411 } else if current.len() + 1 + word.len() > width {
412 out.push_str(¤t);
413 out.push('\n');
414 current.clear();
415 current.push_str(word);
416 } else {
417 current.push(' ');
418 current.push_str(word);
419 }
420 }
421 if !current.is_empty() {
422 out.push_str(¤t);
423 out.push('\n');
424 }
425 }
426 out.trim_end_matches('\n').to_string()
427}
428
429pub fn emit_zig_doc(out: &mut String, doc: &str, indent: &str) {
432 if doc.is_empty() {
433 return;
434 }
435 for line in doc.lines() {
436 out.push_str(indent);
437 out.push_str("/// ");
438 out.push_str(line);
439 out.push('\n');
440 }
441}
442
443pub fn emit_yard_doc(out: &mut String, doc: &str, indent: &str) {
450 if doc.is_empty() {
451 return;
452 }
453 let sections = parse_rustdoc_sections(doc);
454 let any_section = sections.arguments.is_some()
455 || sections.returns.is_some()
456 || sections.errors.is_some()
457 || sections.example.is_some();
458 let body = if any_section {
459 render_yard_sections(§ions)
460 } else {
461 doc.to_string()
462 };
463 for line in body.lines() {
464 out.push_str(indent);
465 out.push_str("# ");
466 out.push_str(line);
467 out.push('\n');
468 }
469}
470
471pub fn render_yard_sections(sections: &RustdocSections) -> String {
481 let mut out = String::new();
482 if !sections.summary.is_empty() {
483 out.push_str(§ions.summary);
484 }
485 if let Some(args) = sections.arguments.as_deref() {
486 for (name, desc) in parse_arguments_bullets(args) {
487 if !out.is_empty() {
488 out.push('\n');
489 }
490 if desc.is_empty() {
491 out.push_str("@param ");
492 out.push_str(&name);
493 } else {
494 out.push_str("@param ");
495 out.push_str(&name);
496 out.push(' ');
497 out.push_str(&desc);
498 }
499 }
500 }
501 if let Some(ret) = sections.returns.as_deref() {
502 if !out.is_empty() {
503 out.push('\n');
504 }
505 out.push_str("@return ");
506 out.push_str(ret.trim());
507 }
508 if let Some(err) = sections.errors.as_deref() {
509 if !out.is_empty() {
510 out.push('\n');
511 }
512 out.push_str("@raise ");
513 out.push_str(err.trim());
514 }
515 if let Some(example) = sections.example.as_deref() {
516 if let Some(body) = example_for_target(example, "ruby") {
517 if !out.is_empty() {
518 out.push('\n');
519 }
520 out.push_str("@example\n");
521 out.push_str(&body);
522 }
523 }
524 out
525}
526
527fn escape_javadoc_line(s: &str) -> String {
537 let mut result = String::with_capacity(s.len());
538 let mut chars = s.chars().peekable();
539 while let Some(ch) = chars.next() {
540 if ch == '`' {
541 let mut code = String::new();
542 for c in chars.by_ref() {
543 if c == '`' {
544 break;
545 }
546 code.push(c);
547 }
548 result.push_str("{@code ");
549 result.push_str(&escape_javadoc_html_entities(&code));
550 result.push('}');
551 } else if ch == '<' {
552 result.push_str("<");
553 } else if ch == '>' {
554 result.push_str(">");
555 } else if ch == '&' {
556 result.push_str("&");
557 } else {
558 result.push(ch);
559 }
560 }
561 result
562}
563
564fn escape_javadoc_html_entities(s: &str) -> String {
567 let mut out = String::with_capacity(s.len());
568 for ch in s.chars() {
569 match ch {
570 '<' => out.push_str("<"),
571 '>' => out.push_str(">"),
572 '&' => out.push_str("&"),
573 other => out.push(other),
574 }
575 }
576 out
577}
578
579#[derive(Debug, Default, Clone, PartialEq, Eq)]
590pub struct RustdocSections {
591 pub summary: String,
593 pub arguments: Option<String>,
595 pub returns: Option<String>,
597 pub errors: Option<String>,
599 pub panics: Option<String>,
601 pub safety: Option<String>,
603 pub example: Option<String>,
605}
606
607pub fn parse_rustdoc_sections(doc: &str) -> RustdocSections {
619 if doc.trim().is_empty() {
620 return RustdocSections::default();
621 }
622 let mut summary = String::new();
623 let mut arguments: Option<String> = None;
624 let mut returns: Option<String> = None;
625 let mut errors: Option<String> = None;
626 let mut panics: Option<String> = None;
627 let mut safety: Option<String> = None;
628 let mut example: Option<String> = None;
629 let mut current: Option<&'static str> = None;
630 let mut buf = String::new();
631 let mut in_fence = false;
632 let flush = |target: Option<&'static str>,
633 buf: &mut String,
634 summary: &mut String,
635 arguments: &mut Option<String>,
636 returns: &mut Option<String>,
637 errors: &mut Option<String>,
638 panics: &mut Option<String>,
639 safety: &mut Option<String>,
640 example: &mut Option<String>| {
641 let body = std::mem::take(buf).trim().to_string();
642 if body.is_empty() {
643 return;
644 }
645 match target {
646 None => {
647 if !summary.is_empty() {
648 summary.push('\n');
649 }
650 summary.push_str(&body);
651 }
652 Some("arguments") => *arguments = Some(body),
653 Some("returns") => *returns = Some(body),
654 Some("errors") => *errors = Some(body),
655 Some("panics") => *panics = Some(body),
656 Some("safety") => *safety = Some(body),
657 Some("example") => *example = Some(body),
658 _ => {}
659 }
660 };
661 for line in doc.lines() {
662 let trimmed = line.trim_start();
663 if trimmed.starts_with("```") {
664 in_fence = !in_fence;
665 buf.push_str(line);
666 buf.push('\n');
667 continue;
668 }
669 if !in_fence {
670 if let Some(rest) = trimmed.strip_prefix("# ") {
671 let head = rest.trim().to_ascii_lowercase();
672 let target = match head.as_str() {
673 "arguments" | "args" => Some("arguments"),
674 "returns" => Some("returns"),
675 "errors" => Some("errors"),
676 "panics" => Some("panics"),
677 "safety" => Some("safety"),
678 "example" | "examples" => Some("example"),
679 _ => None,
680 };
681 if target.is_some() {
682 flush(
683 current,
684 &mut buf,
685 &mut summary,
686 &mut arguments,
687 &mut returns,
688 &mut errors,
689 &mut panics,
690 &mut safety,
691 &mut example,
692 );
693 current = target;
694 continue;
695 }
696 }
697 }
698 buf.push_str(line);
699 buf.push('\n');
700 }
701 flush(
702 current,
703 &mut buf,
704 &mut summary,
705 &mut arguments,
706 &mut returns,
707 &mut errors,
708 &mut panics,
709 &mut safety,
710 &mut example,
711 );
712 RustdocSections {
713 summary,
714 arguments,
715 returns,
716 errors,
717 panics,
718 safety,
719 example,
720 }
721}
722
723pub fn parse_arguments_bullets(body: &str) -> Vec<(String, String)> {
733 let mut out: Vec<(String, String)> = Vec::new();
734 for raw in body.lines() {
735 let line = raw.trim_end();
736 let trimmed = line.trim_start();
737 let is_bullet = trimmed.starts_with("* ") || trimmed.starts_with("- ");
738 if is_bullet {
739 let after = &trimmed[2..];
740 let (name, desc) = if let Some(idx) = after.find(" - ") {
742 (after[..idx].trim(), after[idx + 3..].trim())
743 } else if let Some(idx) = after.find(": ") {
744 (after[..idx].trim(), after[idx + 2..].trim())
745 } else if let Some(idx) = after.find(' ') {
746 (after[..idx].trim(), after[idx + 1..].trim())
747 } else {
748 (after.trim(), "")
749 };
750 let name = name.trim_matches('`').trim_matches('*').to_string();
751 out.push((name, desc.to_string()));
752 } else if !trimmed.is_empty() {
753 if let Some(last) = out.last_mut() {
754 if !last.1.is_empty() {
755 last.1.push(' ');
756 }
757 last.1.push_str(trimmed);
758 }
759 }
760 }
761 out
762}
763
764fn detect_first_fence_lang(body: &str) -> &str {
771 for line in body.lines() {
772 let trimmed = line.trim_start();
773 if let Some(rest) = trimmed.strip_prefix("```") {
774 let tag = rest.split(',').next().unwrap_or("").trim();
775 return if tag.is_empty() { "rust" } else { tag };
776 }
777 }
778 "rust"
779}
780
781pub fn example_for_target(example: &str, target_lang: &str) -> Option<String> {
790 let trimmed = example.trim();
791 let source_lang = detect_first_fence_lang(trimmed);
792 if source_lang == "rust" && target_lang != "rust" {
793 None
794 } else {
795 Some(replace_fence_lang(trimmed, target_lang))
796 }
797}
798
799pub fn replace_fence_lang(body: &str, lang_replacement: &str) -> String {
807 let mut out = String::with_capacity(body.len());
808 for line in body.lines() {
809 let trimmed = line.trim_start();
810 if let Some(rest) = trimmed.strip_prefix("```") {
811 let indent = &line[..line.len() - trimmed.len()];
814 let after_lang = rest.find(',').map(|i| &rest[i..]).unwrap_or("");
815 out.push_str(indent);
816 out.push_str("```");
817 out.push_str(lang_replacement);
818 out.push_str(after_lang);
819 out.push('\n');
820 } else {
821 out.push_str(line);
822 out.push('\n');
823 }
824 }
825 out.trim_end_matches('\n').to_string()
826}
827
828pub fn render_jsdoc_sections(sections: &RustdocSections) -> String {
841 let mut out = String::new();
842 if !sections.summary.is_empty() {
843 out.push_str(§ions.summary);
844 }
845 if let Some(args) = sections.arguments.as_deref() {
846 for (name, desc) in parse_arguments_bullets(args) {
847 if !out.is_empty() {
848 out.push('\n');
849 }
850 if desc.is_empty() {
851 out.push_str(&crate::template_env::render(
852 "doc_jsdoc_param.jinja",
853 minijinja::context! { name => &name },
854 ));
855 } else {
856 out.push_str(&crate::template_env::render(
857 "doc_jsdoc_param_desc.jinja",
858 minijinja::context! { name => &name, desc => &desc },
859 ));
860 }
861 }
862 }
863 if let Some(ret) = sections.returns.as_deref() {
864 if !out.is_empty() {
865 out.push('\n');
866 }
867 out.push_str(&crate::template_env::render(
868 "doc_jsdoc_returns.jinja",
869 minijinja::context! { content => ret.trim() },
870 ));
871 }
872 if let Some(err) = sections.errors.as_deref() {
873 if !out.is_empty() {
874 out.push('\n');
875 }
876 out.push_str(&crate::template_env::render(
877 "doc_jsdoc_throws.jinja",
878 minijinja::context! { content => err.trim() },
879 ));
880 }
881 if let Some(example) = sections.example.as_deref() {
882 if let Some(body) = example_for_target(example, "typescript") {
883 if !out.is_empty() {
884 out.push('\n');
885 }
886 out.push_str("@example\n");
887 out.push_str(&body);
888 }
889 }
890 out
891}
892
893pub fn render_javadoc_sections(sections: &RustdocSections, throws_class: &str) -> String {
903 let mut out = String::new();
904 if !sections.summary.is_empty() {
905 out.push_str(§ions.summary);
906 }
907 if let Some(args) = sections.arguments.as_deref() {
908 for (name, desc) in parse_arguments_bullets(args) {
909 if !out.is_empty() {
910 out.push('\n');
911 }
912 if desc.is_empty() {
913 out.push_str(&crate::template_env::render(
914 "doc_javadoc_param.jinja",
915 minijinja::context! { name => &name },
916 ));
917 } else {
918 out.push_str(&crate::template_env::render(
919 "doc_javadoc_param_desc.jinja",
920 minijinja::context! { name => &name, desc => &desc },
921 ));
922 }
923 }
924 }
925 if let Some(ret) = sections.returns.as_deref() {
926 if !out.is_empty() {
927 out.push('\n');
928 }
929 out.push_str(&crate::template_env::render(
930 "doc_javadoc_return.jinja",
931 minijinja::context! { content => ret.trim() },
932 ));
933 }
934 if let Some(err) = sections.errors.as_deref() {
935 if !out.is_empty() {
936 out.push('\n');
937 }
938 out.push_str(&crate::template_env::render(
939 "doc_javadoc_throws.jinja",
940 minijinja::context! { throws_class => throws_class, content => err.trim() },
941 ));
942 }
943 out
944}
945
946pub fn render_csharp_xml_sections(sections: &RustdocSections, exception_class: &str) -> String {
955 let mut out = String::new();
956 out.push_str("<summary>\n");
957 let summary = if sections.summary.is_empty() {
958 ""
959 } else {
960 sections.summary.as_str()
961 };
962 for line in summary.lines() {
963 out.push_str(line);
964 out.push('\n');
965 }
966 out.push_str("</summary>");
967 if let Some(args) = sections.arguments.as_deref() {
968 for (name, desc) in parse_arguments_bullets(args) {
969 out.push('\n');
970 if desc.is_empty() {
971 out.push_str(&crate::template_env::render(
972 "doc_csharp_param.jinja",
973 minijinja::context! { name => &name },
974 ));
975 } else {
976 out.push_str(&crate::template_env::render(
977 "doc_csharp_param_desc.jinja",
978 minijinja::context! { name => &name, desc => &desc },
979 ));
980 }
981 }
982 }
983 if let Some(ret) = sections.returns.as_deref() {
984 out.push('\n');
985 out.push_str(&crate::template_env::render(
986 "doc_csharp_returns.jinja",
987 minijinja::context! { content => ret.trim() },
988 ));
989 }
990 if let Some(err) = sections.errors.as_deref() {
991 out.push('\n');
992 out.push_str(&crate::template_env::render(
993 "doc_csharp_exception.jinja",
994 minijinja::context! {
995 exception_class => exception_class,
996 content => err.trim(),
997 },
998 ));
999 }
1000 if let Some(example) = sections.example.as_deref() {
1001 out.push('\n');
1002 out.push_str("<example><code language=\"csharp\">\n");
1003 for line in example.lines() {
1005 let t = line.trim_start();
1006 if t.starts_with("```") {
1007 continue;
1008 }
1009 out.push_str(line);
1010 out.push('\n');
1011 }
1012 out.push_str("</code></example>");
1013 }
1014 out
1015}
1016
1017pub fn render_phpdoc_sections(sections: &RustdocSections, throws_class: &str) -> String {
1024 let mut out = String::new();
1025 if !sections.summary.is_empty() {
1026 out.push_str(§ions.summary);
1027 }
1028 if let Some(args) = sections.arguments.as_deref() {
1029 for (name, desc) in parse_arguments_bullets(args) {
1030 if !out.is_empty() {
1031 out.push('\n');
1032 }
1033 if desc.is_empty() {
1034 out.push_str(&crate::template_env::render(
1035 "doc_phpdoc_param.jinja",
1036 minijinja::context! { name => &name },
1037 ));
1038 } else {
1039 out.push_str(&crate::template_env::render(
1040 "doc_phpdoc_param_desc.jinja",
1041 minijinja::context! { name => &name, desc => &desc },
1042 ));
1043 }
1044 }
1045 }
1046 if let Some(ret) = sections.returns.as_deref() {
1047 if !out.is_empty() {
1048 out.push('\n');
1049 }
1050 out.push_str(&crate::template_env::render(
1051 "doc_phpdoc_return.jinja",
1052 minijinja::context! { content => ret.trim() },
1053 ));
1054 }
1055 if let Some(err) = sections.errors.as_deref() {
1056 if !out.is_empty() {
1057 out.push('\n');
1058 }
1059 out.push_str(&crate::template_env::render(
1060 "doc_phpdoc_throws.jinja",
1061 minijinja::context! { throws_class => throws_class, content => err.trim() },
1062 ));
1063 }
1064 if let Some(example) = sections.example.as_deref() {
1065 if let Some(body) = example_for_target(example, "php") {
1066 if !out.is_empty() {
1067 out.push('\n');
1068 }
1069 out.push_str(&body);
1070 }
1071 }
1072 out
1073}
1074
1075pub fn render_doxygen_sections(sections: &RustdocSections) -> String {
1082 let mut out = String::new();
1083 if !sections.summary.is_empty() {
1084 out.push_str(§ions.summary);
1085 }
1086 if let Some(args) = sections.arguments.as_deref() {
1087 for (name, desc) in parse_arguments_bullets(args) {
1088 if !out.is_empty() {
1089 out.push('\n');
1090 }
1091 if desc.is_empty() {
1092 out.push_str(&crate::template_env::render(
1093 "doc_doxygen_param.jinja",
1094 minijinja::context! { name => &name },
1095 ));
1096 } else {
1097 out.push_str(&crate::template_env::render(
1098 "doc_doxygen_param_desc.jinja",
1099 minijinja::context! { name => &name, desc => &desc },
1100 ));
1101 }
1102 }
1103 }
1104 if let Some(ret) = sections.returns.as_deref() {
1105 if !out.is_empty() {
1106 out.push('\n');
1107 }
1108 out.push_str(&crate::template_env::render(
1109 "doc_doxygen_return.jinja",
1110 minijinja::context! { content => ret.trim() },
1111 ));
1112 }
1113 if let Some(err) = sections.errors.as_deref() {
1114 if !out.is_empty() {
1115 out.push('\n');
1116 }
1117 out.push_str(&crate::template_env::render(
1118 "doc_doxygen_errors.jinja",
1119 minijinja::context! { content => err.trim() },
1120 ));
1121 }
1122 if let Some(example) = sections.example.as_deref() {
1123 if !out.is_empty() {
1124 out.push('\n');
1125 }
1126 out.push_str("\\code\n");
1127 for line in example.lines() {
1128 let t = line.trim_start();
1129 if t.starts_with("```") {
1130 continue;
1131 }
1132 out.push_str(line);
1133 out.push('\n');
1134 }
1135 out.push_str("\\endcode");
1136 }
1137 out
1138}
1139
1140pub fn doc_first_paragraph_joined(doc: &str) -> String {
1153 doc.lines()
1154 .take_while(|l| !l.trim().is_empty())
1155 .map(str::trim)
1156 .collect::<Vec<_>>()
1157 .join(" ")
1158}
1159
1160#[cfg(test)]
1161mod tests {
1162 use super::*;
1163
1164 #[test]
1165 fn test_emit_phpdoc() {
1166 let mut out = String::new();
1167 emit_phpdoc(&mut out, "Simple documentation", " ", "TestException");
1168 assert!(out.contains("/**"));
1169 assert!(out.contains("Simple documentation"));
1170 assert!(out.contains("*/"));
1171 }
1172
1173 #[test]
1174 fn test_phpdoc_escaping() {
1175 let mut out = String::new();
1176 emit_phpdoc(&mut out, "Handle */ sequences", "", "TestException");
1177 assert!(out.contains("Handle * / sequences"));
1178 }
1179
1180 #[test]
1181 fn test_emit_csharp_doc() {
1182 let mut out = String::new();
1183 emit_csharp_doc(&mut out, "C# documentation", " ", "TestException");
1184 assert!(out.contains("<summary>"));
1185 assert!(out.contains("C# documentation"));
1186 assert!(out.contains("</summary>"));
1187 }
1188
1189 #[test]
1190 fn test_csharp_xml_escaping() {
1191 let mut out = String::new();
1192 emit_csharp_doc(&mut out, "foo < bar & baz > qux", "", "TestException");
1193 assert!(out.contains("foo < bar & baz > qux"));
1194 }
1195
1196 #[test]
1197 fn test_emit_elixir_doc() {
1198 let mut out = String::new();
1199 emit_elixir_doc(&mut out, "Elixir documentation");
1200 assert!(out.contains("@doc \"\"\""));
1201 assert!(out.contains("Elixir documentation"));
1202 assert!(out.contains("\"\"\""));
1203 }
1204
1205 #[test]
1206 fn test_elixir_heredoc_escaping() {
1207 let mut out = String::new();
1208 emit_elixir_doc(&mut out, "Handle \"\"\" sequences");
1209 assert!(out.contains("Handle \"\" \" sequences"));
1210 }
1211
1212 #[test]
1213 fn test_emit_roxygen() {
1214 let mut out = String::new();
1215 emit_roxygen(&mut out, "R documentation");
1216 assert!(out.contains("#' R documentation"));
1217 }
1218
1219 #[test]
1220 fn test_emit_swift_doc() {
1221 let mut out = String::new();
1222 emit_swift_doc(&mut out, "Swift documentation", " ");
1223 assert!(out.contains("/// Swift documentation"));
1224 }
1225
1226 #[test]
1227 fn test_emit_javadoc() {
1228 let mut out = String::new();
1229 emit_javadoc(&mut out, "Java documentation", " ");
1230 assert!(out.contains("/**"));
1231 assert!(out.contains("Java documentation"));
1232 assert!(out.contains("*/"));
1233 }
1234
1235 #[test]
1236 fn test_emit_kdoc() {
1237 let mut out = String::new();
1238 emit_kdoc(&mut out, "Kotlin documentation", " ");
1239 assert!(out.contains("/**"));
1240 assert!(out.contains("Kotlin documentation"));
1241 assert!(out.contains("*/"));
1242 }
1243
1244 #[test]
1245 fn test_emit_dartdoc() {
1246 let mut out = String::new();
1247 emit_dartdoc(&mut out, "Dart documentation", " ");
1248 assert!(out.contains("/// Dart documentation"));
1249 }
1250
1251 #[test]
1252 fn test_emit_gleam_doc() {
1253 let mut out = String::new();
1254 emit_gleam_doc(&mut out, "Gleam documentation", " ");
1255 assert!(out.contains("/// Gleam documentation"));
1256 }
1257
1258 #[test]
1259 fn test_emit_zig_doc() {
1260 let mut out = String::new();
1261 emit_zig_doc(&mut out, "Zig documentation", " ");
1262 assert!(out.contains("/// Zig documentation"));
1263 }
1264
1265 #[test]
1266 fn test_empty_doc_skipped() {
1267 let mut out = String::new();
1268 emit_phpdoc(&mut out, "", "", "TestException");
1269 emit_csharp_doc(&mut out, "", "", "TestException");
1270 emit_elixir_doc(&mut out, "");
1271 emit_roxygen(&mut out, "");
1272 emit_kdoc(&mut out, "", "");
1273 emit_dartdoc(&mut out, "", "");
1274 emit_gleam_doc(&mut out, "", "");
1275 emit_zig_doc(&mut out, "", "");
1276 assert!(out.is_empty());
1277 }
1278
1279 #[test]
1280 fn test_doc_first_paragraph_joined_single_line() {
1281 assert_eq!(doc_first_paragraph_joined("Simple doc."), "Simple doc.");
1282 }
1283
1284 #[test]
1285 fn test_doc_first_paragraph_joined_wrapped_sentence() {
1286 let doc = "Convert HTML to Markdown,\nreturning a result.";
1288 assert_eq!(
1289 doc_first_paragraph_joined(doc),
1290 "Convert HTML to Markdown, returning a result."
1291 );
1292 }
1293
1294 #[test]
1295 fn test_doc_first_paragraph_joined_stops_at_blank_line() {
1296 let doc = "First paragraph.\nStill first.\n\nSecond paragraph.";
1297 assert_eq!(doc_first_paragraph_joined(doc), "First paragraph. Still first.");
1298 }
1299
1300 #[test]
1301 fn test_doc_first_paragraph_joined_empty() {
1302 assert_eq!(doc_first_paragraph_joined(""), "");
1303 }
1304
1305 #[test]
1306 fn test_parse_rustdoc_sections_basic() {
1307 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.";
1308 let sections = parse_rustdoc_sections(doc);
1309 assert_eq!(sections.summary, "Extracts text from a file.");
1310 assert_eq!(sections.arguments.as_deref(), Some("* `path` - The file path."));
1311 assert_eq!(sections.returns.as_deref(), Some("The extracted text."));
1312 assert_eq!(sections.errors.as_deref(), Some("Returns `KreuzbergError` on failure."));
1313 assert!(sections.panics.is_none());
1314 }
1315
1316 #[test]
1317 fn test_parse_rustdoc_sections_example_with_fence() {
1318 let doc = "Run the thing.\n\n# Example\n\n```rust\nlet x = run();\n```";
1319 let sections = parse_rustdoc_sections(doc);
1320 assert_eq!(sections.summary, "Run the thing.");
1321 assert!(sections.example.as_ref().unwrap().contains("```rust"));
1322 assert!(sections.example.as_ref().unwrap().contains("let x = run();"));
1323 }
1324
1325 #[test]
1326 fn test_parse_rustdoc_sections_pound_inside_fence_is_not_a_heading() {
1327 let doc = "Summary.\n\n# Example\n\n```bash\n# install deps\nrun --foo\n```";
1331 let sections = parse_rustdoc_sections(doc);
1332 assert_eq!(sections.summary, "Summary.");
1333 assert!(sections.example.as_ref().unwrap().contains("# install deps"));
1334 }
1335
1336 #[test]
1337 fn test_parse_arguments_bullets_dash_separator() {
1338 let body = "* `path` - The file path.\n* `config` - Optional configuration.";
1339 let pairs = parse_arguments_bullets(body);
1340 assert_eq!(pairs.len(), 2);
1341 assert_eq!(pairs[0], ("path".to_string(), "The file path.".to_string()));
1342 assert_eq!(pairs[1], ("config".to_string(), "Optional configuration.".to_string()));
1343 }
1344
1345 #[test]
1346 fn test_parse_arguments_bullets_continuation_line() {
1347 let body = "* `path` - The file path,\n resolved relative to cwd.\n* `mode` - Open mode.";
1348 let pairs = parse_arguments_bullets(body);
1349 assert_eq!(pairs.len(), 2);
1350 assert_eq!(pairs[0].1, "The file path, resolved relative to cwd.");
1351 }
1352
1353 #[test]
1354 fn test_replace_fence_lang_rust_to_typescript() {
1355 let body = "```rust\nlet x = run();\n```";
1356 let out = replace_fence_lang(body, "typescript");
1357 assert!(out.starts_with("```typescript"));
1358 assert!(out.contains("let x = run();"));
1359 }
1360
1361 #[test]
1362 fn test_replace_fence_lang_preserves_attrs() {
1363 let body = "```rust,no_run\nlet x = run();\n```";
1364 let out = replace_fence_lang(body, "typescript");
1365 assert!(out.starts_with("```typescript,no_run"));
1366 }
1367
1368 #[test]
1369 fn test_replace_fence_lang_no_fence_unchanged() {
1370 let body = "Plain prose with `inline code`.";
1371 let out = replace_fence_lang(body, "typescript");
1372 assert_eq!(out, "Plain prose with `inline code`.");
1373 }
1374
1375 fn fixture_sections() -> RustdocSections {
1376 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```";
1377 parse_rustdoc_sections(doc)
1378 }
1379
1380 #[test]
1381 fn test_render_jsdoc_sections() {
1382 let sections = fixture_sections();
1383 let out = render_jsdoc_sections(§ions);
1384 assert!(out.starts_with("Extracts text from a file."));
1385 assert!(out.contains("@param path - The file path."));
1386 assert!(out.contains("@param config - Optional configuration."));
1387 assert!(out.contains("@returns The extracted text and metadata."));
1388 assert!(out.contains("@throws Returns an error when the file is unreadable."));
1389 assert!(!out.contains("@example"), "Rust example must not appear in TSDoc");
1391 assert!(!out.contains("```typescript"));
1392 assert!(!out.contains("```rust"));
1393 }
1394
1395 #[test]
1396 fn test_render_jsdoc_sections_preserves_typescript_example() {
1397 let doc = "Do something.\n\n# Example\n\n```typescript\nconst x = doSomething();\n```";
1398 let sections = parse_rustdoc_sections(doc);
1399 let out = render_jsdoc_sections(§ions);
1400 assert!(out.contains("@example"), "TypeScript example must be preserved");
1401 assert!(out.contains("```typescript"));
1402 }
1403
1404 #[test]
1405 fn test_render_javadoc_sections() {
1406 let sections = fixture_sections();
1407 let out = render_javadoc_sections(§ions, "KreuzbergRsException");
1408 assert!(out.contains("@param path The file path."));
1409 assert!(out.contains("@return The extracted text and metadata."));
1410 assert!(out.contains("@throws KreuzbergRsException Returns an error when the file is unreadable."));
1411 assert!(out.starts_with("Extracts text from a file."));
1414 }
1415
1416 #[test]
1417 fn test_render_csharp_xml_sections() {
1418 let sections = fixture_sections();
1419 let out = render_csharp_xml_sections(§ions, "KreuzbergException");
1420 assert!(out.contains("<summary>\nExtracts text from a file.\n</summary>"));
1421 assert!(out.contains("<param name=\"path\">The file path.</param>"));
1422 assert!(out.contains("<returns>The extracted text and metadata.</returns>"));
1423 assert!(out.contains("<exception cref=\"KreuzbergException\">"));
1424 assert!(out.contains("<example><code language=\"csharp\">"));
1425 assert!(out.contains("let result = extract"));
1426 }
1427
1428 #[test]
1429 fn test_render_phpdoc_sections() {
1430 let sections = fixture_sections();
1431 let out = render_phpdoc_sections(§ions, "KreuzbergException");
1432 assert!(out.contains("@param mixed $path The file path."));
1433 assert!(out.contains("@return The extracted text and metadata."));
1434 assert!(out.contains("@throws KreuzbergException"));
1435 assert!(!out.contains("```php"), "Rust example must not appear in PHPDoc");
1437 assert!(!out.contains("```rust"));
1438 }
1439
1440 #[test]
1441 fn test_render_phpdoc_sections_preserves_php_example() {
1442 let doc = "Do something.\n\n# Example\n\n```php\n$x = doSomething();\n```";
1443 let sections = parse_rustdoc_sections(doc);
1444 let out = render_phpdoc_sections(§ions, "MyException");
1445 assert!(out.contains("```php"), "PHP example must be preserved");
1446 }
1447
1448 #[test]
1449 fn test_render_doxygen_sections() {
1450 let sections = fixture_sections();
1451 let out = render_doxygen_sections(§ions);
1452 assert!(out.contains("\\param path The file path."));
1453 assert!(out.contains("\\return The extracted text and metadata."));
1454 assert!(out.contains("\\code"));
1455 assert!(out.contains("\\endcode"));
1456 }
1457
1458 #[test]
1459 fn test_emit_yard_doc_simple() {
1460 let mut out = String::new();
1461 emit_yard_doc(&mut out, "Simple Ruby documentation", " ");
1462 assert!(out.contains("# Simple Ruby documentation"));
1463 }
1464
1465 #[test]
1466 fn test_emit_yard_doc_empty() {
1467 let mut out = String::new();
1468 emit_yard_doc(&mut out, "", " ");
1469 assert!(out.is_empty());
1470 }
1471
1472 #[test]
1473 fn test_emit_yard_doc_with_sections() {
1474 let mut out = String::new();
1475 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.";
1476 emit_yard_doc(&mut out, doc, " ");
1477 assert!(out.contains("# Extracts text from a file."));
1478 assert!(out.contains("# @param path The file path."));
1479 assert!(out.contains("# @return The extracted text."));
1480 assert!(out.contains("# @raise Returns error on failure."));
1481 }
1482
1483 #[test]
1484 fn test_emit_c_doxygen_simple_prose() {
1485 let mut out = String::new();
1486 emit_c_doxygen(&mut out, "Free a string.", "");
1487 assert!(out.contains("/// Free a string."), "got: {out}");
1488 }
1489
1490 #[test]
1491 fn test_emit_c_doxygen_with_sections() {
1492 let mut out = String::new();
1493 let doc = "Extract content from a file.\n\n# Arguments\n\n* `path` - Path to the file.\n* `mode` - Read mode.\n\n# Returns\n\nA newly allocated string the caller owns.\n\n# Errors\n\nReturns null when the file is unreadable.";
1494 emit_c_doxygen(&mut out, doc, "");
1495 assert!(out.contains("/// Extract content from a file."));
1496 assert!(out.contains("/// \\param path Path to the file."));
1497 assert!(out.contains("/// \\param mode Read mode."));
1498 assert!(out.contains("/// \\return A newly allocated string the caller owns."));
1499 assert!(out.contains("/// \\note Returns null when the file is unreadable."));
1500 }
1501
1502 #[test]
1503 fn test_emit_c_doxygen_safety_section_maps_to_note() {
1504 let mut out = String::new();
1505 let doc = "Free a buffer.\n\n# Safety\n\nPointer must have been returned by this library.";
1506 emit_c_doxygen(&mut out, doc, "");
1507 assert!(out.contains("/// \\note SAFETY: Pointer must have been returned by this library."));
1508 }
1509
1510 #[test]
1511 fn test_emit_c_doxygen_example_renders_code_fence() {
1512 let mut out = String::new();
1513 let doc = "Demo.\n\n# Example\n\n```rust\nlet x = run();\n```";
1514 emit_c_doxygen(&mut out, doc, "");
1515 assert!(out.contains("/// \\code"));
1516 assert!(out.contains("/// \\endcode"));
1517 assert!(out.contains("let x = run();"));
1518 }
1519
1520 #[test]
1521 fn test_emit_c_doxygen_strips_markdown_links() {
1522 let mut out = String::new();
1523 let doc = "See [the docs](https://example.com/x) for details.";
1524 emit_c_doxygen(&mut out, doc, "");
1525 assert!(
1526 out.contains("the docs (https://example.com/x)"),
1527 "expected flattened link, got: {out}"
1528 );
1529 assert!(!out.contains("](https://"));
1530 }
1531
1532 #[test]
1533 fn test_emit_c_doxygen_word_wraps_long_lines() {
1534 let mut out = String::new();
1535 let long = "a ".repeat(80);
1536 emit_c_doxygen(&mut out, long.trim(), "");
1537 for line in out.lines() {
1538 let body = line.trim_start_matches("/// ");
1541 assert!(body.len() <= 100, "line too long ({}): {line}", body.len());
1542 }
1543 }
1544
1545 #[test]
1546 fn test_emit_c_doxygen_empty_input_is_noop() {
1547 let mut out = String::new();
1548 emit_c_doxygen(&mut out, "", "");
1549 emit_c_doxygen(&mut out, " \n\t ", "");
1550 assert!(out.is_empty());
1551 }
1552
1553 #[test]
1554 fn test_emit_c_doxygen_indent_applied() {
1555 let mut out = String::new();
1556 emit_c_doxygen(&mut out, "Hello.", " ");
1557 assert!(out.starts_with(" /// Hello."));
1558 }
1559
1560 #[test]
1561 fn test_render_yard_sections() {
1562 let sections = fixture_sections();
1563 let out = render_yard_sections(§ions);
1564 assert!(out.contains("@param path The file path."));
1565 assert!(out.contains("@return The extracted text and metadata."));
1566 assert!(out.contains("@raise Returns an error when the file is unreadable."));
1567 assert!(!out.contains("@example"), "Rust example must not appear in YARD");
1569 assert!(!out.contains("```ruby"));
1570 assert!(!out.contains("```rust"));
1571 }
1572
1573 #[test]
1574 fn test_render_yard_sections_preserves_ruby_example() {
1575 let doc = "Do something.\n\n# Example\n\n```ruby\nputs :hi\n```";
1576 let sections = parse_rustdoc_sections(doc);
1577 let out = render_yard_sections(§ions);
1578 assert!(out.contains("@example"), "Ruby example must be preserved");
1579 assert!(out.contains("```ruby"));
1580 }
1581
1582 #[test]
1585 fn example_for_target_rust_fenced_suppressed_for_php() {
1586 let example = "```rust\nlet x = 1;\n```";
1587 assert_eq!(
1588 example_for_target(example, "php"),
1589 None,
1590 "rust-fenced example must be omitted for PHP target"
1591 );
1592 }
1593
1594 #[test]
1595 fn example_for_target_bare_fence_defaults_to_rust_suppressed_for_ruby() {
1596 let example = "```\nlet x = 1;\n```";
1597 assert_eq!(
1598 example_for_target(example, "ruby"),
1599 None,
1600 "bare fence is treated as Rust and must be omitted for Ruby target"
1601 );
1602 }
1603
1604 #[test]
1605 fn example_for_target_php_example_preserved_for_php() {
1606 let example = "```php\n$x = 1;\n```";
1607 let result = example_for_target(example, "php");
1608 assert!(result.is_some(), "PHP example must be preserved for PHP target");
1609 assert!(result.unwrap().contains("```php"));
1610 }
1611
1612 #[test]
1613 fn example_for_target_ruby_example_preserved_for_ruby() {
1614 let example = "```ruby\nputs :hi\n```";
1615 let result = example_for_target(example, "ruby");
1616 assert!(result.is_some(), "Ruby example must be preserved for Ruby target");
1617 assert!(result.unwrap().contains("```ruby"));
1618 }
1619
1620 #[test]
1621 fn render_phpdoc_sections_with_rust_example_emits_no_at_example_block() {
1622 let doc = "Convert HTML.\n\n# Arguments\n\n* `html` - The HTML input.\n\n# Example\n\n```rust\nlet result = convert(html, None)?;\n```";
1623 let sections = parse_rustdoc_sections(doc);
1624 let out = render_phpdoc_sections(§ions, "HtmlToMarkdownException");
1625 assert!(!out.contains("```php"), "no PHP @example block for Rust source");
1626 assert!(!out.contains("```rust"), "raw Rust must not leak into PHPDoc");
1627 assert!(out.contains("@param"), "other sections must still be emitted");
1628 }
1629}