1pub fn emit_phpdoc(out: &mut String, doc: &str, indent: &str, exception_class: &str) {
13 if doc.is_empty() {
14 return;
15 }
16 let sanitized = sanitize_rust_idioms(doc, DocTarget::PhpDoc);
18 let sections = parse_rustdoc_sections(&sanitized);
19 let any_section = sections.arguments.is_some()
20 || sections.returns.is_some()
21 || sections.errors.is_some()
22 || sections.example.is_some();
23 let body = if any_section {
24 render_phpdoc_sections(§ions, exception_class)
25 } else {
26 sanitized
27 };
28 out.push_str(indent);
29 out.push_str("/**\n");
30 for line in body.lines() {
31 out.push_str(indent);
32 out.push_str(" * ");
33 out.push_str(&escape_phpdoc_line(line));
34 out.push('\n');
35 }
36 out.push_str(indent);
37 out.push_str(" */\n");
38}
39
40fn escape_phpdoc_line(s: &str) -> String {
42 s.replace("*/", "* /")
43}
44
45pub fn emit_csharp_doc(out: &mut String, doc: &str, indent: &str, exception_class: &str) {
54 if doc.is_empty() {
55 return;
56 }
57 let raw_sections = parse_rustdoc_sections(doc);
61 let sections = RustdocSections {
62 summary: sanitize_rust_idioms_keep_sections(&raw_sections.summary, DocTarget::CSharpDoc),
63 arguments: raw_sections
64 .arguments
65 .as_deref()
66 .map(|s| sanitize_rust_idioms_keep_sections(s, DocTarget::CSharpDoc)),
67 returns: raw_sections
68 .returns
69 .as_deref()
70 .map(|s| sanitize_rust_idioms_keep_sections(s, DocTarget::CSharpDoc)),
71 errors: raw_sections
72 .errors
73 .as_deref()
74 .map(|s| sanitize_rust_idioms_keep_sections(s, DocTarget::CSharpDoc)),
75 panics: raw_sections
76 .panics
77 .as_deref()
78 .map(|s| sanitize_rust_idioms_keep_sections(s, DocTarget::CSharpDoc)),
79 safety: raw_sections
80 .safety
81 .as_deref()
82 .map(|s| sanitize_rust_idioms_keep_sections(s, DocTarget::CSharpDoc)),
83 example: None,
86 };
87 let any_section = sections.arguments.is_some()
88 || sections.returns.is_some()
89 || sections.errors.is_some()
90 || sections.example.is_some();
91 if !any_section {
92 out.push_str(indent);
94 out.push_str("/// <summary>\n");
95 for line in sections.summary.lines() {
96 out.push_str(indent);
97 out.push_str("/// ");
98 out.push_str(line);
102 out.push('\n');
103 }
104 out.push_str(indent);
105 out.push_str("/// </summary>\n");
106 return;
107 }
108 let rendered = render_csharp_xml_sections(§ions, exception_class);
109 for line in rendered.lines() {
110 out.push_str(indent);
111 out.push_str("/// ");
112 out.push_str(line);
117 out.push('\n');
118 }
119}
120
121pub fn emit_elixir_doc(out: &mut String, doc: &str) {
124 if doc.is_empty() {
125 return;
126 }
127 out.push_str("@doc \"\"\"\n");
128 for line in doc.lines() {
129 out.push_str(&escape_elixir_doc_line(line));
130 out.push('\n');
131 }
132 out.push_str("\"\"\"\n");
133}
134
135pub fn emit_rustdoc(out: &mut String, doc: &str, indent: &str) {
141 if doc.is_empty() {
142 return;
143 }
144 for line in doc.lines() {
145 out.push_str(indent);
146 out.push_str("/// ");
147 out.push_str(line);
148 out.push('\n');
149 }
150}
151
152fn escape_elixir_doc_line(s: &str) -> String {
154 s.replace("\"\"\"", "\"\" \"")
155}
156
157pub fn emit_roxygen(out: &mut String, doc: &str) {
160 if doc.is_empty() {
161 return;
162 }
163 for line in doc.lines() {
164 out.push_str("#' ");
165 out.push_str(line);
166 out.push('\n');
167 }
168}
169
170pub fn emit_swift_doc(out: &mut String, doc: &str, indent: &str) {
173 if doc.is_empty() {
174 return;
175 }
176 for line in doc.lines() {
177 out.push_str(indent);
178 out.push_str("/// ");
179 out.push_str(line);
180 out.push('\n');
181 }
182}
183
184pub fn emit_javadoc(out: &mut String, doc: &str, indent: &str) {
188 if doc.is_empty() {
189 return;
190 }
191 out.push_str(indent);
192 out.push_str("/**\n");
193 for line in doc.lines() {
194 let escaped = escape_javadoc_line(line);
195 let trimmed = escaped.trim_end();
196 if trimmed.is_empty() {
197 out.push_str(indent);
198 out.push_str(" *\n");
199 } else {
200 out.push_str(indent);
201 out.push_str(" * ");
202 out.push_str(trimmed);
203 out.push('\n');
204 }
205 }
206 out.push_str(indent);
207 out.push_str(" */\n");
208}
209
210pub fn emit_kdoc(out: &mut String, doc: &str, indent: &str) {
213 if doc.is_empty() {
214 return;
215 }
216 out.push_str(indent);
217 out.push_str("/**\n");
218 for line in doc.lines() {
219 let trimmed = line.trim_end();
220 if trimmed.is_empty() {
221 out.push_str(indent);
222 out.push_str(" *\n");
223 } else {
224 out.push_str(indent);
225 out.push_str(" * ");
226 out.push_str(trimmed);
227 out.push('\n');
228 }
229 }
230 out.push_str(indent);
231 out.push_str(" */\n");
232}
233
234pub fn emit_kdoc_ktfmt_canonical(out: &mut String, doc: &str, indent: &str) {
245 const KTFMT_LINE_WIDTH: usize = 100;
246
247 if doc.is_empty() {
248 return;
249 }
250
251 let lines: Vec<&str> = doc.lines().collect();
252
253 let is_short_single_paragraph = lines.len() == 1 && !lines[0].contains('\n');
255
256 if is_short_single_paragraph {
257 let trimmed = lines[0].trim();
258 let single_line_len = indent.len() + 4 + trimmed.len() + 3; if single_line_len <= KTFMT_LINE_WIDTH {
261 out.push_str(indent);
263 out.push_str("/** ");
264 out.push_str(trimmed);
265 out.push_str(" */\n");
266 return;
267 }
268 }
269
270 out.push_str(indent);
272 out.push_str("/**\n");
273 for line in lines {
274 let trimmed = line.trim_end();
275 if trimmed.is_empty() {
276 out.push_str(indent);
277 out.push_str(" *\n");
278 } else {
279 out.push_str(indent);
280 out.push_str(" * ");
281 out.push_str(trimmed);
282 out.push('\n');
283 }
284 }
285 out.push_str(indent);
286 out.push_str(" */\n");
287}
288
289pub fn emit_dartdoc(out: &mut String, doc: &str, indent: &str) {
292 if doc.is_empty() {
293 return;
294 }
295 for line in doc.lines() {
296 out.push_str(indent);
297 out.push_str("/// ");
298 out.push_str(line);
299 out.push('\n');
300 }
301}
302
303pub fn emit_gleam_doc(out: &mut String, doc: &str, indent: &str) {
306 if doc.is_empty() {
307 return;
308 }
309 for line in doc.lines() {
310 out.push_str(indent);
311 out.push_str("/// ");
312 out.push_str(line);
313 out.push('\n');
314 }
315}
316
317pub fn emit_c_doxygen(out: &mut String, doc: &str, indent: &str) {
338 if doc.trim().is_empty() {
339 return;
340 }
341 let sections = parse_rustdoc_sections(doc);
342 let any_section = sections.arguments.is_some()
343 || sections.returns.is_some()
344 || sections.errors.is_some()
345 || sections.safety.is_some()
346 || sections.example.is_some();
347 let mut body = if any_section {
348 render_doxygen_sections_with_notes(§ions)
349 } else {
350 sections.summary.clone()
351 };
352 body = strip_markdown_links(&body);
353 let wrapped = word_wrap(&body, DOXYGEN_WRAP_WIDTH);
354 for line in wrapped.lines() {
355 out.push_str(indent);
356 out.push_str("/// ");
357 out.push_str(line);
358 out.push('\n');
359 }
360}
361
362const DOXYGEN_WRAP_WIDTH: usize = 100;
363
364fn render_doxygen_sections_with_notes(sections: &RustdocSections) -> String {
369 let mut out = String::new();
370 if !sections.summary.is_empty() {
371 out.push_str(§ions.summary);
372 }
373 if let Some(args) = sections.arguments.as_deref() {
374 for (name, desc) in parse_arguments_bullets(args) {
375 if !out.is_empty() {
376 out.push('\n');
377 }
378 if desc.is_empty() {
379 out.push_str("\\param ");
380 out.push_str(&name);
381 } else {
382 out.push_str("\\param ");
383 out.push_str(&name);
384 out.push(' ');
385 out.push_str(&desc);
386 }
387 }
388 }
389 if let Some(ret) = sections.returns.as_deref() {
390 if !out.is_empty() {
391 out.push('\n');
392 }
393 out.push_str("\\return ");
394 out.push_str(ret.trim());
395 }
396 if let Some(err) = sections.errors.as_deref() {
397 if !out.is_empty() {
398 out.push('\n');
399 }
400 out.push_str("\\note ");
401 out.push_str(err.trim());
402 }
403 if let Some(safety) = sections.safety.as_deref() {
404 if !out.is_empty() {
405 out.push('\n');
406 }
407 out.push_str("\\note SAFETY: ");
408 out.push_str(safety.trim());
409 }
410 if let Some(example) = sections.example.as_deref() {
411 if !out.is_empty() {
412 out.push('\n');
413 }
414 out.push_str("\\code\n");
415 for line in example.lines() {
416 let t = line.trim_start();
417 if t.starts_with("```") {
418 continue;
419 }
420 out.push_str(line);
421 out.push('\n');
422 }
423 out.push_str("\\endcode");
424 }
425 out
426}
427
428fn strip_markdown_links(s: &str) -> String {
431 let mut out = String::with_capacity(s.len());
432 let bytes = s.as_bytes();
433 let mut i = 0;
434 while i < bytes.len() {
435 if bytes[i] == b'[' {
436 if let Some(close) = bytes[i + 1..].iter().position(|&b| b == b']') {
438 let text_end = i + 1 + close;
439 if text_end + 1 < bytes.len() && bytes[text_end + 1] == b'(' {
440 if let Some(paren_close) = bytes[text_end + 2..].iter().position(|&b| b == b')') {
441 let url_start = text_end + 2;
442 let url_end = url_start + paren_close;
443 let text = &s[i + 1..text_end];
444 let url = &s[url_start..url_end];
445 out.push_str(text);
446 out.push_str(" (");
447 out.push_str(url);
448 out.push(')');
449 i = url_end + 1;
450 continue;
451 }
452 }
453 }
454 }
455 out.push(bytes[i] as char);
456 i += 1;
457 }
458 out
459}
460
461fn word_wrap(input: &str, width: usize) -> String {
465 let mut out = String::with_capacity(input.len());
466 let mut in_code = false;
467 for raw in input.lines() {
468 let trimmed = raw.trim_start();
469 if trimmed.starts_with("\\code") {
470 in_code = true;
471 out.push_str(raw);
472 out.push('\n');
473 continue;
474 }
475 if trimmed.starts_with("\\endcode") {
476 in_code = false;
477 out.push_str(raw);
478 out.push('\n');
479 continue;
480 }
481 if in_code || trimmed.starts_with("```") {
482 out.push_str(raw);
483 out.push('\n');
484 continue;
485 }
486 if raw.len() <= width {
487 out.push_str(raw);
488 out.push('\n');
489 continue;
490 }
491 let mut current = String::with_capacity(width);
492 for word in raw.split_whitespace() {
493 if current.is_empty() {
494 current.push_str(word);
495 } else if current.len() + 1 + word.len() > width {
496 out.push_str(¤t);
497 out.push('\n');
498 current.clear();
499 current.push_str(word);
500 } else {
501 current.push(' ');
502 current.push_str(word);
503 }
504 }
505 if !current.is_empty() {
506 out.push_str(¤t);
507 out.push('\n');
508 }
509 }
510 out.trim_end_matches('\n').to_string()
511}
512
513pub fn emit_zig_doc(out: &mut String, doc: &str, indent: &str) {
516 if doc.is_empty() {
517 return;
518 }
519 for line in doc.lines() {
520 out.push_str(indent);
521 out.push_str("/// ");
522 out.push_str(line);
523 out.push('\n');
524 }
525}
526
527pub fn emit_yard_doc(out: &mut String, doc: &str, indent: &str) {
534 if doc.is_empty() {
535 return;
536 }
537 let sections = parse_rustdoc_sections(doc);
538 let any_section = sections.arguments.is_some()
539 || sections.returns.is_some()
540 || sections.errors.is_some()
541 || sections.example.is_some();
542 let body = if any_section {
543 render_yard_sections(§ions)
544 } else {
545 doc.to_string()
546 };
547 for line in body.lines() {
548 out.push_str(indent);
549 out.push_str("# ");
550 out.push_str(line);
551 out.push('\n');
552 }
553}
554
555pub fn render_yard_sections(sections: &RustdocSections) -> String {
565 let mut out = String::new();
566 if !sections.summary.is_empty() {
567 out.push_str(§ions.summary);
568 }
569 if let Some(args) = sections.arguments.as_deref() {
570 for (name, desc) in parse_arguments_bullets(args) {
571 if !out.is_empty() {
572 out.push('\n');
573 }
574 if desc.is_empty() {
575 out.push_str("@param ");
576 out.push_str(&name);
577 } else {
578 out.push_str("@param ");
579 out.push_str(&name);
580 out.push(' ');
581 out.push_str(&desc);
582 }
583 }
584 }
585 if let Some(ret) = sections.returns.as_deref() {
586 if !out.is_empty() {
587 out.push('\n');
588 }
589 out.push_str("@return ");
590 out.push_str(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("@raise ");
597 out.push_str(err.trim());
598 }
599 if let Some(example) = sections.example.as_deref() {
600 if let Some(body) = example_for_target(example, "ruby") {
601 if !out.is_empty() {
602 out.push('\n');
603 }
604 out.push_str("@example\n");
605 out.push_str(&body);
606 }
607 }
608 out
609}
610
611fn escape_javadoc_line(s: &str) -> String {
621 let mut result = String::with_capacity(s.len());
622 let mut chars = s.chars().peekable();
623 while let Some(ch) = chars.next() {
624 if ch == '`' {
625 let mut code = String::new();
626 for c in chars.by_ref() {
627 if c == '`' {
628 break;
629 }
630 code.push(c);
631 }
632 result.push_str("{@code ");
633 result.push_str(&escape_javadoc_html_entities(&code));
634 result.push('}');
635 } else if ch == '<' {
636 result.push_str("<");
637 } else if ch == '>' {
638 result.push_str(">");
639 } else if ch == '&' {
640 result.push_str("&");
641 } else {
642 result.push(ch);
643 }
644 }
645 result
646}
647
648fn escape_javadoc_html_entities(s: &str) -> String {
651 let mut out = String::with_capacity(s.len());
652 for ch in s.chars() {
653 match ch {
654 '<' => out.push_str("<"),
655 '>' => out.push_str(">"),
656 '&' => out.push_str("&"),
657 other => out.push(other),
658 }
659 }
660 out
661}
662
663#[derive(Debug, Default, Clone, PartialEq, Eq)]
674pub struct RustdocSections {
675 pub summary: String,
677 pub arguments: Option<String>,
679 pub returns: Option<String>,
681 pub errors: Option<String>,
683 pub panics: Option<String>,
685 pub safety: Option<String>,
687 pub example: Option<String>,
689}
690
691pub fn parse_rustdoc_sections(doc: &str) -> RustdocSections {
703 if doc.trim().is_empty() {
704 return RustdocSections::default();
705 }
706 let mut summary = String::new();
707 let mut arguments: Option<String> = None;
708 let mut returns: Option<String> = None;
709 let mut errors: Option<String> = None;
710 let mut panics: Option<String> = None;
711 let mut safety: Option<String> = None;
712 let mut example: Option<String> = None;
713 let mut current: Option<&'static str> = None;
714 let mut buf = String::new();
715 let mut in_fence = false;
716 let flush = |target: Option<&'static str>,
717 buf: &mut String,
718 summary: &mut String,
719 arguments: &mut Option<String>,
720 returns: &mut Option<String>,
721 errors: &mut Option<String>,
722 panics: &mut Option<String>,
723 safety: &mut Option<String>,
724 example: &mut Option<String>| {
725 let body = std::mem::take(buf).trim().to_string();
726 if body.is_empty() {
727 return;
728 }
729 match target {
730 None => {
731 if !summary.is_empty() {
732 summary.push('\n');
733 }
734 summary.push_str(&body);
735 }
736 Some("arguments") => *arguments = Some(body),
737 Some("returns") => *returns = Some(body),
738 Some("errors") => *errors = Some(body),
739 Some("panics") => *panics = Some(body),
740 Some("safety") => *safety = Some(body),
741 Some("example") => *example = Some(body),
742 _ => {}
743 }
744 };
745 for line in doc.lines() {
746 let trimmed = line.trim_start();
747 if trimmed.starts_with("```") {
748 in_fence = !in_fence;
749 buf.push_str(line);
750 buf.push('\n');
751 continue;
752 }
753 if !in_fence {
754 if let Some(rest) = trimmed.strip_prefix("# ") {
755 let head = rest.trim().to_ascii_lowercase();
756 let target = match head.as_str() {
757 "arguments" | "args" => Some("arguments"),
758 "returns" => Some("returns"),
759 "errors" => Some("errors"),
760 "panics" => Some("panics"),
761 "safety" => Some("safety"),
762 "example" | "examples" => Some("example"),
763 _ => None,
764 };
765 if target.is_some() {
766 flush(
767 current,
768 &mut buf,
769 &mut summary,
770 &mut arguments,
771 &mut returns,
772 &mut errors,
773 &mut panics,
774 &mut safety,
775 &mut example,
776 );
777 current = target;
778 continue;
779 }
780 }
781 }
782 buf.push_str(line);
783 buf.push('\n');
784 }
785 flush(
786 current,
787 &mut buf,
788 &mut summary,
789 &mut arguments,
790 &mut returns,
791 &mut errors,
792 &mut panics,
793 &mut safety,
794 &mut example,
795 );
796 RustdocSections {
797 summary,
798 arguments,
799 returns,
800 errors,
801 panics,
802 safety,
803 example,
804 }
805}
806
807pub fn parse_arguments_bullets(body: &str) -> Vec<(String, String)> {
817 let mut out: Vec<(String, String)> = Vec::new();
818 for raw in body.lines() {
819 let line = raw.trim_end();
820 let trimmed = line.trim_start();
821 let is_bullet = trimmed.starts_with("* ") || trimmed.starts_with("- ");
822 if is_bullet {
823 let after = &trimmed[2..];
824 let (name, desc) = if let Some(idx) = after.find(" - ") {
826 (after[..idx].trim(), after[idx + 3..].trim())
827 } else if let Some(idx) = after.find(": ") {
828 (after[..idx].trim(), after[idx + 2..].trim())
829 } else if let Some(idx) = after.find(' ') {
830 (after[..idx].trim(), after[idx + 1..].trim())
831 } else {
832 (after.trim(), "")
833 };
834 let name = name.trim_matches('`').trim_matches('*').to_string();
835 out.push((name, desc.to_string()));
836 } else if !trimmed.is_empty() {
837 if let Some(last) = out.last_mut() {
838 if !last.1.is_empty() {
839 last.1.push(' ');
840 }
841 last.1.push_str(trimmed);
842 }
843 }
844 }
845 out
846}
847
848fn is_rust_fence_tag(tag: &str) -> bool {
860 const RUSTDOC_ATTRS: &[&str] = &["no_run", "ignore", "should_panic", "compile_fail"];
861 tag.is_empty()
862 || tag == "rust"
863 || tag.starts_with("rust,")
864 || RUSTDOC_ATTRS.contains(&tag)
865 || tag.starts_with("edition")
866}
867
868fn detect_first_fence_lang(body: &str) -> &str {
875 for line in body.lines() {
876 let trimmed = line.trim_start();
877 if let Some(rest) = trimmed.strip_prefix("```") {
878 let tag = rest.split(',').next().unwrap_or("").trim();
879 return if tag.is_empty() || is_rust_fence_tag(tag) {
880 "rust"
881 } else {
882 tag
883 };
884 }
885 }
886 "rust"
887}
888
889pub fn example_for_target(example: &str, target_lang: &str) -> Option<String> {
898 let trimmed = example.trim();
899 let source_lang = detect_first_fence_lang(trimmed);
900 if source_lang == "rust" && target_lang != "rust" {
901 None
902 } else {
903 Some(replace_fence_lang(trimmed, target_lang))
904 }
905}
906
907pub fn replace_fence_lang(body: &str, lang_replacement: &str) -> String {
915 let mut out = String::with_capacity(body.len());
916 for line in body.lines() {
917 let trimmed = line.trim_start();
918 if let Some(rest) = trimmed.strip_prefix("```") {
919 let indent = &line[..line.len() - trimmed.len()];
922 let after_lang = rest.find(',').map(|i| &rest[i..]).unwrap_or("");
923 out.push_str(indent);
924 out.push_str("```");
925 out.push_str(lang_replacement);
926 out.push_str(after_lang);
927 out.push('\n');
928 } else {
929 out.push_str(line);
930 out.push('\n');
931 }
932 }
933 out.trim_end_matches('\n').to_string()
934}
935
936pub fn render_jsdoc_sections(sections: &RustdocSections) -> String {
949 let mut out = String::new();
950 if !sections.summary.is_empty() {
951 out.push_str(§ions.summary);
952 }
953 if let Some(args) = sections.arguments.as_deref() {
954 for (name, desc) in parse_arguments_bullets(args) {
955 if !out.is_empty() {
956 out.push('\n');
957 }
958 if desc.is_empty() {
959 out.push_str(&crate::template_env::render(
960 "doc_jsdoc_param.jinja",
961 minijinja::context! { name => &name },
962 ));
963 } else {
964 out.push_str(&crate::template_env::render(
965 "doc_jsdoc_param_desc.jinja",
966 minijinja::context! { name => &name, desc => &desc },
967 ));
968 }
969 }
970 }
971 if let Some(ret) = sections.returns.as_deref() {
972 if !out.is_empty() {
973 out.push('\n');
974 }
975 out.push_str(&crate::template_env::render(
976 "doc_jsdoc_returns.jinja",
977 minijinja::context! { content => ret.trim() },
978 ));
979 }
980 if let Some(err) = sections.errors.as_deref() {
981 if !out.is_empty() {
982 out.push('\n');
983 }
984 out.push_str(&crate::template_env::render(
985 "doc_jsdoc_throws.jinja",
986 minijinja::context! { content => err.trim() },
987 ));
988 }
989 if let Some(example) = sections.example.as_deref() {
990 if let Some(body) = example_for_target(example, "typescript") {
991 if !out.is_empty() {
992 out.push('\n');
993 }
994 out.push_str("@example\n");
995 out.push_str(&body);
996 }
997 }
998 out
999}
1000
1001pub fn render_javadoc_sections(sections: &RustdocSections, throws_class: &str) -> String {
1011 let mut out = String::new();
1012 if !sections.summary.is_empty() {
1013 out.push_str(§ions.summary);
1014 }
1015 if let Some(args) = sections.arguments.as_deref() {
1016 for (name, desc) in parse_arguments_bullets(args) {
1017 if !out.is_empty() {
1018 out.push('\n');
1019 }
1020 if desc.is_empty() {
1021 out.push_str(&crate::template_env::render(
1022 "doc_javadoc_param.jinja",
1023 minijinja::context! { name => &name },
1024 ));
1025 } else {
1026 out.push_str(&crate::template_env::render(
1027 "doc_javadoc_param_desc.jinja",
1028 minijinja::context! { name => &name, desc => &desc },
1029 ));
1030 }
1031 }
1032 }
1033 if let Some(ret) = sections.returns.as_deref() {
1034 if !out.is_empty() {
1035 out.push('\n');
1036 }
1037 out.push_str(&crate::template_env::render(
1038 "doc_javadoc_return.jinja",
1039 minijinja::context! { content => ret.trim() },
1040 ));
1041 }
1042 if let Some(err) = sections.errors.as_deref() {
1043 if !out.is_empty() {
1044 out.push('\n');
1045 }
1046 out.push_str(&crate::template_env::render(
1047 "doc_javadoc_throws.jinja",
1048 minijinja::context! { throws_class => throws_class, content => err.trim() },
1049 ));
1050 }
1051 out
1052}
1053
1054pub fn render_csharp_xml_sections(sections: &RustdocSections, exception_class: &str) -> String {
1063 let mut out = String::new();
1064 out.push_str("<summary>\n");
1065 let summary = if sections.summary.is_empty() {
1066 ""
1067 } else {
1068 sections.summary.as_str()
1069 };
1070 for line in summary.lines() {
1071 out.push_str(line);
1072 out.push('\n');
1073 }
1074 out.push_str("</summary>");
1075 if let Some(args) = sections.arguments.as_deref() {
1076 for (name, desc) in parse_arguments_bullets(args) {
1077 out.push('\n');
1078 if desc.is_empty() {
1079 out.push_str(&crate::template_env::render(
1080 "doc_csharp_param.jinja",
1081 minijinja::context! { name => &name },
1082 ));
1083 } else {
1084 out.push_str(&crate::template_env::render(
1085 "doc_csharp_param_desc.jinja",
1086 minijinja::context! { name => &name, desc => &desc },
1087 ));
1088 }
1089 }
1090 }
1091 if let Some(ret) = sections.returns.as_deref() {
1092 out.push('\n');
1093 out.push_str(&crate::template_env::render(
1094 "doc_csharp_returns.jinja",
1095 minijinja::context! { content => ret.trim() },
1096 ));
1097 }
1098 if let Some(err) = sections.errors.as_deref() {
1099 out.push('\n');
1100 out.push_str(&crate::template_env::render(
1101 "doc_csharp_exception.jinja",
1102 minijinja::context! {
1103 exception_class => exception_class,
1104 content => err.trim(),
1105 },
1106 ));
1107 }
1108 if let Some(example) = sections.example.as_deref() {
1109 out.push('\n');
1110 out.push_str("<example><code language=\"csharp\">\n");
1111 for line in example.lines() {
1113 let t = line.trim_start();
1114 if t.starts_with("```") {
1115 continue;
1116 }
1117 out.push_str(line);
1118 out.push('\n');
1119 }
1120 out.push_str("</code></example>");
1121 }
1122 out
1123}
1124
1125pub fn render_phpdoc_sections(sections: &RustdocSections, throws_class: &str) -> String {
1132 let mut out = String::new();
1133 if !sections.summary.is_empty() {
1134 out.push_str(§ions.summary);
1135 }
1136 if let Some(args) = sections.arguments.as_deref() {
1137 for (name, desc) in parse_arguments_bullets(args) {
1138 if !out.is_empty() {
1139 out.push('\n');
1140 }
1141 if desc.is_empty() {
1142 out.push_str(&crate::template_env::render(
1143 "doc_phpdoc_param.jinja",
1144 minijinja::context! { name => &name },
1145 ));
1146 } else {
1147 out.push_str(&crate::template_env::render(
1148 "doc_phpdoc_param_desc.jinja",
1149 minijinja::context! { name => &name, desc => &desc },
1150 ));
1151 }
1152 }
1153 }
1154 if let Some(ret) = sections.returns.as_deref() {
1155 if !out.is_empty() {
1156 out.push('\n');
1157 }
1158 out.push_str(&crate::template_env::render(
1159 "doc_phpdoc_return.jinja",
1160 minijinja::context! { content => ret.trim() },
1161 ));
1162 }
1163 if let Some(err) = sections.errors.as_deref() {
1164 if !out.is_empty() {
1165 out.push('\n');
1166 }
1167 out.push_str(&crate::template_env::render(
1168 "doc_phpdoc_throws.jinja",
1169 minijinja::context! { throws_class => throws_class, content => err.trim() },
1170 ));
1171 }
1172 if let Some(example) = sections.example.as_deref() {
1173 if let Some(body) = example_for_target(example, "php") {
1174 if !out.is_empty() {
1175 out.push('\n');
1176 }
1177 out.push_str(&body);
1178 }
1179 }
1180 out
1181}
1182
1183pub fn render_doxygen_sections(sections: &RustdocSections) -> String {
1190 let mut out = String::new();
1191 if !sections.summary.is_empty() {
1192 out.push_str(§ions.summary);
1193 }
1194 if let Some(args) = sections.arguments.as_deref() {
1195 for (name, desc) in parse_arguments_bullets(args) {
1196 if !out.is_empty() {
1197 out.push('\n');
1198 }
1199 if desc.is_empty() {
1200 out.push_str(&crate::template_env::render(
1201 "doc_doxygen_param.jinja",
1202 minijinja::context! { name => &name },
1203 ));
1204 } else {
1205 out.push_str(&crate::template_env::render(
1206 "doc_doxygen_param_desc.jinja",
1207 minijinja::context! { name => &name, desc => &desc },
1208 ));
1209 }
1210 }
1211 }
1212 if let Some(ret) = sections.returns.as_deref() {
1213 if !out.is_empty() {
1214 out.push('\n');
1215 }
1216 out.push_str(&crate::template_env::render(
1217 "doc_doxygen_return.jinja",
1218 minijinja::context! { content => ret.trim() },
1219 ));
1220 }
1221 if let Some(err) = sections.errors.as_deref() {
1222 if !out.is_empty() {
1223 out.push('\n');
1224 }
1225 out.push_str(&crate::template_env::render(
1226 "doc_doxygen_errors.jinja",
1227 minijinja::context! { content => err.trim() },
1228 ));
1229 }
1230 if let Some(example) = sections.example.as_deref() {
1231 if !out.is_empty() {
1232 out.push('\n');
1233 }
1234 out.push_str("\\code\n");
1235 for line in example.lines() {
1236 let t = line.trim_start();
1237 if t.starts_with("```") {
1238 continue;
1239 }
1240 out.push_str(line);
1241 out.push('\n');
1242 }
1243 out.push_str("\\endcode");
1244 }
1245 out
1246}
1247
1248pub fn doc_first_paragraph_joined(doc: &str) -> String {
1261 doc.lines()
1262 .take_while(|l| !l.trim().is_empty())
1263 .map(str::trim)
1264 .collect::<Vec<_>>()
1265 .join(" ")
1266}
1267
1268#[derive(Copy, Clone, Debug, PartialEq, Eq)]
1273pub enum DocTarget {
1274 PhpDoc,
1276 JavaDoc,
1278 TsDoc,
1280 JsDoc,
1282 CSharpDoc,
1289}
1290
1291pub fn sanitize_rust_idioms(text: &str, target: DocTarget) -> String {
1318 sanitize_rust_idioms_inner(text, target, true)
1324}
1325
1326pub fn sanitize_rust_idioms_keep_sections(text: &str, target: DocTarget) -> String {
1332 sanitize_rust_idioms_inner(text, target, false)
1333}
1334
1335fn sanitize_rust_idioms_inner(text: &str, target: DocTarget, drop_csharp_sections: bool) -> String {
1336 let mut out = String::with_capacity(text.len());
1337 let mut in_rust_fence = false;
1338 let mut in_other_fence = false;
1339 let mut csharp_section_dropped = false;
1346
1347 for line in text.lines() {
1348 if csharp_section_dropped {
1349 continue;
1350 }
1351 let trimmed = line.trim_start();
1352 if drop_csharp_sections
1353 && matches!(target, DocTarget::CSharpDoc)
1354 && !in_rust_fence
1355 && !in_other_fence
1356 && is_rustdoc_section_heading(trimmed)
1357 {
1358 csharp_section_dropped = true;
1359 continue;
1360 }
1361
1362 if let Some(rest) = trimmed.strip_prefix("```") {
1364 if in_rust_fence {
1365 in_rust_fence = false;
1367 match target {
1368 DocTarget::TsDoc
1369 | DocTarget::JsDoc
1370 | DocTarget::CSharpDoc
1371 | DocTarget::PhpDoc
1372 | DocTarget::JavaDoc => {
1373 }
1375 }
1376 continue;
1377 }
1378 if in_other_fence {
1379 in_other_fence = false;
1381 out.push_str(line);
1382 out.push('\n');
1383 continue;
1384 }
1385 let lang = rest.split(',').next().unwrap_or("").trim();
1387 let is_rust = is_rust_fence_tag(lang);
1388 if is_rust {
1389 in_rust_fence = true;
1390 match target {
1391 DocTarget::TsDoc
1392 | DocTarget::JsDoc
1393 | DocTarget::CSharpDoc
1394 | DocTarget::PhpDoc
1395 | DocTarget::JavaDoc => {
1396 }
1399 }
1400 continue;
1401 }
1402 in_other_fence = true;
1404 out.push_str(line);
1405 out.push('\n');
1406 continue;
1407 }
1408
1409 if in_rust_fence {
1411 match target {
1412 DocTarget::TsDoc | DocTarget::JsDoc | DocTarget::CSharpDoc | DocTarget::PhpDoc | DocTarget::JavaDoc => {
1413 }
1415 }
1416 continue;
1417 }
1418
1419 if in_other_fence {
1421 out.push_str(line);
1422 out.push('\n');
1423 continue;
1424 }
1425
1426 let stripped_indent = line.trim_start();
1428 if stripped_indent.starts_with("#[") && stripped_indent.ends_with(']') {
1429 continue;
1431 }
1432
1433 let sanitized = apply_prose_transforms(line, target);
1435 out.push_str(&sanitized);
1436 out.push('\n');
1437 }
1438
1439 if out.ends_with('\n') && !text.ends_with('\n') {
1441 out.pop();
1442 }
1443
1444 if matches!(target, DocTarget::CSharpDoc) {
1451 out = xml_escape_for_csharp(&out);
1452 }
1453
1454 out
1455}
1456
1457fn is_rustdoc_section_heading(trimmed: &str) -> bool {
1461 let Some(rest) = trimmed.strip_prefix("# ") else {
1462 return false;
1463 };
1464 let head = rest.trim().to_ascii_lowercase();
1465 matches!(
1466 head.as_str(),
1467 "arguments" | "args" | "returns" | "errors" | "panics" | "safety" | "example" | "examples"
1468 )
1469}
1470
1471fn xml_escape_for_csharp(s: &str) -> String {
1478 let mut out = String::with_capacity(s.len());
1479 for ch in s.chars() {
1480 match ch {
1481 '&' => out.push_str("&"),
1482 '<' => out.push_str("<"),
1483 '>' => out.push_str(">"),
1484 _ => out.push(ch),
1485 }
1486 }
1487 out
1488}
1489
1490fn apply_prose_transforms(line: &str, target: DocTarget) -> String {
1503 let line = replace_intradoc_links(line, target);
1505
1506 let line = replace_path_separator(&line);
1509
1510 let line = strip_unwrap_expect(&line);
1513
1514 let segments = tokenize_backtick_spans(&line);
1516 let mut result = String::with_capacity(line.len());
1517 for (is_code, span) in segments {
1518 if is_code {
1519 result.push('`');
1520 result.push_str(span);
1521 result.push('`');
1522 } else {
1523 result.push_str(&transform_prose_segment(span, target));
1524 }
1525 }
1526 result
1527}
1528
1529fn tokenize_backtick_spans(line: &str) -> Vec<(bool, &str)> {
1535 let mut segments = Vec::new();
1536 let bytes = line.as_bytes();
1537 let mut start = 0;
1538 let mut i = 0;
1539
1540 while i < bytes.len() {
1541 if bytes[i] == b'`' {
1542 if i > start {
1544 segments.push((false, &line[start..i]));
1545 }
1546 let code_start = i + 1;
1548 let close = bytes[code_start..].iter().position(|&b| b == b'`');
1549 if let Some(offset) = close {
1550 let code_end = code_start + offset;
1551 segments.push((true, &line[code_start..code_end]));
1552 i = code_end + 1;
1553 start = i;
1554 } else {
1555 segments.push((false, &line[i..]));
1557 start = line.len();
1558 i = line.len();
1559 }
1560 } else {
1561 i += 1;
1562 }
1563 }
1564 if start < line.len() {
1565 segments.push((false, &line[start..]));
1566 }
1567 segments
1568}
1569
1570fn transform_prose_segment(text: &str, target: DocTarget) -> String {
1575 let mut s = text.to_string();
1576
1577 s = strip_inline_attributes(&s);
1579
1580 s = s.replace("pub fn ", "");
1582 s = s.replace("crate::", "");
1583 s = s.replace("&mut self", "");
1584 s = s.replace("&self", "");
1585
1586 s = strip_lifetime_and_bounds(&s);
1588
1589 s = replace_type_wrappers(&s, target);
1591
1592 s = replace_some_calls(&s);
1594
1595 s = replace_some_keyword_in_prose(&s);
1597
1598 s = replace_none_keyword(&s, target);
1600
1601 s
1606}
1607
1608#[inline]
1615fn advance_char(s: &str, out: &mut String, i: usize) -> usize {
1616 let ch = s[i..].chars().next().expect("valid UTF-8 position");
1620 out.push(ch);
1621 i + ch.len_utf8()
1622}
1623
1624fn replace_intradoc_links(s: &str, _target: DocTarget) -> String {
1627 let mut out = String::with_capacity(s.len());
1628 let bytes = s.as_bytes();
1629 let mut i = 0;
1630 while i < bytes.len() {
1631 if i + 1 < bytes.len() && bytes[i] == b'[' && bytes[i + 1] == b'`' {
1633 let search_start = i + 2;
1635 let mut found = false;
1636 let mut j = search_start;
1637 while j + 1 < bytes.len() {
1638 if bytes[j] == b'`' && bytes[j + 1] == b']' {
1639 let inner = &s[search_start..j];
1640 let converted = inner.replace("::", ".");
1642 out.push('`');
1643 out.push_str(&converted);
1644 out.push('`');
1645 i = j + 2;
1646 found = true;
1647 break;
1648 }
1649 j += 1;
1650 }
1651 if !found {
1652 i = advance_char(s, &mut out, i);
1653 }
1654 } else {
1655 i = advance_char(s, &mut out, i);
1656 }
1657 }
1658 out
1659}
1660
1661fn strip_inline_attributes(s: &str) -> String {
1664 let mut out = String::with_capacity(s.len());
1665 let bytes = s.as_bytes();
1666 let mut i = 0;
1667 while i < bytes.len() {
1668 if bytes[i] == b'#' && i + 1 < bytes.len() && bytes[i + 1] == b'[' {
1669 let mut depth = 0usize;
1671 let mut j = i + 1;
1672 while j < bytes.len() {
1673 if bytes[j] == b'[' {
1674 depth += 1;
1675 } else if bytes[j] == b']' {
1676 depth -= 1;
1677 if depth == 0 {
1678 i = j + 1;
1679 break;
1680 }
1681 }
1682 j += 1;
1683 }
1684 if depth != 0 {
1685 i = advance_char(s, &mut out, i);
1687 }
1688 } else {
1689 i = advance_char(s, &mut out, i);
1690 }
1691 }
1692 out
1693}
1694
1695fn strip_lifetime_and_bounds(s: &str) -> String {
1697 let mut out = s.to_string();
1699 out = regex_replace_all(&out, r"Send\s*\+\s*Sync", "");
1701 out = regex_replace_all(&out, r"Sync\s*\+\s*Send", "");
1702 out = regex_replace_word_boundary(&out, "Send", "");
1704 out = regex_replace_word_boundary(&out, "Sync", "");
1705 out = regex_replace_all(&out, r"'\s*static\b", "");
1707 out
1708}
1709
1710fn regex_replace_all(s: &str, pattern: &str, replacement: &str) -> String {
1716 match pattern {
1718 r"Send\s*\+\s*Sync" => replace_with_optional_spaces(s, "Send", "+", "Sync", replacement),
1719 r"Sync\s*\+\s*Send" => replace_with_optional_spaces(s, "Sync", "+", "Send", replacement),
1720 r"'\s*static\b" => replace_static_lifetime(s, replacement),
1721 _ => s.replace(pattern, replacement),
1722 }
1723}
1724
1725fn regex_replace_word_boundary(s: &str, keyword: &str, replacement: &str) -> String {
1727 let mut out = String::with_capacity(s.len());
1728 let klen = keyword.len();
1729 let bytes = s.as_bytes();
1730 let kbytes = keyword.as_bytes();
1731 if klen == 0 || klen > bytes.len() {
1732 return s.to_string();
1733 }
1734 let mut i = 0;
1735 while i + klen <= bytes.len() {
1736 if &bytes[i..i + klen] == kbytes {
1737 let before_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric() && bytes[i - 1] != b'_';
1738 let after_ok =
1739 i + klen >= bytes.len() || !bytes[i + klen].is_ascii_alphanumeric() && bytes[i + klen] != b'_';
1740 if before_ok && after_ok {
1741 out.push_str(replacement);
1742 i += klen;
1743 continue;
1744 }
1745 }
1746 i = advance_char(s, &mut out, i);
1747 }
1748 if i < bytes.len() {
1749 out.push_str(&s[i..]);
1750 }
1751 out
1752}
1753
1754fn replace_with_optional_spaces(s: &str, a: &str, op: &str, b: &str, replacement: &str) -> String {
1756 let mut out = String::with_capacity(s.len());
1757 let mut i = 0;
1758 let chars: Vec<char> = s.chars().collect();
1759 let total = chars.len();
1760
1761 while i < total {
1762 let a_chars: Vec<char> = a.chars().collect();
1764 let b_chars: Vec<char> = b.chars().collect();
1765 let op_chars: Vec<char> = op.chars().collect();
1766
1767 if chars[i..].starts_with(&a_chars) {
1768 let mut j = i + a_chars.len();
1769 while j < total && chars[j] == ' ' {
1771 j += 1;
1772 }
1773 if j + op_chars.len() <= total && chars[j..].starts_with(&op_chars) {
1775 let mut k = j + op_chars.len();
1776 while k < total && chars[k] == ' ' {
1778 k += 1;
1779 }
1780 if k + b_chars.len() <= total && chars[k..].starts_with(&b_chars) {
1782 out.push_str(replacement);
1783 i = k + b_chars.len();
1784 continue;
1785 }
1786 }
1787 }
1788 out.push(chars[i]);
1789 i += 1;
1790 }
1791 out
1792}
1793
1794fn replace_static_lifetime(s: &str, replacement: &str) -> String {
1796 let mut out = String::with_capacity(s.len());
1797 let bytes = s.as_bytes();
1798 let mut i = 0;
1799 while i < bytes.len() {
1800 if bytes[i] == b'\'' {
1801 let mut j = i + 1;
1803 while j < bytes.len() && bytes[j] == b' ' {
1804 j += 1;
1805 }
1806 let keyword = b"static";
1807 if bytes[j..].starts_with(keyword) {
1808 let end = j + keyword.len();
1809 let after_ok = end >= bytes.len() || !bytes[end].is_ascii_alphanumeric() && bytes[end] != b'_';
1811 if after_ok {
1812 out.push_str(replacement);
1813 i = end;
1814 continue;
1815 }
1816 }
1817 }
1818 i = advance_char(s, &mut out, i);
1819 }
1820 out
1821}
1822
1823fn replace_type_wrappers(s: &str, target: DocTarget) -> String {
1825 let mut out = s.to_string();
1827
1828 let vec_u8_replacement = match target {
1830 DocTarget::PhpDoc => "string",
1831 DocTarget::JavaDoc => "byte[]",
1832 DocTarget::TsDoc | DocTarget::JsDoc => "Uint8Array",
1833 DocTarget::CSharpDoc => "byte[]",
1834 };
1835 out = replace_generic1(&out, "Vec", "u8", vec_u8_replacement);
1836
1837 let map_replacement_fn = |k: &str, v: &str| match target {
1839 DocTarget::PhpDoc => format!("array<{k}, {v}>"),
1840 DocTarget::JavaDoc => format!("Map<{k}, {v}>"),
1841 DocTarget::TsDoc | DocTarget::JsDoc => format!("Record<{k}, {v}>"),
1842 DocTarget::CSharpDoc => format!("Dictionary<{k}, {v}>"),
1843 };
1844 out = replace_generic2(&out, "HashMap", &map_replacement_fn);
1845
1846 out = replace_generic1_passthrough(&out, "Vec", |inner| format!("{inner}[]"));
1848
1849 let option_replacement_fn = |inner: &str| match target {
1851 DocTarget::PhpDoc => format!("{inner}?"),
1852 DocTarget::JavaDoc => format!("{inner} | null"),
1853 DocTarget::TsDoc | DocTarget::JsDoc => format!("{inner} | undefined"),
1854 DocTarget::CSharpDoc => format!("{inner}?"),
1855 };
1856 out = replace_generic1_passthrough(&out, "Option", option_replacement_fn);
1857
1858 if matches!(target, DocTarget::CSharpDoc) {
1863 out = replace_generic2(&out, "Result", &|t: &str, _e: &str| t.to_string());
1864 }
1865
1866 for wrapper in &["Arc", "Box", "Mutex", "RwLock", "Rc", "Cell", "RefCell"] {
1868 out = replace_generic1_passthrough(&out, wrapper, |inner| inner.to_string());
1869 }
1870
1871 out
1872}
1873
1874fn replace_generic1(s: &str, name: &str, arg: &str, replacement: &str) -> String {
1876 let pattern = format!("{name}<{arg}>");
1877 s.replace(&pattern, replacement)
1878}
1879
1880fn replace_generic1_passthrough<F>(s: &str, name: &str, f: F) -> String
1884where
1885 F: Fn(&str) -> String,
1886{
1887 let mut out = String::with_capacity(s.len());
1888 let mut i = 0;
1889 let prefix = format!("{name}<");
1890 let pbytes = prefix.as_bytes();
1891 let bytes = s.as_bytes();
1892
1893 while i < bytes.len() {
1894 if bytes[i..].starts_with(pbytes) {
1895 let before_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric() && bytes[i - 1] != b'_';
1897 if before_ok {
1898 let inner_start = i + pbytes.len();
1899 let mut depth = 1usize;
1901 let mut j = inner_start;
1902 while j < bytes.len() {
1903 match bytes[j] {
1904 b'<' => depth += 1,
1905 b'>' => {
1906 depth -= 1;
1907 if depth == 0 {
1908 break;
1909 }
1910 }
1911 _ => {}
1912 }
1913 j += 1;
1914 }
1915 if depth == 0 && j < bytes.len() {
1916 let inner = &s[inner_start..j];
1917 out.push_str(&f(inner));
1918 i = j + 1;
1919 continue;
1920 }
1921 }
1922 }
1923 i = advance_char(s, &mut out, i);
1924 }
1925 out
1926}
1927
1928fn replace_generic2<F>(s: &str, name: &str, f: &F) -> String
1930where
1931 F: Fn(&str, &str) -> String,
1932{
1933 let mut out = String::with_capacity(s.len());
1934 let mut i = 0;
1935 let prefix = format!("{name}<");
1936 let pbytes = prefix.as_bytes();
1937 let bytes = s.as_bytes();
1938
1939 while i < bytes.len() {
1940 if bytes[i..].starts_with(pbytes) {
1941 let before_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric() && bytes[i - 1] != b'_';
1942 if before_ok {
1943 let inner_start = i + pbytes.len();
1944 let mut depth = 1usize;
1946 let mut j = inner_start;
1947 while j < bytes.len() {
1948 match bytes[j] {
1949 b'<' => depth += 1,
1950 b'>' => {
1951 depth -= 1;
1952 if depth == 0 {
1953 break;
1954 }
1955 }
1956 _ => {}
1957 }
1958 j += 1;
1959 }
1960 if depth == 0 && j < bytes.len() {
1961 let inner = &s[inner_start..j];
1962 let split = split_on_comma_at_top_level(inner);
1964 if let Some((k, v)) = split {
1965 out.push_str(&f(k.trim(), v.trim()));
1966 i = j + 1;
1967 continue;
1968 }
1969 }
1970 }
1971 }
1972 i = advance_char(s, &mut out, i);
1973 }
1974 out
1975}
1976
1977fn split_on_comma_at_top_level(s: &str) -> Option<(&str, &str)> {
1979 let mut depth = 0i32;
1980 for (idx, ch) in s.char_indices() {
1981 match ch {
1982 '<' => depth += 1,
1983 '>' => depth -= 1,
1984 ',' if depth == 0 => return Some((&s[..idx], &s[idx + 1..])),
1985 _ => {}
1986 }
1987 }
1988 None
1989}
1990
1991fn replace_some_calls(s: &str) -> String {
1993 let mut out = String::with_capacity(s.len());
1994 let bytes = s.as_bytes();
1995 let prefix = b"Some(";
1996 let mut i = 0;
1997
1998 while i < bytes.len() {
1999 if bytes[i..].starts_with(prefix) {
2000 let before_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric() && bytes[i - 1] != b'_';
2001 if before_ok {
2002 let arg_start = i + prefix.len();
2003 let mut depth = 1usize;
2005 let mut j = arg_start;
2006 while j < bytes.len() {
2007 match bytes[j] {
2008 b'(' => depth += 1,
2009 b')' => {
2010 depth -= 1;
2011 if depth == 0 {
2012 break;
2013 }
2014 }
2015 _ => {}
2016 }
2017 j += 1;
2018 }
2019 if depth == 0 && j < bytes.len() {
2020 let arg = &s[arg_start..j];
2021 out.push_str("the value (");
2022 out.push_str(arg);
2023 out.push(')');
2024 i = j + 1;
2025 continue;
2026 }
2027 }
2028 }
2029 i = advance_char(s, &mut out, i);
2030 }
2031 out
2032}
2033
2034fn replace_some_keyword_in_prose(s: &str) -> String {
2043 let keyword = b"Some ";
2044 let klen = keyword.len();
2045 let bytes = s.as_bytes();
2046 if klen >= bytes.len() {
2047 return s.to_string();
2048 }
2049 let mut out = String::with_capacity(s.len());
2050 let mut i = 0;
2051 while i + klen < bytes.len() {
2052 if &bytes[i..i + klen] == keyword {
2053 let before_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric() && bytes[i - 1] != b'_';
2054 let after_ok = bytes[i + klen].is_ascii_lowercase();
2055 if before_ok && after_ok {
2056 i += klen;
2057 continue;
2058 }
2059 }
2060 i = advance_char(s, &mut out, i);
2061 }
2062 if i < bytes.len() {
2063 out.push_str(&s[i..]);
2064 }
2065 out
2066}
2067
2068fn replace_none_keyword(s: &str, target: DocTarget) -> String {
2070 let replacement = match target {
2071 DocTarget::PhpDoc | DocTarget::JavaDoc | DocTarget::CSharpDoc => "null",
2072 DocTarget::TsDoc | DocTarget::JsDoc => "undefined",
2073 };
2074 let keyword = b"None";
2075 let klen = keyword.len();
2076 let mut out = String::with_capacity(s.len());
2077 let bytes = s.as_bytes();
2078 if klen > bytes.len() {
2079 return s.to_string();
2080 }
2081 let mut i = 0;
2082
2083 while i + klen <= bytes.len() {
2084 if &bytes[i..i + klen] == keyword {
2085 let before_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric() && bytes[i - 1] != b'_';
2086 let after_ok =
2087 i + klen >= bytes.len() || !bytes[i + klen].is_ascii_alphanumeric() && bytes[i + klen] != b'_';
2088 if before_ok && after_ok {
2089 out.push_str(replacement);
2090 i += klen;
2091 continue;
2092 }
2093 }
2094 i = advance_char(s, &mut out, i);
2095 }
2096 if i < bytes.len() {
2097 out.push_str(&s[i..]);
2098 }
2099 out
2100}
2101
2102fn replace_path_separator(s: &str) -> String {
2104 let mut out = String::with_capacity(s.len());
2105 let bytes = s.as_bytes();
2106 let mut i = 0;
2107
2108 while i < bytes.len() {
2109 if i + 1 < bytes.len() && bytes[i] == b':' && bytes[i + 1] == b':' {
2110 let before_ok = i > 0 && (bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_');
2112 let after_ok = i + 2 < bytes.len() && (bytes[i + 2].is_ascii_alphanumeric() || bytes[i + 2] == b'_');
2113 if before_ok || after_ok {
2114 out.push('.');
2115 i += 2;
2116 continue;
2117 }
2118 }
2119 i = advance_char(s, &mut out, i);
2120 }
2121 out
2122}
2123
2124fn strip_unwrap_expect(s: &str) -> String {
2126 let mut out = String::with_capacity(s.len());
2127 let bytes = s.as_bytes();
2128 let mut i = 0;
2129
2130 while i < bytes.len() {
2131 if bytes[i..].starts_with(b".unwrap()") {
2133 i += b".unwrap()".len();
2134 continue;
2135 }
2136 if bytes[i..].starts_with(b".expect(") {
2138 let arg_start = i + b".expect(".len();
2139 let mut depth = 1usize;
2140 let mut j = arg_start;
2141 while j < bytes.len() {
2142 match bytes[j] {
2143 b'(' => depth += 1,
2144 b')' => {
2145 depth -= 1;
2146 if depth == 0 {
2147 break;
2148 }
2149 }
2150 _ => {}
2151 }
2152 j += 1;
2153 }
2154 if depth == 0 {
2155 i = j + 1;
2156 continue;
2157 }
2158 }
2159 i = advance_char(s, &mut out, i);
2160 }
2161 out
2162}
2163
2164#[cfg(test)]
2165mod tests {
2166 use super::*;
2167
2168 #[test]
2169 fn test_emit_phpdoc() {
2170 let mut out = String::new();
2171 emit_phpdoc(&mut out, "Simple documentation", " ", "TestException");
2172 assert!(out.contains("/**"));
2173 assert!(out.contains("Simple documentation"));
2174 assert!(out.contains("*/"));
2175 }
2176
2177 #[test]
2178 fn test_phpdoc_escaping() {
2179 let mut out = String::new();
2180 emit_phpdoc(&mut out, "Handle */ sequences", "", "TestException");
2181 assert!(out.contains("Handle * / sequences"));
2182 }
2183
2184 #[test]
2185 fn test_emit_csharp_doc() {
2186 let mut out = String::new();
2187 emit_csharp_doc(&mut out, "C# documentation", " ", "TestException");
2188 assert!(out.contains("<summary>"));
2189 assert!(out.contains("C# documentation"));
2190 assert!(out.contains("</summary>"));
2191 }
2192
2193 #[test]
2194 fn test_csharp_xml_escaping() {
2195 let mut out = String::new();
2196 emit_csharp_doc(&mut out, "foo < bar & baz > qux", "", "TestException");
2197 assert!(out.contains("foo < bar & baz > qux"));
2198 }
2199
2200 #[test]
2201 fn test_emit_elixir_doc() {
2202 let mut out = String::new();
2203 emit_elixir_doc(&mut out, "Elixir documentation");
2204 assert!(out.contains("@doc \"\"\""));
2205 assert!(out.contains("Elixir documentation"));
2206 assert!(out.contains("\"\"\""));
2207 }
2208
2209 #[test]
2210 fn test_elixir_heredoc_escaping() {
2211 let mut out = String::new();
2212 emit_elixir_doc(&mut out, "Handle \"\"\" sequences");
2213 assert!(out.contains("Handle \"\" \" sequences"));
2214 }
2215
2216 #[test]
2217 fn test_emit_roxygen() {
2218 let mut out = String::new();
2219 emit_roxygen(&mut out, "R documentation");
2220 assert!(out.contains("#' R documentation"));
2221 }
2222
2223 #[test]
2224 fn test_emit_swift_doc() {
2225 let mut out = String::new();
2226 emit_swift_doc(&mut out, "Swift documentation", " ");
2227 assert!(out.contains("/// Swift documentation"));
2228 }
2229
2230 #[test]
2231 fn test_emit_javadoc() {
2232 let mut out = String::new();
2233 emit_javadoc(&mut out, "Java documentation", " ");
2234 assert!(out.contains("/**"));
2235 assert!(out.contains("Java documentation"));
2236 assert!(out.contains("*/"));
2237 }
2238
2239 #[test]
2240 fn test_emit_kdoc() {
2241 let mut out = String::new();
2242 emit_kdoc(&mut out, "Kotlin documentation", " ");
2243 assert!(out.contains("/**"));
2244 assert!(out.contains("Kotlin documentation"));
2245 assert!(out.contains("*/"));
2246 }
2247
2248 #[test]
2249 fn test_emit_dartdoc() {
2250 let mut out = String::new();
2251 emit_dartdoc(&mut out, "Dart documentation", " ");
2252 assert!(out.contains("/// Dart documentation"));
2253 }
2254
2255 #[test]
2256 fn test_emit_gleam_doc() {
2257 let mut out = String::new();
2258 emit_gleam_doc(&mut out, "Gleam documentation", " ");
2259 assert!(out.contains("/// Gleam documentation"));
2260 }
2261
2262 #[test]
2263 fn test_emit_zig_doc() {
2264 let mut out = String::new();
2265 emit_zig_doc(&mut out, "Zig documentation", " ");
2266 assert!(out.contains("/// Zig documentation"));
2267 }
2268
2269 #[test]
2270 fn test_empty_doc_skipped() {
2271 let mut out = String::new();
2272 emit_phpdoc(&mut out, "", "", "TestException");
2273 emit_csharp_doc(&mut out, "", "", "TestException");
2274 emit_elixir_doc(&mut out, "");
2275 emit_roxygen(&mut out, "");
2276 emit_kdoc(&mut out, "", "");
2277 emit_dartdoc(&mut out, "", "");
2278 emit_gleam_doc(&mut out, "", "");
2279 emit_zig_doc(&mut out, "", "");
2280 assert!(out.is_empty());
2281 }
2282
2283 #[test]
2284 fn test_doc_first_paragraph_joined_single_line() {
2285 assert_eq!(doc_first_paragraph_joined("Simple doc."), "Simple doc.");
2286 }
2287
2288 #[test]
2289 fn test_doc_first_paragraph_joined_wrapped_sentence() {
2290 let doc = "Convert HTML to Markdown,\nreturning a result.";
2292 assert_eq!(
2293 doc_first_paragraph_joined(doc),
2294 "Convert HTML to Markdown, returning a result."
2295 );
2296 }
2297
2298 #[test]
2299 fn test_doc_first_paragraph_joined_stops_at_blank_line() {
2300 let doc = "First paragraph.\nStill first.\n\nSecond paragraph.";
2301 assert_eq!(doc_first_paragraph_joined(doc), "First paragraph. Still first.");
2302 }
2303
2304 #[test]
2305 fn test_doc_first_paragraph_joined_empty() {
2306 assert_eq!(doc_first_paragraph_joined(""), "");
2307 }
2308
2309 #[test]
2310 fn test_parse_rustdoc_sections_basic() {
2311 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.";
2312 let sections = parse_rustdoc_sections(doc);
2313 assert_eq!(sections.summary, "Extracts text from a file.");
2314 assert_eq!(sections.arguments.as_deref(), Some("* `path` - The file path."));
2315 assert_eq!(sections.returns.as_deref(), Some("The extracted text."));
2316 assert_eq!(sections.errors.as_deref(), Some("Returns `KreuzbergError` on failure."));
2317 assert!(sections.panics.is_none());
2318 }
2319
2320 #[test]
2321 fn test_parse_rustdoc_sections_example_with_fence() {
2322 let doc = "Run the thing.\n\n# Example\n\n```rust\nlet x = run();\n```";
2323 let sections = parse_rustdoc_sections(doc);
2324 assert_eq!(sections.summary, "Run the thing.");
2325 assert!(sections.example.as_ref().unwrap().contains("```rust"));
2326 assert!(sections.example.as_ref().unwrap().contains("let x = run();"));
2327 }
2328
2329 #[test]
2330 fn test_parse_rustdoc_sections_pound_inside_fence_is_not_a_heading() {
2331 let doc = "Summary.\n\n# Example\n\n```bash\n# install deps\nrun --foo\n```";
2335 let sections = parse_rustdoc_sections(doc);
2336 assert_eq!(sections.summary, "Summary.");
2337 assert!(sections.example.as_ref().unwrap().contains("# install deps"));
2338 }
2339
2340 #[test]
2341 fn test_parse_arguments_bullets_dash_separator() {
2342 let body = "* `path` - The file path.\n* `config` - Optional configuration.";
2343 let pairs = parse_arguments_bullets(body);
2344 assert_eq!(pairs.len(), 2);
2345 assert_eq!(pairs[0], ("path".to_string(), "The file path.".to_string()));
2346 assert_eq!(pairs[1], ("config".to_string(), "Optional configuration.".to_string()));
2347 }
2348
2349 #[test]
2350 fn test_parse_arguments_bullets_continuation_line() {
2351 let body = "* `path` - The file path,\n resolved relative to cwd.\n* `mode` - Open mode.";
2352 let pairs = parse_arguments_bullets(body);
2353 assert_eq!(pairs.len(), 2);
2354 assert_eq!(pairs[0].1, "The file path, resolved relative to cwd.");
2355 }
2356
2357 #[test]
2358 fn test_replace_fence_lang_rust_to_typescript() {
2359 let body = "```rust\nlet x = run();\n```";
2360 let out = replace_fence_lang(body, "typescript");
2361 assert!(out.starts_with("```typescript"));
2362 assert!(out.contains("let x = run();"));
2363 }
2364
2365 #[test]
2366 fn test_replace_fence_lang_preserves_attrs() {
2367 let body = "```rust,no_run\nlet x = run();\n```";
2368 let out = replace_fence_lang(body, "typescript");
2369 assert!(out.starts_with("```typescript,no_run"));
2370 }
2371
2372 #[test]
2373 fn test_replace_fence_lang_no_fence_unchanged() {
2374 let body = "Plain prose with `inline code`.";
2375 let out = replace_fence_lang(body, "typescript");
2376 assert_eq!(out, "Plain prose with `inline code`.");
2377 }
2378
2379 fn fixture_sections() -> RustdocSections {
2380 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```";
2381 parse_rustdoc_sections(doc)
2382 }
2383
2384 #[test]
2385 fn test_render_jsdoc_sections() {
2386 let sections = fixture_sections();
2387 let out = render_jsdoc_sections(§ions);
2388 assert!(out.starts_with("Extracts text from a file."));
2389 assert!(out.contains("@param path - The file path."));
2390 assert!(out.contains("@param config - Optional configuration."));
2391 assert!(out.contains("@returns The extracted text and metadata."));
2392 assert!(out.contains("@throws Returns an error when the file is unreadable."));
2393 assert!(!out.contains("@example"), "Rust example must not appear in TSDoc");
2395 assert!(!out.contains("```typescript"));
2396 assert!(!out.contains("```rust"));
2397 }
2398
2399 #[test]
2400 fn test_render_jsdoc_sections_preserves_typescript_example() {
2401 let doc = "Do something.\n\n# Example\n\n```typescript\nconst x = doSomething();\n```";
2402 let sections = parse_rustdoc_sections(doc);
2403 let out = render_jsdoc_sections(§ions);
2404 assert!(out.contains("@example"), "TypeScript example must be preserved");
2405 assert!(out.contains("```typescript"));
2406 }
2407
2408 #[test]
2409 fn test_render_javadoc_sections() {
2410 let sections = fixture_sections();
2411 let out = render_javadoc_sections(§ions, "KreuzbergRsException");
2412 assert!(out.contains("@param path The file path."));
2413 assert!(out.contains("@return The extracted text and metadata."));
2414 assert!(out.contains("@throws KreuzbergRsException Returns an error when the file is unreadable."));
2415 assert!(out.starts_with("Extracts text from a file."));
2418 }
2419
2420 #[test]
2421 fn test_render_csharp_xml_sections() {
2422 let sections = fixture_sections();
2423 let out = render_csharp_xml_sections(§ions, "KreuzbergException");
2424 assert!(out.contains("<summary>\nExtracts text from a file.\n</summary>"));
2425 assert!(out.contains("<param name=\"path\">The file path.</param>"));
2426 assert!(out.contains("<returns>The extracted text and metadata.</returns>"));
2427 assert!(out.contains("<exception cref=\"KreuzbergException\">"));
2428 assert!(out.contains("<example><code language=\"csharp\">"));
2429 assert!(out.contains("let result = extract"));
2430 }
2431
2432 #[test]
2433 fn test_render_phpdoc_sections() {
2434 let sections = fixture_sections();
2435 let out = render_phpdoc_sections(§ions, "KreuzbergException");
2436 assert!(out.contains("@param mixed $path The file path."));
2437 assert!(out.contains("@return The extracted text and metadata."));
2438 assert!(out.contains("@throws KreuzbergException"));
2439 assert!(!out.contains("```php"), "Rust example must not appear in PHPDoc");
2441 assert!(!out.contains("```rust"));
2442 }
2443
2444 #[test]
2445 fn test_render_phpdoc_sections_preserves_php_example() {
2446 let doc = "Do something.\n\n# Example\n\n```php\n$x = doSomething();\n```";
2447 let sections = parse_rustdoc_sections(doc);
2448 let out = render_phpdoc_sections(§ions, "MyException");
2449 assert!(out.contains("```php"), "PHP example must be preserved");
2450 }
2451
2452 #[test]
2453 fn test_render_doxygen_sections() {
2454 let sections = fixture_sections();
2455 let out = render_doxygen_sections(§ions);
2456 assert!(out.contains("\\param path The file path."));
2457 assert!(out.contains("\\return The extracted text and metadata."));
2458 assert!(out.contains("\\code"));
2459 assert!(out.contains("\\endcode"));
2460 }
2461
2462 #[test]
2463 fn test_emit_yard_doc_simple() {
2464 let mut out = String::new();
2465 emit_yard_doc(&mut out, "Simple Ruby documentation", " ");
2466 assert!(out.contains("# Simple Ruby documentation"));
2467 }
2468
2469 #[test]
2470 fn test_emit_yard_doc_empty() {
2471 let mut out = String::new();
2472 emit_yard_doc(&mut out, "", " ");
2473 assert!(out.is_empty());
2474 }
2475
2476 #[test]
2477 fn test_emit_yard_doc_with_sections() {
2478 let mut out = String::new();
2479 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.";
2480 emit_yard_doc(&mut out, doc, " ");
2481 assert!(out.contains("# Extracts text from a file."));
2482 assert!(out.contains("# @param path The file path."));
2483 assert!(out.contains("# @return The extracted text."));
2484 assert!(out.contains("# @raise Returns error on failure."));
2485 }
2486
2487 #[test]
2488 fn test_emit_c_doxygen_simple_prose() {
2489 let mut out = String::new();
2490 emit_c_doxygen(&mut out, "Free a string.", "");
2491 assert!(out.contains("/// Free a string."), "got: {out}");
2492 }
2493
2494 #[test]
2495 fn test_emit_c_doxygen_with_sections() {
2496 let mut out = String::new();
2497 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.";
2498 emit_c_doxygen(&mut out, doc, "");
2499 assert!(out.contains("/// Extract content from a file."));
2500 assert!(out.contains("/// \\param path Path to the file."));
2501 assert!(out.contains("/// \\param mode Read mode."));
2502 assert!(out.contains("/// \\return A newly allocated string the caller owns."));
2503 assert!(out.contains("/// \\note Returns null when the file is unreadable."));
2504 }
2505
2506 #[test]
2507 fn test_emit_c_doxygen_safety_section_maps_to_note() {
2508 let mut out = String::new();
2509 let doc = "Free a buffer.\n\n# Safety\n\nPointer must have been returned by this library.";
2510 emit_c_doxygen(&mut out, doc, "");
2511 assert!(out.contains("/// \\note SAFETY: Pointer must have been returned by this library."));
2512 }
2513
2514 #[test]
2515 fn test_emit_c_doxygen_example_renders_code_fence() {
2516 let mut out = String::new();
2517 let doc = "Demo.\n\n# Example\n\n```rust\nlet x = run();\n```";
2518 emit_c_doxygen(&mut out, doc, "");
2519 assert!(out.contains("/// \\code"));
2520 assert!(out.contains("/// \\endcode"));
2521 assert!(out.contains("let x = run();"));
2522 }
2523
2524 #[test]
2525 fn test_emit_c_doxygen_strips_markdown_links() {
2526 let mut out = String::new();
2527 let doc = "See [the docs](https://example.com/x) for details.";
2528 emit_c_doxygen(&mut out, doc, "");
2529 assert!(
2530 out.contains("the docs (https://example.com/x)"),
2531 "expected flattened link, got: {out}"
2532 );
2533 assert!(!out.contains("](https://"));
2534 }
2535
2536 #[test]
2537 fn test_emit_c_doxygen_word_wraps_long_lines() {
2538 let mut out = String::new();
2539 let long = "a ".repeat(80);
2540 emit_c_doxygen(&mut out, long.trim(), "");
2541 for line in out.lines() {
2542 let body = line.trim_start_matches("/// ");
2545 assert!(body.len() <= 100, "line too long ({}): {line}", body.len());
2546 }
2547 }
2548
2549 #[test]
2550 fn test_emit_c_doxygen_empty_input_is_noop() {
2551 let mut out = String::new();
2552 emit_c_doxygen(&mut out, "", "");
2553 emit_c_doxygen(&mut out, " \n\t ", "");
2554 assert!(out.is_empty());
2555 }
2556
2557 #[test]
2558 fn test_emit_c_doxygen_indent_applied() {
2559 let mut out = String::new();
2560 emit_c_doxygen(&mut out, "Hello.", " ");
2561 assert!(out.starts_with(" /// Hello."));
2562 }
2563
2564 #[test]
2565 fn test_render_yard_sections() {
2566 let sections = fixture_sections();
2567 let out = render_yard_sections(§ions);
2568 assert!(out.contains("@param path The file path."));
2569 assert!(out.contains("@return The extracted text and metadata."));
2570 assert!(out.contains("@raise Returns an error when the file is unreadable."));
2571 assert!(!out.contains("@example"), "Rust example must not appear in YARD");
2573 assert!(!out.contains("```ruby"));
2574 assert!(!out.contains("```rust"));
2575 }
2576
2577 #[test]
2578 fn test_render_yard_sections_preserves_ruby_example() {
2579 let doc = "Do something.\n\n# Example\n\n```ruby\nputs :hi\n```";
2580 let sections = parse_rustdoc_sections(doc);
2581 let out = render_yard_sections(§ions);
2582 assert!(out.contains("@example"), "Ruby example must be preserved");
2583 assert!(out.contains("```ruby"));
2584 }
2585
2586 #[test]
2589 fn example_for_target_rust_fenced_suppressed_for_php() {
2590 let example = "```rust\nlet x = 1;\n```";
2591 assert_eq!(
2592 example_for_target(example, "php"),
2593 None,
2594 "rust-fenced example must be omitted for PHP target"
2595 );
2596 }
2597
2598 #[test]
2599 fn example_for_target_bare_fence_defaults_to_rust_suppressed_for_ruby() {
2600 let example = "```\nlet x = 1;\n```";
2601 assert_eq!(
2602 example_for_target(example, "ruby"),
2603 None,
2604 "bare fence is treated as Rust and must be omitted for Ruby target"
2605 );
2606 }
2607
2608 #[test]
2609 fn example_for_target_php_example_preserved_for_php() {
2610 let example = "```php\n$x = 1;\n```";
2611 let result = example_for_target(example, "php");
2612 assert!(result.is_some(), "PHP example must be preserved for PHP target");
2613 assert!(result.unwrap().contains("```php"));
2614 }
2615
2616 #[test]
2617 fn example_for_target_ruby_example_preserved_for_ruby() {
2618 let example = "```ruby\nputs :hi\n```";
2619 let result = example_for_target(example, "ruby");
2620 assert!(result.is_some(), "Ruby example must be preserved for Ruby target");
2621 assert!(result.unwrap().contains("```ruby"));
2622 }
2623
2624 #[test]
2625 fn render_phpdoc_sections_with_rust_example_emits_no_at_example_block() {
2626 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```";
2627 let sections = parse_rustdoc_sections(doc);
2628 let out = render_phpdoc_sections(§ions, "HtmlToMarkdownException");
2629 assert!(!out.contains("```php"), "no PHP @example block for Rust source");
2630 assert!(!out.contains("```rust"), "raw Rust must not leak into PHPDoc");
2631 assert!(out.contains("@param"), "other sections must still be emitted");
2632 }
2633
2634 #[test]
2637 fn test_emit_kdoc_ktfmt_canonical_short_single_line() {
2638 let mut out = String::new();
2639 emit_kdoc_ktfmt_canonical(&mut out, "Simple doc.", "");
2640 assert_eq!(
2641 out, "/** Simple doc. */\n",
2642 "short single-line comment should collapse to canonical format"
2643 );
2644 }
2645
2646 #[test]
2647 fn test_emit_kdoc_ktfmt_canonical_short_with_indent() {
2648 let mut out = String::new();
2649 emit_kdoc_ktfmt_canonical(&mut out, "Text node (most frequent - 100+ per document)", " ");
2650 assert_eq!(out, " /** Text node (most frequent - 100+ per document) */\n");
2651 }
2652
2653 #[test]
2654 fn test_emit_kdoc_ktfmt_canonical_long_comment_uses_multiline() {
2655 let mut out = String::new();
2656 let long_text = "This is a very long documentation comment that exceeds the 100-character line width limit and should therefore be emitted in multi-line format";
2657 emit_kdoc_ktfmt_canonical(&mut out, long_text, "");
2658 assert!(out.contains("/**\n"), "long comment should start with newline");
2659 assert!(out.contains(" * "), "long comment should use multi-line format");
2660 assert!(out.contains(" */\n"), "long comment should end with newline");
2661 }
2662
2663 #[test]
2664 fn test_emit_kdoc_ktfmt_canonical_multiline_comment() {
2665 let mut out = String::new();
2666 let doc = "First line.\n\nSecond paragraph.";
2667 emit_kdoc_ktfmt_canonical(&mut out, doc, "");
2668 assert!(out.contains("/**\n"), "multi-paragraph should use multi-line format");
2669 assert!(out.contains(" * First line."), "first paragraph preserved");
2670 assert!(out.contains(" *\n"), "blank line preserved");
2671 assert!(out.contains(" * Second paragraph."), "second paragraph preserved");
2672 }
2673
2674 #[test]
2675 fn test_emit_kdoc_ktfmt_canonical_empty_doc() {
2676 let mut out = String::new();
2677 emit_kdoc_ktfmt_canonical(&mut out, "", "");
2678 assert!(out.is_empty(), "empty doc should produce no output");
2679 }
2680
2681 #[test]
2682 fn test_emit_kdoc_ktfmt_canonical_fits_within_100_chars() {
2683 let mut out = String::new();
2684 let content = "a".repeat(93);
2687 emit_kdoc_ktfmt_canonical(&mut out, &content, "");
2688 let line = out.lines().next().unwrap();
2689 assert_eq!(
2690 line.len(),
2691 100,
2692 "should fit exactly at 100 chars and use single-line format"
2693 );
2694 assert!(out.starts_with("/**"), "should use single-line format");
2695 }
2696
2697 #[test]
2698 fn test_emit_kdoc_ktfmt_canonical_exceeds_100_chars() {
2699 let mut out = String::new();
2700 let content = "a".repeat(94);
2702 emit_kdoc_ktfmt_canonical(&mut out, &content, "");
2703 assert!(
2704 out.contains("/**\n"),
2705 "should use multi-line format when exceeding 100 chars"
2706 );
2707 assert!(out.contains(" * "), "multi-line format with ` * ` prefix");
2708 }
2709
2710 #[test]
2711 fn test_emit_kdoc_ktfmt_canonical_respects_indent() {
2712 let mut out = String::new();
2713 let content = "a".repeat(89);
2715 emit_kdoc_ktfmt_canonical(&mut out, &content, " ");
2716 let line = out.lines().next().unwrap();
2717 assert_eq!(line.len(), 100, "should respect indent in 100-char calculation");
2718 assert!(line.starts_with(" /** "), "should include indent");
2719 }
2720
2721 #[test]
2722 fn test_emit_kdoc_ktfmt_canonical_real_world_enum_variant() {
2723 let mut out = String::new();
2724 emit_kdoc_ktfmt_canonical(&mut out, "Text node (most frequent - 100+ per document)", " ");
2725 assert!(out.starts_with(" /** "), "should preserve 4-space indent");
2727 assert!(out.contains(" */\n"), "should end with newline");
2728 let line_count = out.lines().count();
2730 assert_eq!(line_count, 1, "should be single-line format");
2731 }
2732
2733 #[test]
2734 fn test_emit_kdoc_ktfmt_canonical_real_world_data_class_field() {
2735 let mut out = String::new();
2736 let doc = "Heading style to use in Markdown output (ATX `#` or Setext underline).";
2737 emit_kdoc_ktfmt_canonical(&mut out, doc, " ");
2738 let line_count = out.lines().count();
2740 assert_eq!(line_count, 1, "should be single-line format");
2741 assert!(out.starts_with(" /** "), "should have correct indent");
2742 }
2743
2744 #[test]
2747 fn sanitize_intradoc_link_with_path_separator_java() {
2748 let input = "See [`ConversionOptions::builder()`] for details.";
2749 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
2750 assert!(out.contains("`ConversionOptions.builder()`"), "got: {out}");
2751 assert!(!out.contains("[`"), "brackets must be removed, got: {out}");
2752 }
2753
2754 #[test]
2755 fn sanitize_intradoc_link_simple_type_php() {
2756 let input = "Returns a [`ConversionResult`].";
2757 let out = sanitize_rust_idioms(input, DocTarget::PhpDoc);
2758 assert!(out.contains("`ConversionResult`"), "got: {out}");
2759 assert!(!out.contains("[`"), "got: {out}");
2760 }
2761
2762 #[test]
2763 fn sanitize_none_to_null_javadoc() {
2764 let input = "Returns None when no value is found.";
2765 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
2766 assert!(out.contains("null"), "got: {out}");
2767 assert!(!out.contains("None"), "got: {out}");
2768 }
2769
2770 #[test]
2771 fn sanitize_none_to_undefined_tsdoc() {
2772 let input = "Returns None if absent.";
2773 let out = sanitize_rust_idioms(input, DocTarget::TsDoc);
2774 assert!(out.contains("undefined"), "got: {out}");
2775 assert!(!out.contains("None"), "got: {out}");
2776 }
2777
2778 #[test]
2779 fn sanitize_some_x_to_the_value_x() {
2780 let input = "Pass Some(value) to enable.";
2781 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
2782 assert!(out.contains("the value (value)"), "got: {out}");
2783 assert!(!out.contains("Some("), "got: {out}");
2784 }
2785
2786 #[test]
2787 fn sanitize_bare_some_followed_by_lowercase_noun_is_dropped() {
2788 let input =
2790 "Only specified fields (Some values) will override existing options; None values leave the previous";
2791 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
2792 assert!(
2793 out.contains("(values)"),
2794 "bare `Some ` before lowercase noun must be stripped; got: {out}"
2795 );
2796 assert!(
2797 out.contains("null values"),
2798 "bare `None ` must also be replaced; got: {out}"
2799 );
2800 assert!(!out.contains("Some "), "Some prefix must not survive; got: {out}");
2801 }
2802
2803 #[test]
2804 fn sanitize_bare_some_does_not_touch_identifiers_or_uppercase_followers() {
2805 let cases = [
2807 "SomeType lives on.",
2808 "Some.method() returns Self.",
2809 "Some Title",
2810 "Some(x) is a value.",
2811 ];
2812 for case in cases {
2813 let out = sanitize_rust_idioms(case, DocTarget::JavaDoc);
2814 if case.starts_with("Some(") {
2817 assert!(out.contains("the value (x)"), "got: {out}");
2818 } else {
2819 assert!(out.contains("Some"), "Some must survive in {case:?}; got: {out}");
2820 }
2821 }
2822 }
2823
2824 #[test]
2825 fn sanitize_option_t_to_nullable_php() {
2826 let input = "The result is Option<String>.";
2827 let out = sanitize_rust_idioms(input, DocTarget::PhpDoc);
2828 assert!(out.contains("String?"), "got: {out}");
2829 assert!(!out.contains("Option<"), "got: {out}");
2830 }
2831
2832 #[test]
2833 fn sanitize_option_t_to_or_null_java() {
2834 let input = "The result is Option<String>.";
2835 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
2836 assert!(out.contains("String | null"), "got: {out}");
2837 }
2838
2839 #[test]
2840 fn sanitize_option_t_to_or_undefined_tsdoc() {
2841 let input = "The result is Option<String>.";
2842 let out = sanitize_rust_idioms(input, DocTarget::TsDoc);
2843 assert!(out.contains("String | undefined"), "got: {out}");
2844 }
2845
2846 #[test]
2847 fn sanitize_vec_u8_per_target() {
2848 assert!(sanitize_rust_idioms("Takes Vec<u8>.", DocTarget::PhpDoc).contains("string"));
2849 assert!(sanitize_rust_idioms("Takes Vec<u8>.", DocTarget::JavaDoc).contains("byte[]"));
2850 assert!(sanitize_rust_idioms("Takes Vec<u8>.", DocTarget::TsDoc).contains("Uint8Array"));
2851 assert!(sanitize_rust_idioms("Takes Vec<u8>.", DocTarget::JsDoc).contains("Uint8Array"));
2852 }
2853
2854 #[test]
2855 fn sanitize_vec_t_to_array() {
2856 let input = "Returns Vec<String>.";
2857 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
2858 assert!(out.contains("String[]"), "got: {out}");
2859 assert!(!out.contains("Vec<"), "got: {out}");
2860 }
2861
2862 #[test]
2863 fn sanitize_hashmap_per_target() {
2864 let input = "Uses HashMap<String, u32>.";
2865 assert!(sanitize_rust_idioms(input, DocTarget::PhpDoc).contains("array<String, u32>"));
2866 assert!(sanitize_rust_idioms(input, DocTarget::JavaDoc).contains("Map<String, u32>"));
2867 assert!(sanitize_rust_idioms(input, DocTarget::TsDoc).contains("Record<String, u32>"));
2868 }
2869
2870 #[test]
2871 fn sanitize_arc_wrapper_stripped() {
2872 let input = "Holds Arc<Config>.";
2873 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
2874 assert!(out.contains("Config"), "got: {out}");
2875 assert!(!out.contains("Arc<"), "got: {out}");
2876 }
2877
2878 #[test]
2879 fn sanitize_box_mutex_rwlock_rc_cell_refcell_stripped() {
2880 for wrapper in &["Box", "Mutex", "RwLock", "Rc", "Cell", "RefCell"] {
2881 let input = format!("Contains {wrapper}<Inner>.");
2882 let out = sanitize_rust_idioms(&input, DocTarget::JavaDoc);
2883 assert!(out.contains("Inner"), "wrapper {wrapper} not stripped, got: {out}");
2884 assert!(
2885 !out.contains(&format!("{wrapper}<")),
2886 "wrapper {wrapper} still present, got: {out}"
2887 );
2888 }
2889 }
2890
2891 #[test]
2892 fn sanitize_send_sync_stripped() {
2893 let input = "The type is Send + Sync.";
2894 let out = sanitize_rust_idioms(input, DocTarget::TsDoc);
2895 assert!(!out.contains("Send"), "got: {out}");
2896 assert!(!out.contains("Sync"), "got: {out}");
2897 }
2898
2899 #[test]
2900 fn sanitize_static_lifetime_stripped() {
2901 let input = "Requires 'static lifetime.";
2902 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
2903 assert!(!out.contains("'static"), "got: {out}");
2904 }
2905
2906 #[test]
2907 fn sanitize_pub_fn_stripped() {
2908 let input = "Calls pub fn convert().";
2909 let out = sanitize_rust_idioms(input, DocTarget::PhpDoc);
2910 assert!(!out.contains("pub fn"), "got: {out}");
2911 assert!(out.contains("convert()"), "got: {out}");
2912 }
2913
2914 #[test]
2915 fn sanitize_crate_prefix_stripped() {
2916 let input = "See crate::error::ConversionError.";
2917 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
2918 assert!(!out.contains("crate::"), "got: {out}");
2919 assert!(out.contains("error.ConversionError"), "got: {out}");
2920 }
2921
2922 #[test]
2923 fn sanitize_unwrap_expect_stripped() {
2924 let input = "Call result.unwrap() or result.expect(\"msg\").";
2925 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
2926 assert!(!out.contains(".unwrap()"), "got: {out}");
2927 assert!(!out.contains(".expect("), "got: {out}");
2928 }
2929
2930 #[test]
2931 fn sanitize_no_mutation_inside_backticks() {
2932 let input = "Use `None` as the argument.";
2934 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
2935 assert!(out.contains("`None`"), "backtick span must be preserved, got: {out}");
2936 }
2937
2938 #[test]
2939 fn sanitize_rust_fence_dropped_for_tsdoc() {
2940 let input = "Intro.\n\n```rust\nlet x = 1;\n```\n\nTrailer.";
2941 let out = sanitize_rust_idioms(input, DocTarget::TsDoc);
2942 assert!(
2943 !out.contains("let x = 1;"),
2944 "rust fence content must be dropped, got: {out}"
2945 );
2946 assert!(!out.contains("```rust"), "got: {out}");
2947 assert!(out.contains("Trailer."), "text after fence must survive, got: {out}");
2948 }
2949
2950 #[test]
2951 fn sanitize_rust_fence_dropped_for_java() {
2952 let input = "Intro.\n\n```rust\nlet x = 1;\n```\n\nTrailer.";
2953 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
2954 assert!(
2956 !out.contains("let x = 1;"),
2957 "fence content must be dropped for Java, got: {out}"
2958 );
2959 assert!(!out.contains("```"), "fence markers must be dropped, got: {out}");
2960 assert!(out.contains("Intro."), "prose before fence kept: {out}");
2961 assert!(out.contains("Trailer."), "prose after fence kept: {out}");
2962 }
2963
2964 #[test]
2965 fn sanitize_non_rust_fence_passed_through() {
2966 let input = "Example:\n\n```typescript\nconst x = 1;\n```";
2967 let out = sanitize_rust_idioms(input, DocTarget::TsDoc);
2968 assert!(out.contains("```typescript"), "non-rust fence must survive, got: {out}");
2969 assert!(out.contains("const x = 1;"), "got: {out}");
2970 }
2971
2972 #[test]
2973 fn sanitize_backtick_code_span_not_mutated_option() {
2974 let input = "The type is `Option<String>`.";
2976 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
2977 assert!(
2979 out.contains("`Option<String>`"),
2980 "code span must be preserved, got: {out}"
2981 );
2982 }
2983
2984 #[test]
2985 fn sanitize_idempotent() {
2986 let input = "Returns None when Vec<String> is empty.";
2988 let once = sanitize_rust_idioms(input, DocTarget::JavaDoc);
2989 let twice = sanitize_rust_idioms(&once, DocTarget::JavaDoc);
2990 assert_eq!(once, twice, "sanitize_rust_idioms should be idempotent");
2991 }
2992
2993 #[test]
2994 fn sanitize_multiline_prose() {
2995 let input = "Convert HTML to Markdown.\n\nReturns None on failure.\nUse Option<String> for the result.";
2996 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
2997 assert!(out.contains("null"), "None must be replaced on line 2, got: {out}");
2998 assert!(
2999 out.contains("String | null"),
3000 "Option<String> must be replaced on line 3, got: {out}"
3001 );
3002 }
3003
3004 #[test]
3005 fn sanitize_attribute_line_dropped() {
3006 let input = "#[derive(Debug, Clone)]\nSome documentation.";
3007 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
3008 assert!(!out.contains("#[derive("), "attribute line must be dropped, got: {out}");
3009 assert!(out.contains("documentation."), "prose must survive, got: {out}");
3012 }
3013
3014 #[test]
3015 fn sanitize_path_separator_in_prose() {
3016 let input = "See std::collections::HashMap for details.";
3017 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
3018 assert!(out.contains("std.collections.HashMap"), ":: must become ., got: {out}");
3019 }
3020
3021 #[test]
3022 fn sanitize_none_not_replaced_inside_identifier() {
3023 let input = "Unlike NoneType in Python.";
3025 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
3026 assert!(out.contains("NoneType"), "NoneType must not be replaced, got: {out}");
3027 }
3028
3029 #[test]
3032 fn sanitize_csharp_drops_rust_section_headings_and_example_body() {
3033 let input = "Convert error to HTTP status code\n\n\
3037 Maps GraphQL error types to status codes.\n\n\
3038 # Examples\n\n\
3039 ```ignore\n\
3040 use spikard_graphql::error::GraphQLError;\n\
3041 let error = GraphQLError::AuthenticationError(\"Invalid token\".to_string());\n\
3042 assert_eq!(error.status_code(), 401);\n\
3043 ```\n";
3044 let out = sanitize_rust_idioms(input, DocTarget::CSharpDoc);
3045 assert!(
3046 out.contains("Convert error to HTTP status code"),
3047 "summary preserved: {out}"
3048 );
3049 assert!(out.contains("Maps GraphQL error types"), "prose preserved: {out}");
3050 assert!(!out.contains("# Examples"), "heading dropped: {out}");
3051 assert!(!out.contains("```"), "code fence dropped: {out}");
3052 assert!(!out.contains("Self::error_code"), "Self::method dropped: {out}");
3053 assert!(
3054 !out.contains("GraphQLError::AuthenticationError"),
3055 "rust path dropped: {out}"
3056 );
3057 }
3058
3059 #[test]
3060 fn sanitize_csharp_intradoc_link_with_path_separator() {
3061 let input = "See [`Self::error_code`] for the variant codes.";
3062 let out = sanitize_rust_idioms(input, DocTarget::CSharpDoc);
3063 assert!(out.contains("`Self.error_code`"), "intra-doc link normalised: {out}");
3064 assert!(!out.contains("[`"), "square brackets removed: {out}");
3065 assert!(!out.contains("::"), ":: replaced with .: {out}");
3066 }
3067
3068 #[test]
3069 fn sanitize_csharp_result_type_keeps_success_drops_error() {
3070 let input = "Returns Result<String, ConversionError> on failure.";
3071 let out = sanitize_rust_idioms(input, DocTarget::CSharpDoc);
3072 assert!(out.contains("String"), "success type kept: {out}");
3073 assert!(!out.contains("Result<"), "Result wrapper dropped: {out}");
3074 assert!(!out.contains("ConversionError"), "error type dropped: {out}");
3075 }
3076
3077 #[test]
3078 fn sanitize_csharp_option_becomes_nullable() {
3079 let input = "Returns Option<String>.";
3080 let out = sanitize_rust_idioms(input, DocTarget::CSharpDoc);
3081 assert!(out.contains("String?"), "Option<T> -> T?: {out}");
3083 assert!(!out.contains("Option<"), "Option dropped: {out}");
3084 }
3085
3086 #[test]
3087 fn sanitize_csharp_vec_u8_becomes_byte_array() {
3088 let input = "Accepts Vec<u8>.";
3089 let out = sanitize_rust_idioms(input, DocTarget::CSharpDoc);
3090 assert!(out.contains("byte[]"), "Vec<u8> -> byte[]: {out}");
3092 }
3093
3094 #[test]
3095 fn sanitize_csharp_hashmap_becomes_dictionary() {
3096 let input = "Holds HashMap<String, u32>.";
3097 let out = sanitize_rust_idioms(input, DocTarget::CSharpDoc);
3098 assert!(
3100 out.contains("Dictionary<String, u32>"),
3101 "HashMap -> Dictionary with XML-escaped brackets: {out}"
3102 );
3103 }
3104
3105 #[test]
3106 fn sanitize_csharp_none_to_null() {
3107 let input = "Returns None on miss.";
3108 let out = sanitize_rust_idioms(input, DocTarget::CSharpDoc);
3109 assert!(out.contains("null"), "None -> null: {out}");
3110 assert!(!out.contains("None"), "None replaced: {out}");
3111 }
3112
3113 #[test]
3114 fn sanitize_csharp_escapes_raw_angle_brackets_and_amp() {
3115 let input = "Accepts Box<dyn Trait> and combines a & b.";
3119 let out = sanitize_rust_idioms(input, DocTarget::CSharpDoc);
3120 assert!(out.contains("dyn Trait"), "Box<T> stripped: {out}");
3122 assert!(out.contains("&"), "ampersand escaped: {out}");
3123 }
3124
3125 #[test]
3126 fn sanitize_csharp_drops_rust_code_fence_entirely() {
3127 let input = "Intro.\n\n```rust\nlet x: Vec<u8> = vec![];\n```\n\nTrailer.";
3128 let out = sanitize_rust_idioms(input, DocTarget::CSharpDoc);
3129 assert!(!out.contains("let x"), "code fence body dropped: {out}");
3130 assert!(!out.contains("```"), "fence markers dropped: {out}");
3131 assert!(out.contains("Intro."), "prose before fence kept: {out}");
3132 assert!(out.contains("Trailer."), "prose after fence kept: {out}");
3133 }
3134
3135 #[test]
3136 fn sanitize_csharp_keep_sections_does_not_drop_headings() {
3137 let input = "Summary.\n\n# Arguments\n\n* `name` - the value.";
3140 let out = sanitize_rust_idioms_keep_sections(input, DocTarget::CSharpDoc);
3141 assert!(out.contains("# Arguments"), "heading preserved: {out}");
3142 assert!(out.contains("name"), "body preserved: {out}");
3143 }
3144
3145 #[test]
3146 fn sanitize_csharp_idempotent() {
3147 let input = "Returns Option<String> or None.";
3148 let once = sanitize_rust_idioms(input, DocTarget::CSharpDoc);
3149 let twice = sanitize_rust_idioms(&once, DocTarget::CSharpDoc);
3150 assert_eq!(once, twice, "CSharpDoc sanitisation must be idempotent");
3151 }
3152
3153 #[test]
3154 fn sanitize_phpdoc_drops_unmarked_rust_code_fences() {
3155 let input = "Detect language name from a file extension.\n\nReturns `None` for unrecognized extensions.\n\n```\nuse tree_sitter_language_pack::detect_language_from_extension;\nassert_eq!(detect_language_from_extension(\"py\"), Some(\"python\"));\nassert_eq!(detect_language_from_extension(\"RS\"), Some(\"rust\"));\nassert_eq!(detect_language_from_extension(\"xyz\"), None);\n```";
3158 let out = sanitize_rust_idioms(input, DocTarget::PhpDoc);
3159 assert!(
3160 !out.contains("use tree_sitter_language_pack"),
3161 "Rust use stmt dropped: {out}"
3162 );
3163 assert!(!out.contains("assert_eq!"), "Rust code dropped: {out}");
3164 assert!(!out.contains("```"), "fence markers dropped: {out}");
3165 assert!(out.contains("Detect language name"), "prose before fence kept: {out}");
3166 assert!(out.contains("unrecognized extensions"), "prose kept: {out}");
3167 }
3168
3169 #[test]
3170 fn sanitize_javadoc_drops_unmarked_rust_code_fences() {
3171 let input = "Process a file.\n\n```\nlet result = process(\"def hello(): pass\", &config).unwrap();\n```";
3174 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
3175 assert!(!out.contains("unwrap"), "Rust unwrap dropped: {out}");
3176 assert!(!out.contains("```"), "fence markers dropped: {out}");
3177 assert!(out.contains("Process a file"), "prose kept: {out}");
3178 }
3179
3180 #[test]
3181 fn sanitize_phpdoc_drops_explicit_rust_fences() {
3182 let input = "Summary.\n\n```rust\nuse std::path::PathBuf;\nlet p = PathBuf::from(\"/tmp\");\n```";
3184 let out = sanitize_rust_idioms(input, DocTarget::PhpDoc);
3185 assert!(!out.contains("use std::"), "Rust code dropped: {out}");
3186 assert!(!out.contains("PathBuf"), "Rust types dropped: {out}");
3187 assert!(!out.contains("```"), "fence markers dropped: {out}");
3188 assert!(out.contains("Summary"), "prose kept: {out}");
3189 }
3190
3191 #[test]
3194 fn sanitize_no_run_fence_dropped_for_tsdoc() {
3195 let input = "Intro.\n\n```no_run\nuse foo::bar;\nbar::init();\n```\n\nTrailer.";
3196 let out = sanitize_rust_idioms(input, DocTarget::TsDoc);
3197 assert!(!out.contains("use foo::bar"), "no_run fence body dropped: {out}");
3198 assert!(!out.contains("```"), "fence markers dropped: {out}");
3199 assert!(out.contains("Intro."), "prose before fence kept: {out}");
3200 assert!(out.contains("Trailer."), "prose after fence kept: {out}");
3201 }
3202
3203 #[test]
3204 fn sanitize_ignore_fence_dropped_for_phpdoc() {
3205 let input = "Summary.\n\n```ignore\nlet x = 1;\n// this would not compile\n```";
3206 let out = sanitize_rust_idioms(input, DocTarget::PhpDoc);
3207 assert!(!out.contains("let x = 1"), "ignore fence body dropped: {out}");
3208 assert!(!out.contains("```"), "fence markers dropped: {out}");
3209 assert!(out.contains("Summary"), "prose kept: {out}");
3210 }
3211
3212 #[test]
3213 fn sanitize_should_panic_fence_dropped_for_javadoc() {
3214 let input = "Panics on null.\n\n```should_panic\nlet _ = parse(null);\n```";
3215 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
3216 assert!(!out.contains("parse(null)"), "should_panic fence body dropped: {out}");
3217 assert!(!out.contains("```"), "fence markers dropped: {out}");
3218 assert!(out.contains("Panics on null"), "prose kept: {out}");
3219 }
3220
3221 #[test]
3222 fn sanitize_compile_fail_fence_dropped_for_csharp() {
3223 let input = "Type safety demo.\n\n```compile_fail\nlet x: u32 = \"hello\";\n```";
3224 let out = sanitize_rust_idioms(input, DocTarget::CSharpDoc);
3225 assert!(!out.contains("let x:"), "compile_fail fence body dropped: {out}");
3226 assert!(!out.contains("```"), "fence markers dropped: {out}");
3227 assert!(out.contains("Type safety demo"), "prose kept: {out}");
3228 }
3229
3230 #[test]
3231 fn sanitize_edition_fence_dropped_for_tsdoc() {
3232 let input = "Edition example.\n\n```edition2021\nuse std::fmt;\n```\n\nSee also edition2018.";
3233 let out = sanitize_rust_idioms(input, DocTarget::TsDoc);
3234 assert!(!out.contains("use std::fmt"), "edition2021 fence body dropped: {out}");
3235 assert!(!out.contains("```"), "fence markers dropped: {out}");
3236 assert!(out.contains("Edition example"), "prose kept: {out}");
3237 }
3238
3239 #[test]
3240 fn sanitize_python_fence_preserved_for_tsdoc() {
3241 let input = "Example:\n\n```python\nimport foo\nfoo.bar()\n```";
3243 let out = sanitize_rust_idioms(input, DocTarget::TsDoc);
3244 assert!(out.contains("```python"), "python fence preserved: {out}");
3245 assert!(out.contains("import foo"), "python body preserved: {out}");
3246 }
3247
3248 #[test]
3249 fn sanitize_javascript_fence_preserved_for_phpdoc() {
3250 let input = "Usage:\n\n```javascript\nconst x = require('foo');\n```";
3251 let out = sanitize_rust_idioms(input, DocTarget::PhpDoc);
3252 assert!(out.contains("```javascript"), "javascript fence preserved: {out}");
3253 assert!(out.contains("require('foo')"), "javascript body preserved: {out}");
3254 }
3255
3256 #[test]
3257 fn example_for_target_no_run_fence_suppressed_for_typescript() {
3258 let example =
3259 "```no_run\nuse tree_sitter_language_pack::available_languages;\nlet langs = available_languages();\n```";
3260 assert_eq!(
3261 example_for_target(example, "typescript"),
3262 None,
3263 "no_run fence must be treated as Rust and suppressed for TypeScript"
3264 );
3265 }
3266
3267 #[test]
3268 fn example_for_target_ignore_fence_suppressed_for_php() {
3269 let example = "```ignore\nlet x = 1;\n```";
3270 assert_eq!(
3271 example_for_target(example, "php"),
3272 None,
3273 "ignore fence must be treated as Rust and suppressed for PHP"
3274 );
3275 }
3276
3277 #[test]
3278 fn example_for_target_compile_fail_fence_suppressed_for_java() {
3279 let example = "```compile_fail\nlet x: u32 = \"wrong\";\n```";
3280 assert_eq!(
3281 example_for_target(example, "java"),
3282 None,
3283 "compile_fail fence must be treated as Rust and suppressed for Java"
3284 );
3285 }
3286
3287 #[test]
3288 fn example_for_target_should_panic_fence_suppressed_for_ruby() {
3289 let example = "```should_panic\nlet _ = parse(None);\n```";
3290 assert_eq!(
3291 example_for_target(example, "ruby"),
3292 None,
3293 "should_panic fence must be treated as Rust and suppressed for Ruby"
3294 );
3295 }
3296
3297 #[test]
3298 fn example_for_target_edition_fence_suppressed_for_php() {
3299 let example = "```edition2021\nuse std::fmt;\n```";
3300 assert_eq!(
3301 example_for_target(example, "php"),
3302 None,
3303 "edition2021 fence must be treated as Rust and suppressed for PHP"
3304 );
3305 }
3306
3307 #[test]
3308 fn example_for_target_python_fence_preserved() {
3309 let example = "```python\nimport foo\n```";
3310 let result = example_for_target(example, "php");
3311 assert!(result.is_some(), "python fence must be preserved for PHP target");
3312 }
3313}