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) { "rust" } else { tag };
880 }
881 }
882 "rust"
883}
884
885pub fn example_for_target(example: &str, target_lang: &str) -> Option<String> {
894 let trimmed = example.trim();
895 let source_lang = detect_first_fence_lang(trimmed);
896 if source_lang == "rust" && target_lang != "rust" {
897 None
898 } else {
899 Some(replace_fence_lang(trimmed, target_lang))
900 }
901}
902
903pub fn replace_fence_lang(body: &str, lang_replacement: &str) -> String {
911 let mut out = String::with_capacity(body.len());
912 for line in body.lines() {
913 let trimmed = line.trim_start();
914 if let Some(rest) = trimmed.strip_prefix("```") {
915 let indent = &line[..line.len() - trimmed.len()];
918 let after_lang = rest.find(',').map(|i| &rest[i..]).unwrap_or("");
919 out.push_str(indent);
920 out.push_str("```");
921 out.push_str(lang_replacement);
922 out.push_str(after_lang);
923 out.push('\n');
924 } else {
925 out.push_str(line);
926 out.push('\n');
927 }
928 }
929 out.trim_end_matches('\n').to_string()
930}
931
932pub fn render_jsdoc_sections(sections: &RustdocSections) -> String {
945 let mut out = String::new();
946 if !sections.summary.is_empty() {
947 out.push_str(§ions.summary);
948 }
949 if let Some(args) = sections.arguments.as_deref() {
950 for (name, desc) in parse_arguments_bullets(args) {
951 if !out.is_empty() {
952 out.push('\n');
953 }
954 if desc.is_empty() {
955 out.push_str(&crate::template_env::render(
956 "doc_jsdoc_param.jinja",
957 minijinja::context! { name => &name },
958 ));
959 } else {
960 out.push_str(&crate::template_env::render(
961 "doc_jsdoc_param_desc.jinja",
962 minijinja::context! { name => &name, desc => &desc },
963 ));
964 }
965 }
966 }
967 if let Some(ret) = sections.returns.as_deref() {
968 if !out.is_empty() {
969 out.push('\n');
970 }
971 out.push_str(&crate::template_env::render(
972 "doc_jsdoc_returns.jinja",
973 minijinja::context! { content => ret.trim() },
974 ));
975 }
976 if let Some(err) = sections.errors.as_deref() {
977 if !out.is_empty() {
978 out.push('\n');
979 }
980 out.push_str(&crate::template_env::render(
981 "doc_jsdoc_throws.jinja",
982 minijinja::context! { content => err.trim() },
983 ));
984 }
985 if let Some(example) = sections.example.as_deref() {
986 if let Some(body) = example_for_target(example, "typescript") {
987 if !out.is_empty() {
988 out.push('\n');
989 }
990 out.push_str("@example\n");
991 out.push_str(&body);
992 }
993 }
994 out
995}
996
997pub fn render_javadoc_sections(sections: &RustdocSections, throws_class: &str) -> String {
1007 let mut out = String::new();
1008 if !sections.summary.is_empty() {
1009 out.push_str(§ions.summary);
1010 }
1011 if let Some(args) = sections.arguments.as_deref() {
1012 for (name, desc) in parse_arguments_bullets(args) {
1013 if !out.is_empty() {
1014 out.push('\n');
1015 }
1016 if desc.is_empty() {
1017 out.push_str(&crate::template_env::render(
1018 "doc_javadoc_param.jinja",
1019 minijinja::context! { name => &name },
1020 ));
1021 } else {
1022 out.push_str(&crate::template_env::render(
1023 "doc_javadoc_param_desc.jinja",
1024 minijinja::context! { name => &name, desc => &desc },
1025 ));
1026 }
1027 }
1028 }
1029 if let Some(ret) = sections.returns.as_deref() {
1030 if !out.is_empty() {
1031 out.push('\n');
1032 }
1033 out.push_str(&crate::template_env::render(
1034 "doc_javadoc_return.jinja",
1035 minijinja::context! { content => ret.trim() },
1036 ));
1037 }
1038 if let Some(err) = sections.errors.as_deref() {
1039 if !out.is_empty() {
1040 out.push('\n');
1041 }
1042 out.push_str(&crate::template_env::render(
1043 "doc_javadoc_throws.jinja",
1044 minijinja::context! { throws_class => throws_class, content => err.trim() },
1045 ));
1046 }
1047 out
1048}
1049
1050pub fn render_csharp_xml_sections(sections: &RustdocSections, exception_class: &str) -> String {
1059 let mut out = String::new();
1060 out.push_str("<summary>\n");
1061 let summary = if sections.summary.is_empty() {
1062 ""
1063 } else {
1064 sections.summary.as_str()
1065 };
1066 for line in summary.lines() {
1067 out.push_str(line);
1068 out.push('\n');
1069 }
1070 out.push_str("</summary>");
1071 if let Some(args) = sections.arguments.as_deref() {
1072 for (name, desc) in parse_arguments_bullets(args) {
1073 out.push('\n');
1074 if desc.is_empty() {
1075 out.push_str(&crate::template_env::render(
1076 "doc_csharp_param.jinja",
1077 minijinja::context! { name => &name },
1078 ));
1079 } else {
1080 out.push_str(&crate::template_env::render(
1081 "doc_csharp_param_desc.jinja",
1082 minijinja::context! { name => &name, desc => &desc },
1083 ));
1084 }
1085 }
1086 }
1087 if let Some(ret) = sections.returns.as_deref() {
1088 out.push('\n');
1089 out.push_str(&crate::template_env::render(
1090 "doc_csharp_returns.jinja",
1091 minijinja::context! { content => ret.trim() },
1092 ));
1093 }
1094 if let Some(err) = sections.errors.as_deref() {
1095 out.push('\n');
1096 out.push_str(&crate::template_env::render(
1097 "doc_csharp_exception.jinja",
1098 minijinja::context! {
1099 exception_class => exception_class,
1100 content => err.trim(),
1101 },
1102 ));
1103 }
1104 if let Some(example) = sections.example.as_deref() {
1105 out.push('\n');
1106 out.push_str("<example><code language=\"csharp\">\n");
1107 for line in example.lines() {
1109 let t = line.trim_start();
1110 if t.starts_with("```") {
1111 continue;
1112 }
1113 out.push_str(line);
1114 out.push('\n');
1115 }
1116 out.push_str("</code></example>");
1117 }
1118 out
1119}
1120
1121pub fn render_phpdoc_sections(sections: &RustdocSections, throws_class: &str) -> String {
1128 let mut out = String::new();
1129 if !sections.summary.is_empty() {
1130 out.push_str(§ions.summary);
1131 }
1132 if let Some(args) = sections.arguments.as_deref() {
1133 for (name, desc) in parse_arguments_bullets(args) {
1134 if !out.is_empty() {
1135 out.push('\n');
1136 }
1137 if desc.is_empty() {
1138 out.push_str(&crate::template_env::render(
1139 "doc_phpdoc_param.jinja",
1140 minijinja::context! { name => &name },
1141 ));
1142 } else {
1143 out.push_str(&crate::template_env::render(
1144 "doc_phpdoc_param_desc.jinja",
1145 minijinja::context! { name => &name, desc => &desc },
1146 ));
1147 }
1148 }
1149 }
1150 if let Some(ret) = sections.returns.as_deref() {
1151 if !out.is_empty() {
1152 out.push('\n');
1153 }
1154 out.push_str(&crate::template_env::render(
1155 "doc_phpdoc_return.jinja",
1156 minijinja::context! { content => ret.trim() },
1157 ));
1158 }
1159 if let Some(err) = sections.errors.as_deref() {
1160 if !out.is_empty() {
1161 out.push('\n');
1162 }
1163 out.push_str(&crate::template_env::render(
1164 "doc_phpdoc_throws.jinja",
1165 minijinja::context! { throws_class => throws_class, content => err.trim() },
1166 ));
1167 }
1168 if let Some(example) = sections.example.as_deref() {
1169 if let Some(body) = example_for_target(example, "php") {
1170 if !out.is_empty() {
1171 out.push('\n');
1172 }
1173 out.push_str(&body);
1174 }
1175 }
1176 out
1177}
1178
1179pub fn render_doxygen_sections(sections: &RustdocSections) -> String {
1186 let mut out = String::new();
1187 if !sections.summary.is_empty() {
1188 out.push_str(§ions.summary);
1189 }
1190 if let Some(args) = sections.arguments.as_deref() {
1191 for (name, desc) in parse_arguments_bullets(args) {
1192 if !out.is_empty() {
1193 out.push('\n');
1194 }
1195 if desc.is_empty() {
1196 out.push_str(&crate::template_env::render(
1197 "doc_doxygen_param.jinja",
1198 minijinja::context! { name => &name },
1199 ));
1200 } else {
1201 out.push_str(&crate::template_env::render(
1202 "doc_doxygen_param_desc.jinja",
1203 minijinja::context! { name => &name, desc => &desc },
1204 ));
1205 }
1206 }
1207 }
1208 if let Some(ret) = sections.returns.as_deref() {
1209 if !out.is_empty() {
1210 out.push('\n');
1211 }
1212 out.push_str(&crate::template_env::render(
1213 "doc_doxygen_return.jinja",
1214 minijinja::context! { content => ret.trim() },
1215 ));
1216 }
1217 if let Some(err) = sections.errors.as_deref() {
1218 if !out.is_empty() {
1219 out.push('\n');
1220 }
1221 out.push_str(&crate::template_env::render(
1222 "doc_doxygen_errors.jinja",
1223 minijinja::context! { content => err.trim() },
1224 ));
1225 }
1226 if let Some(example) = sections.example.as_deref() {
1227 if !out.is_empty() {
1228 out.push('\n');
1229 }
1230 out.push_str("\\code\n");
1231 for line in example.lines() {
1232 let t = line.trim_start();
1233 if t.starts_with("```") {
1234 continue;
1235 }
1236 out.push_str(line);
1237 out.push('\n');
1238 }
1239 out.push_str("\\endcode");
1240 }
1241 out
1242}
1243
1244pub fn doc_first_paragraph_joined(doc: &str) -> String {
1257 doc.lines()
1258 .take_while(|l| !l.trim().is_empty())
1259 .map(str::trim)
1260 .collect::<Vec<_>>()
1261 .join(" ")
1262}
1263
1264#[derive(Copy, Clone, Debug, PartialEq, Eq)]
1269pub enum DocTarget {
1270 PhpDoc,
1272 JavaDoc,
1274 TsDoc,
1276 JsDoc,
1278 CSharpDoc,
1285}
1286
1287pub fn sanitize_rust_idioms(text: &str, target: DocTarget) -> String {
1314 sanitize_rust_idioms_inner(text, target, true)
1320}
1321
1322pub fn sanitize_rust_idioms_keep_sections(text: &str, target: DocTarget) -> String {
1328 sanitize_rust_idioms_inner(text, target, false)
1329}
1330
1331fn sanitize_rust_idioms_inner(text: &str, target: DocTarget, drop_csharp_sections: bool) -> String {
1332 let mut out = String::with_capacity(text.len());
1333 let mut in_rust_fence = false;
1334 let mut in_other_fence = false;
1335 let mut csharp_section_dropped = false;
1342
1343 for line in text.lines() {
1344 if csharp_section_dropped {
1345 continue;
1346 }
1347 let trimmed = line.trim_start();
1348 if drop_csharp_sections
1349 && matches!(target, DocTarget::CSharpDoc)
1350 && !in_rust_fence
1351 && !in_other_fence
1352 && is_rustdoc_section_heading(trimmed)
1353 {
1354 csharp_section_dropped = true;
1355 continue;
1356 }
1357
1358 if let Some(rest) = trimmed.strip_prefix("```") {
1360 if in_rust_fence {
1361 in_rust_fence = false;
1363 match target {
1364 DocTarget::TsDoc
1365 | DocTarget::JsDoc
1366 | DocTarget::CSharpDoc
1367 | DocTarget::PhpDoc
1368 | DocTarget::JavaDoc => {
1369 }
1371 }
1372 continue;
1373 }
1374 if in_other_fence {
1375 in_other_fence = false;
1377 out.push_str(line);
1378 out.push('\n');
1379 continue;
1380 }
1381 let lang = rest.split(',').next().unwrap_or("").trim();
1383 let is_rust = is_rust_fence_tag(lang);
1384 if is_rust {
1385 in_rust_fence = true;
1386 match target {
1387 DocTarget::TsDoc
1388 | DocTarget::JsDoc
1389 | DocTarget::CSharpDoc
1390 | DocTarget::PhpDoc
1391 | DocTarget::JavaDoc => {
1392 }
1395 }
1396 continue;
1397 }
1398 in_other_fence = true;
1400 out.push_str(line);
1401 out.push('\n');
1402 continue;
1403 }
1404
1405 if in_rust_fence {
1407 match target {
1408 DocTarget::TsDoc | DocTarget::JsDoc | DocTarget::CSharpDoc | DocTarget::PhpDoc | DocTarget::JavaDoc => {
1409 }
1411 }
1412 continue;
1413 }
1414
1415 if in_other_fence {
1417 out.push_str(line);
1418 out.push('\n');
1419 continue;
1420 }
1421
1422 let stripped_indent = line.trim_start();
1424 if stripped_indent.starts_with("#[") && stripped_indent.ends_with(']') {
1425 continue;
1427 }
1428
1429 let sanitized = apply_prose_transforms(line, target);
1431 out.push_str(&sanitized);
1432 out.push('\n');
1433 }
1434
1435 if out.ends_with('\n') && !text.ends_with('\n') {
1437 out.pop();
1438 }
1439
1440 if matches!(target, DocTarget::CSharpDoc) {
1447 out = xml_escape_for_csharp(&out);
1448 }
1449
1450 out
1451}
1452
1453fn is_rustdoc_section_heading(trimmed: &str) -> bool {
1457 let Some(rest) = trimmed.strip_prefix("# ") else {
1458 return false;
1459 };
1460 let head = rest.trim().to_ascii_lowercase();
1461 matches!(
1462 head.as_str(),
1463 "arguments" | "args" | "returns" | "errors" | "panics" | "safety" | "example" | "examples"
1464 )
1465}
1466
1467fn xml_escape_for_csharp(s: &str) -> String {
1474 let mut out = String::with_capacity(s.len());
1475 for ch in s.chars() {
1476 match ch {
1477 '&' => out.push_str("&"),
1478 '<' => out.push_str("<"),
1479 '>' => out.push_str(">"),
1480 _ => out.push(ch),
1481 }
1482 }
1483 out
1484}
1485
1486fn apply_prose_transforms(line: &str, target: DocTarget) -> String {
1499 let line = replace_intradoc_links(line, target);
1501
1502 let line = replace_path_separator(&line);
1505
1506 let line = strip_unwrap_expect(&line);
1509
1510 let segments = tokenize_backtick_spans(&line);
1512 let mut result = String::with_capacity(line.len());
1513 for (is_code, span) in segments {
1514 if is_code {
1515 result.push('`');
1516 result.push_str(span);
1517 result.push('`');
1518 } else {
1519 result.push_str(&transform_prose_segment(span, target));
1520 }
1521 }
1522 result
1523}
1524
1525fn tokenize_backtick_spans(line: &str) -> Vec<(bool, &str)> {
1531 let mut segments = Vec::new();
1532 let bytes = line.as_bytes();
1533 let mut start = 0;
1534 let mut i = 0;
1535
1536 while i < bytes.len() {
1537 if bytes[i] == b'`' {
1538 if i > start {
1540 segments.push((false, &line[start..i]));
1541 }
1542 let code_start = i + 1;
1544 let close = bytes[code_start..].iter().position(|&b| b == b'`');
1545 if let Some(offset) = close {
1546 let code_end = code_start + offset;
1547 segments.push((true, &line[code_start..code_end]));
1548 i = code_end + 1;
1549 start = i;
1550 } else {
1551 segments.push((false, &line[i..]));
1553 start = line.len();
1554 i = line.len();
1555 }
1556 } else {
1557 i += 1;
1558 }
1559 }
1560 if start < line.len() {
1561 segments.push((false, &line[start..]));
1562 }
1563 segments
1564}
1565
1566fn transform_prose_segment(text: &str, target: DocTarget) -> String {
1571 let mut s = text.to_string();
1572
1573 s = strip_inline_attributes(&s);
1575
1576 s = s.replace("pub fn ", "");
1578 s = s.replace("crate::", "");
1579 s = s.replace("&mut self", "");
1580 s = s.replace("&self", "");
1581
1582 s = strip_lifetime_and_bounds(&s);
1584
1585 s = replace_type_wrappers(&s, target);
1587
1588 s = replace_some_calls(&s);
1590
1591 s = replace_some_keyword_in_prose(&s);
1593
1594 s = replace_none_keyword(&s, target);
1596
1597 s
1602}
1603
1604#[inline]
1611fn advance_char(s: &str, out: &mut String, i: usize) -> usize {
1612 let ch = s[i..].chars().next().expect("valid UTF-8 position");
1616 out.push(ch);
1617 i + ch.len_utf8()
1618}
1619
1620fn replace_intradoc_links(s: &str, _target: DocTarget) -> String {
1623 let mut out = String::with_capacity(s.len());
1624 let bytes = s.as_bytes();
1625 let mut i = 0;
1626 while i < bytes.len() {
1627 if i + 1 < bytes.len() && bytes[i] == b'[' && bytes[i + 1] == b'`' {
1629 let search_start = i + 2;
1631 let mut found = false;
1632 let mut j = search_start;
1633 while j + 1 < bytes.len() {
1634 if bytes[j] == b'`' && bytes[j + 1] == b']' {
1635 let inner = &s[search_start..j];
1636 let converted = inner.replace("::", ".");
1638 out.push('`');
1639 out.push_str(&converted);
1640 out.push('`');
1641 i = j + 2;
1642 found = true;
1643 break;
1644 }
1645 j += 1;
1646 }
1647 if !found {
1648 i = advance_char(s, &mut out, i);
1649 }
1650 } else {
1651 i = advance_char(s, &mut out, i);
1652 }
1653 }
1654 out
1655}
1656
1657fn strip_inline_attributes(s: &str) -> String {
1660 let mut out = String::with_capacity(s.len());
1661 let bytes = s.as_bytes();
1662 let mut i = 0;
1663 while i < bytes.len() {
1664 if bytes[i] == b'#' && i + 1 < bytes.len() && bytes[i + 1] == b'[' {
1665 let mut depth = 0usize;
1667 let mut j = i + 1;
1668 while j < bytes.len() {
1669 if bytes[j] == b'[' {
1670 depth += 1;
1671 } else if bytes[j] == b']' {
1672 depth -= 1;
1673 if depth == 0 {
1674 i = j + 1;
1675 break;
1676 }
1677 }
1678 j += 1;
1679 }
1680 if depth != 0 {
1681 i = advance_char(s, &mut out, i);
1683 }
1684 } else {
1685 i = advance_char(s, &mut out, i);
1686 }
1687 }
1688 out
1689}
1690
1691fn strip_lifetime_and_bounds(s: &str) -> String {
1693 let mut out = s.to_string();
1695 out = regex_replace_all(&out, r"Send\s*\+\s*Sync", "");
1697 out = regex_replace_all(&out, r"Sync\s*\+\s*Send", "");
1698 out = regex_replace_word_boundary(&out, "Send", "");
1700 out = regex_replace_word_boundary(&out, "Sync", "");
1701 out = regex_replace_all(&out, r"'\s*static\b", "");
1703 out
1704}
1705
1706fn regex_replace_all(s: &str, pattern: &str, replacement: &str) -> String {
1712 match pattern {
1714 r"Send\s*\+\s*Sync" => replace_with_optional_spaces(s, "Send", "+", "Sync", replacement),
1715 r"Sync\s*\+\s*Send" => replace_with_optional_spaces(s, "Sync", "+", "Send", replacement),
1716 r"'\s*static\b" => replace_static_lifetime(s, replacement),
1717 _ => s.replace(pattern, replacement),
1718 }
1719}
1720
1721fn regex_replace_word_boundary(s: &str, keyword: &str, replacement: &str) -> String {
1723 let mut out = String::with_capacity(s.len());
1724 let klen = keyword.len();
1725 let bytes = s.as_bytes();
1726 let kbytes = keyword.as_bytes();
1727 if klen == 0 || klen > bytes.len() {
1728 return s.to_string();
1729 }
1730 let mut i = 0;
1731 while i + klen <= bytes.len() {
1732 if &bytes[i..i + klen] == kbytes {
1733 let before_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric() && bytes[i - 1] != b'_';
1734 let after_ok =
1735 i + klen >= bytes.len() || !bytes[i + klen].is_ascii_alphanumeric() && bytes[i + klen] != b'_';
1736 if before_ok && after_ok {
1737 out.push_str(replacement);
1738 i += klen;
1739 continue;
1740 }
1741 }
1742 i = advance_char(s, &mut out, i);
1743 }
1744 if i < bytes.len() {
1745 out.push_str(&s[i..]);
1746 }
1747 out
1748}
1749
1750fn replace_with_optional_spaces(s: &str, a: &str, op: &str, b: &str, replacement: &str) -> String {
1752 let mut out = String::with_capacity(s.len());
1753 let mut i = 0;
1754 let chars: Vec<char> = s.chars().collect();
1755 let total = chars.len();
1756
1757 while i < total {
1758 let a_chars: Vec<char> = a.chars().collect();
1760 let b_chars: Vec<char> = b.chars().collect();
1761 let op_chars: Vec<char> = op.chars().collect();
1762
1763 if chars[i..].starts_with(&a_chars) {
1764 let mut j = i + a_chars.len();
1765 while j < total && chars[j] == ' ' {
1767 j += 1;
1768 }
1769 if j + op_chars.len() <= total && chars[j..].starts_with(&op_chars) {
1771 let mut k = j + op_chars.len();
1772 while k < total && chars[k] == ' ' {
1774 k += 1;
1775 }
1776 if k + b_chars.len() <= total && chars[k..].starts_with(&b_chars) {
1778 out.push_str(replacement);
1779 i = k + b_chars.len();
1780 continue;
1781 }
1782 }
1783 }
1784 out.push(chars[i]);
1785 i += 1;
1786 }
1787 out
1788}
1789
1790fn replace_static_lifetime(s: &str, replacement: &str) -> String {
1792 let mut out = String::with_capacity(s.len());
1793 let bytes = s.as_bytes();
1794 let mut i = 0;
1795 while i < bytes.len() {
1796 if bytes[i] == b'\'' {
1797 let mut j = i + 1;
1799 while j < bytes.len() && bytes[j] == b' ' {
1800 j += 1;
1801 }
1802 let keyword = b"static";
1803 if bytes[j..].starts_with(keyword) {
1804 let end = j + keyword.len();
1805 let after_ok = end >= bytes.len() || !bytes[end].is_ascii_alphanumeric() && bytes[end] != b'_';
1807 if after_ok {
1808 out.push_str(replacement);
1809 i = end;
1810 continue;
1811 }
1812 }
1813 }
1814 i = advance_char(s, &mut out, i);
1815 }
1816 out
1817}
1818
1819fn replace_type_wrappers(s: &str, target: DocTarget) -> String {
1821 let mut out = s.to_string();
1823
1824 let vec_u8_replacement = match target {
1826 DocTarget::PhpDoc => "string",
1827 DocTarget::JavaDoc => "byte[]",
1828 DocTarget::TsDoc | DocTarget::JsDoc => "Uint8Array",
1829 DocTarget::CSharpDoc => "byte[]",
1830 };
1831 out = replace_generic1(&out, "Vec", "u8", vec_u8_replacement);
1832
1833 let map_replacement_fn = |k: &str, v: &str| match target {
1835 DocTarget::PhpDoc => format!("array<{k}, {v}>"),
1836 DocTarget::JavaDoc => format!("Map<{k}, {v}>"),
1837 DocTarget::TsDoc | DocTarget::JsDoc => format!("Record<{k}, {v}>"),
1838 DocTarget::CSharpDoc => format!("Dictionary<{k}, {v}>"),
1839 };
1840 out = replace_generic2(&out, "HashMap", &map_replacement_fn);
1841
1842 out = replace_generic1_passthrough(&out, "Vec", |inner| format!("{inner}[]"));
1844
1845 let option_replacement_fn = |inner: &str| match target {
1847 DocTarget::PhpDoc => format!("{inner}?"),
1848 DocTarget::JavaDoc => format!("{inner} | null"),
1849 DocTarget::TsDoc | DocTarget::JsDoc => format!("{inner} | undefined"),
1850 DocTarget::CSharpDoc => format!("{inner}?"),
1851 };
1852 out = replace_generic1_passthrough(&out, "Option", option_replacement_fn);
1853
1854 if matches!(target, DocTarget::CSharpDoc) {
1859 out = replace_generic2(&out, "Result", &|t: &str, _e: &str| t.to_string());
1860 }
1861
1862 for wrapper in &["Arc", "Box", "Mutex", "RwLock", "Rc", "Cell", "RefCell"] {
1864 out = replace_generic1_passthrough(&out, wrapper, |inner| inner.to_string());
1865 }
1866
1867 out
1868}
1869
1870fn replace_generic1(s: &str, name: &str, arg: &str, replacement: &str) -> String {
1872 let pattern = format!("{name}<{arg}>");
1873 s.replace(&pattern, replacement)
1874}
1875
1876fn replace_generic1_passthrough<F>(s: &str, name: &str, f: F) -> String
1880where
1881 F: Fn(&str) -> String,
1882{
1883 let mut out = String::with_capacity(s.len());
1884 let mut i = 0;
1885 let prefix = format!("{name}<");
1886 let pbytes = prefix.as_bytes();
1887 let bytes = s.as_bytes();
1888
1889 while i < bytes.len() {
1890 if bytes[i..].starts_with(pbytes) {
1891 let before_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric() && bytes[i - 1] != b'_';
1893 if before_ok {
1894 let inner_start = i + pbytes.len();
1895 let mut depth = 1usize;
1897 let mut j = inner_start;
1898 while j < bytes.len() {
1899 match bytes[j] {
1900 b'<' => depth += 1,
1901 b'>' => {
1902 depth -= 1;
1903 if depth == 0 {
1904 break;
1905 }
1906 }
1907 _ => {}
1908 }
1909 j += 1;
1910 }
1911 if depth == 0 && j < bytes.len() {
1912 let inner = &s[inner_start..j];
1913 out.push_str(&f(inner));
1914 i = j + 1;
1915 continue;
1916 }
1917 }
1918 }
1919 i = advance_char(s, &mut out, i);
1920 }
1921 out
1922}
1923
1924fn replace_generic2<F>(s: &str, name: &str, f: &F) -> String
1926where
1927 F: Fn(&str, &str) -> String,
1928{
1929 let mut out = String::with_capacity(s.len());
1930 let mut i = 0;
1931 let prefix = format!("{name}<");
1932 let pbytes = prefix.as_bytes();
1933 let bytes = s.as_bytes();
1934
1935 while i < bytes.len() {
1936 if bytes[i..].starts_with(pbytes) {
1937 let before_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric() && bytes[i - 1] != b'_';
1938 if before_ok {
1939 let inner_start = i + pbytes.len();
1940 let mut depth = 1usize;
1942 let mut j = inner_start;
1943 while j < bytes.len() {
1944 match bytes[j] {
1945 b'<' => depth += 1,
1946 b'>' => {
1947 depth -= 1;
1948 if depth == 0 {
1949 break;
1950 }
1951 }
1952 _ => {}
1953 }
1954 j += 1;
1955 }
1956 if depth == 0 && j < bytes.len() {
1957 let inner = &s[inner_start..j];
1958 let split = split_on_comma_at_top_level(inner);
1960 if let Some((k, v)) = split {
1961 out.push_str(&f(k.trim(), v.trim()));
1962 i = j + 1;
1963 continue;
1964 }
1965 }
1966 }
1967 }
1968 i = advance_char(s, &mut out, i);
1969 }
1970 out
1971}
1972
1973fn split_on_comma_at_top_level(s: &str) -> Option<(&str, &str)> {
1975 let mut depth = 0i32;
1976 for (idx, ch) in s.char_indices() {
1977 match ch {
1978 '<' => depth += 1,
1979 '>' => depth -= 1,
1980 ',' if depth == 0 => return Some((&s[..idx], &s[idx + 1..])),
1981 _ => {}
1982 }
1983 }
1984 None
1985}
1986
1987fn replace_some_calls(s: &str) -> String {
1989 let mut out = String::with_capacity(s.len());
1990 let bytes = s.as_bytes();
1991 let prefix = b"Some(";
1992 let mut i = 0;
1993
1994 while i < bytes.len() {
1995 if bytes[i..].starts_with(prefix) {
1996 let before_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric() && bytes[i - 1] != b'_';
1997 if before_ok {
1998 let arg_start = i + prefix.len();
1999 let mut depth = 1usize;
2001 let mut j = arg_start;
2002 while j < bytes.len() {
2003 match bytes[j] {
2004 b'(' => depth += 1,
2005 b')' => {
2006 depth -= 1;
2007 if depth == 0 {
2008 break;
2009 }
2010 }
2011 _ => {}
2012 }
2013 j += 1;
2014 }
2015 if depth == 0 && j < bytes.len() {
2016 let arg = &s[arg_start..j];
2017 out.push_str("the value (");
2018 out.push_str(arg);
2019 out.push(')');
2020 i = j + 1;
2021 continue;
2022 }
2023 }
2024 }
2025 i = advance_char(s, &mut out, i);
2026 }
2027 out
2028}
2029
2030fn replace_some_keyword_in_prose(s: &str) -> String {
2039 let keyword = b"Some ";
2040 let klen = keyword.len();
2041 let bytes = s.as_bytes();
2042 if klen >= bytes.len() {
2043 return s.to_string();
2044 }
2045 let mut out = String::with_capacity(s.len());
2046 let mut i = 0;
2047 while i + klen < bytes.len() {
2048 if &bytes[i..i + klen] == keyword {
2049 let before_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric() && bytes[i - 1] != b'_';
2050 let after_ok = bytes[i + klen].is_ascii_lowercase();
2051 if before_ok && after_ok {
2052 i += klen;
2053 continue;
2054 }
2055 }
2056 i = advance_char(s, &mut out, i);
2057 }
2058 if i < bytes.len() {
2059 out.push_str(&s[i..]);
2060 }
2061 out
2062}
2063
2064fn replace_none_keyword(s: &str, target: DocTarget) -> String {
2066 let replacement = match target {
2067 DocTarget::PhpDoc | DocTarget::JavaDoc | DocTarget::CSharpDoc => "null",
2068 DocTarget::TsDoc | DocTarget::JsDoc => "undefined",
2069 };
2070 let keyword = b"None";
2071 let klen = keyword.len();
2072 let mut out = String::with_capacity(s.len());
2073 let bytes = s.as_bytes();
2074 if klen > bytes.len() {
2075 return s.to_string();
2076 }
2077 let mut i = 0;
2078
2079 while i + klen <= bytes.len() {
2080 if &bytes[i..i + klen] == keyword {
2081 let before_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric() && bytes[i - 1] != b'_';
2082 let after_ok =
2083 i + klen >= bytes.len() || !bytes[i + klen].is_ascii_alphanumeric() && bytes[i + klen] != b'_';
2084 if before_ok && after_ok {
2085 out.push_str(replacement);
2086 i += klen;
2087 continue;
2088 }
2089 }
2090 i = advance_char(s, &mut out, i);
2091 }
2092 if i < bytes.len() {
2093 out.push_str(&s[i..]);
2094 }
2095 out
2096}
2097
2098fn replace_path_separator(s: &str) -> String {
2100 let mut out = String::with_capacity(s.len());
2101 let bytes = s.as_bytes();
2102 let mut i = 0;
2103
2104 while i < bytes.len() {
2105 if i + 1 < bytes.len() && bytes[i] == b':' && bytes[i + 1] == b':' {
2106 let before_ok = i > 0 && (bytes[i - 1].is_ascii_alphanumeric() || bytes[i - 1] == b'_');
2108 let after_ok = i + 2 < bytes.len() && (bytes[i + 2].is_ascii_alphanumeric() || bytes[i + 2] == b'_');
2109 if before_ok || after_ok {
2110 out.push('.');
2111 i += 2;
2112 continue;
2113 }
2114 }
2115 i = advance_char(s, &mut out, i);
2116 }
2117 out
2118}
2119
2120fn strip_unwrap_expect(s: &str) -> String {
2122 let mut out = String::with_capacity(s.len());
2123 let bytes = s.as_bytes();
2124 let mut i = 0;
2125
2126 while i < bytes.len() {
2127 if bytes[i..].starts_with(b".unwrap()") {
2129 i += b".unwrap()".len();
2130 continue;
2131 }
2132 if bytes[i..].starts_with(b".expect(") {
2134 let arg_start = i + b".expect(".len();
2135 let mut depth = 1usize;
2136 let mut j = arg_start;
2137 while j < bytes.len() {
2138 match bytes[j] {
2139 b'(' => depth += 1,
2140 b')' => {
2141 depth -= 1;
2142 if depth == 0 {
2143 break;
2144 }
2145 }
2146 _ => {}
2147 }
2148 j += 1;
2149 }
2150 if depth == 0 {
2151 i = j + 1;
2152 continue;
2153 }
2154 }
2155 i = advance_char(s, &mut out, i);
2156 }
2157 out
2158}
2159
2160#[cfg(test)]
2161mod tests {
2162 use super::*;
2163
2164 #[test]
2165 fn test_emit_phpdoc() {
2166 let mut out = String::new();
2167 emit_phpdoc(&mut out, "Simple documentation", " ", "TestException");
2168 assert!(out.contains("/**"));
2169 assert!(out.contains("Simple documentation"));
2170 assert!(out.contains("*/"));
2171 }
2172
2173 #[test]
2174 fn test_phpdoc_escaping() {
2175 let mut out = String::new();
2176 emit_phpdoc(&mut out, "Handle */ sequences", "", "TestException");
2177 assert!(out.contains("Handle * / sequences"));
2178 }
2179
2180 #[test]
2181 fn test_emit_csharp_doc() {
2182 let mut out = String::new();
2183 emit_csharp_doc(&mut out, "C# documentation", " ", "TestException");
2184 assert!(out.contains("<summary>"));
2185 assert!(out.contains("C# documentation"));
2186 assert!(out.contains("</summary>"));
2187 }
2188
2189 #[test]
2190 fn test_csharp_xml_escaping() {
2191 let mut out = String::new();
2192 emit_csharp_doc(&mut out, "foo < bar & baz > qux", "", "TestException");
2193 assert!(out.contains("foo < bar & baz > qux"));
2194 }
2195
2196 #[test]
2197 fn test_emit_elixir_doc() {
2198 let mut out = String::new();
2199 emit_elixir_doc(&mut out, "Elixir documentation");
2200 assert!(out.contains("@doc \"\"\""));
2201 assert!(out.contains("Elixir documentation"));
2202 assert!(out.contains("\"\"\""));
2203 }
2204
2205 #[test]
2206 fn test_elixir_heredoc_escaping() {
2207 let mut out = String::new();
2208 emit_elixir_doc(&mut out, "Handle \"\"\" sequences");
2209 assert!(out.contains("Handle \"\" \" sequences"));
2210 }
2211
2212 #[test]
2213 fn test_emit_roxygen() {
2214 let mut out = String::new();
2215 emit_roxygen(&mut out, "R documentation");
2216 assert!(out.contains("#' R documentation"));
2217 }
2218
2219 #[test]
2220 fn test_emit_swift_doc() {
2221 let mut out = String::new();
2222 emit_swift_doc(&mut out, "Swift documentation", " ");
2223 assert!(out.contains("/// Swift documentation"));
2224 }
2225
2226 #[test]
2227 fn test_emit_javadoc() {
2228 let mut out = String::new();
2229 emit_javadoc(&mut out, "Java documentation", " ");
2230 assert!(out.contains("/**"));
2231 assert!(out.contains("Java documentation"));
2232 assert!(out.contains("*/"));
2233 }
2234
2235 #[test]
2236 fn test_emit_kdoc() {
2237 let mut out = String::new();
2238 emit_kdoc(&mut out, "Kotlin documentation", " ");
2239 assert!(out.contains("/**"));
2240 assert!(out.contains("Kotlin documentation"));
2241 assert!(out.contains("*/"));
2242 }
2243
2244 #[test]
2245 fn test_emit_dartdoc() {
2246 let mut out = String::new();
2247 emit_dartdoc(&mut out, "Dart documentation", " ");
2248 assert!(out.contains("/// Dart documentation"));
2249 }
2250
2251 #[test]
2252 fn test_emit_gleam_doc() {
2253 let mut out = String::new();
2254 emit_gleam_doc(&mut out, "Gleam documentation", " ");
2255 assert!(out.contains("/// Gleam documentation"));
2256 }
2257
2258 #[test]
2259 fn test_emit_zig_doc() {
2260 let mut out = String::new();
2261 emit_zig_doc(&mut out, "Zig documentation", " ");
2262 assert!(out.contains("/// Zig documentation"));
2263 }
2264
2265 #[test]
2266 fn test_empty_doc_skipped() {
2267 let mut out = String::new();
2268 emit_phpdoc(&mut out, "", "", "TestException");
2269 emit_csharp_doc(&mut out, "", "", "TestException");
2270 emit_elixir_doc(&mut out, "");
2271 emit_roxygen(&mut out, "");
2272 emit_kdoc(&mut out, "", "");
2273 emit_dartdoc(&mut out, "", "");
2274 emit_gleam_doc(&mut out, "", "");
2275 emit_zig_doc(&mut out, "", "");
2276 assert!(out.is_empty());
2277 }
2278
2279 #[test]
2280 fn test_doc_first_paragraph_joined_single_line() {
2281 assert_eq!(doc_first_paragraph_joined("Simple doc."), "Simple doc.");
2282 }
2283
2284 #[test]
2285 fn test_doc_first_paragraph_joined_wrapped_sentence() {
2286 let doc = "Convert HTML to Markdown,\nreturning a result.";
2288 assert_eq!(
2289 doc_first_paragraph_joined(doc),
2290 "Convert HTML to Markdown, returning a result."
2291 );
2292 }
2293
2294 #[test]
2295 fn test_doc_first_paragraph_joined_stops_at_blank_line() {
2296 let doc = "First paragraph.\nStill first.\n\nSecond paragraph.";
2297 assert_eq!(doc_first_paragraph_joined(doc), "First paragraph. Still first.");
2298 }
2299
2300 #[test]
2301 fn test_doc_first_paragraph_joined_empty() {
2302 assert_eq!(doc_first_paragraph_joined(""), "");
2303 }
2304
2305 #[test]
2306 fn test_parse_rustdoc_sections_basic() {
2307 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.";
2308 let sections = parse_rustdoc_sections(doc);
2309 assert_eq!(sections.summary, "Extracts text from a file.");
2310 assert_eq!(sections.arguments.as_deref(), Some("* `path` - The file path."));
2311 assert_eq!(sections.returns.as_deref(), Some("The extracted text."));
2312 assert_eq!(sections.errors.as_deref(), Some("Returns `KreuzbergError` on failure."));
2313 assert!(sections.panics.is_none());
2314 }
2315
2316 #[test]
2317 fn test_parse_rustdoc_sections_example_with_fence() {
2318 let doc = "Run the thing.\n\n# Example\n\n```rust\nlet x = run();\n```";
2319 let sections = parse_rustdoc_sections(doc);
2320 assert_eq!(sections.summary, "Run the thing.");
2321 assert!(sections.example.as_ref().unwrap().contains("```rust"));
2322 assert!(sections.example.as_ref().unwrap().contains("let x = run();"));
2323 }
2324
2325 #[test]
2326 fn test_parse_rustdoc_sections_pound_inside_fence_is_not_a_heading() {
2327 let doc = "Summary.\n\n# Example\n\n```bash\n# install deps\nrun --foo\n```";
2331 let sections = parse_rustdoc_sections(doc);
2332 assert_eq!(sections.summary, "Summary.");
2333 assert!(sections.example.as_ref().unwrap().contains("# install deps"));
2334 }
2335
2336 #[test]
2337 fn test_parse_arguments_bullets_dash_separator() {
2338 let body = "* `path` - The file path.\n* `config` - Optional configuration.";
2339 let pairs = parse_arguments_bullets(body);
2340 assert_eq!(pairs.len(), 2);
2341 assert_eq!(pairs[0], ("path".to_string(), "The file path.".to_string()));
2342 assert_eq!(pairs[1], ("config".to_string(), "Optional configuration.".to_string()));
2343 }
2344
2345 #[test]
2346 fn test_parse_arguments_bullets_continuation_line() {
2347 let body = "* `path` - The file path,\n resolved relative to cwd.\n* `mode` - Open mode.";
2348 let pairs = parse_arguments_bullets(body);
2349 assert_eq!(pairs.len(), 2);
2350 assert_eq!(pairs[0].1, "The file path, resolved relative to cwd.");
2351 }
2352
2353 #[test]
2354 fn test_replace_fence_lang_rust_to_typescript() {
2355 let body = "```rust\nlet x = run();\n```";
2356 let out = replace_fence_lang(body, "typescript");
2357 assert!(out.starts_with("```typescript"));
2358 assert!(out.contains("let x = run();"));
2359 }
2360
2361 #[test]
2362 fn test_replace_fence_lang_preserves_attrs() {
2363 let body = "```rust,no_run\nlet x = run();\n```";
2364 let out = replace_fence_lang(body, "typescript");
2365 assert!(out.starts_with("```typescript,no_run"));
2366 }
2367
2368 #[test]
2369 fn test_replace_fence_lang_no_fence_unchanged() {
2370 let body = "Plain prose with `inline code`.";
2371 let out = replace_fence_lang(body, "typescript");
2372 assert_eq!(out, "Plain prose with `inline code`.");
2373 }
2374
2375 fn fixture_sections() -> RustdocSections {
2376 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```";
2377 parse_rustdoc_sections(doc)
2378 }
2379
2380 #[test]
2381 fn test_render_jsdoc_sections() {
2382 let sections = fixture_sections();
2383 let out = render_jsdoc_sections(§ions);
2384 assert!(out.starts_with("Extracts text from a file."));
2385 assert!(out.contains("@param path - The file path."));
2386 assert!(out.contains("@param config - Optional configuration."));
2387 assert!(out.contains("@returns The extracted text and metadata."));
2388 assert!(out.contains("@throws Returns an error when the file is unreadable."));
2389 assert!(!out.contains("@example"), "Rust example must not appear in TSDoc");
2391 assert!(!out.contains("```typescript"));
2392 assert!(!out.contains("```rust"));
2393 }
2394
2395 #[test]
2396 fn test_render_jsdoc_sections_preserves_typescript_example() {
2397 let doc = "Do something.\n\n# Example\n\n```typescript\nconst x = doSomething();\n```";
2398 let sections = parse_rustdoc_sections(doc);
2399 let out = render_jsdoc_sections(§ions);
2400 assert!(out.contains("@example"), "TypeScript example must be preserved");
2401 assert!(out.contains("```typescript"));
2402 }
2403
2404 #[test]
2405 fn test_render_javadoc_sections() {
2406 let sections = fixture_sections();
2407 let out = render_javadoc_sections(§ions, "KreuzbergRsException");
2408 assert!(out.contains("@param path The file path."));
2409 assert!(out.contains("@return The extracted text and metadata."));
2410 assert!(out.contains("@throws KreuzbergRsException Returns an error when the file is unreadable."));
2411 assert!(out.starts_with("Extracts text from a file."));
2414 }
2415
2416 #[test]
2417 fn test_render_csharp_xml_sections() {
2418 let sections = fixture_sections();
2419 let out = render_csharp_xml_sections(§ions, "KreuzbergException");
2420 assert!(out.contains("<summary>\nExtracts text from a file.\n</summary>"));
2421 assert!(out.contains("<param name=\"path\">The file path.</param>"));
2422 assert!(out.contains("<returns>The extracted text and metadata.</returns>"));
2423 assert!(out.contains("<exception cref=\"KreuzbergException\">"));
2424 assert!(out.contains("<example><code language=\"csharp\">"));
2425 assert!(out.contains("let result = extract"));
2426 }
2427
2428 #[test]
2429 fn test_render_phpdoc_sections() {
2430 let sections = fixture_sections();
2431 let out = render_phpdoc_sections(§ions, "KreuzbergException");
2432 assert!(out.contains("@param mixed $path The file path."));
2433 assert!(out.contains("@return The extracted text and metadata."));
2434 assert!(out.contains("@throws KreuzbergException"));
2435 assert!(!out.contains("```php"), "Rust example must not appear in PHPDoc");
2437 assert!(!out.contains("```rust"));
2438 }
2439
2440 #[test]
2441 fn test_render_phpdoc_sections_preserves_php_example() {
2442 let doc = "Do something.\n\n# Example\n\n```php\n$x = doSomething();\n```";
2443 let sections = parse_rustdoc_sections(doc);
2444 let out = render_phpdoc_sections(§ions, "MyException");
2445 assert!(out.contains("```php"), "PHP example must be preserved");
2446 }
2447
2448 #[test]
2449 fn test_render_doxygen_sections() {
2450 let sections = fixture_sections();
2451 let out = render_doxygen_sections(§ions);
2452 assert!(out.contains("\\param path The file path."));
2453 assert!(out.contains("\\return The extracted text and metadata."));
2454 assert!(out.contains("\\code"));
2455 assert!(out.contains("\\endcode"));
2456 }
2457
2458 #[test]
2459 fn test_emit_yard_doc_simple() {
2460 let mut out = String::new();
2461 emit_yard_doc(&mut out, "Simple Ruby documentation", " ");
2462 assert!(out.contains("# Simple Ruby documentation"));
2463 }
2464
2465 #[test]
2466 fn test_emit_yard_doc_empty() {
2467 let mut out = String::new();
2468 emit_yard_doc(&mut out, "", " ");
2469 assert!(out.is_empty());
2470 }
2471
2472 #[test]
2473 fn test_emit_yard_doc_with_sections() {
2474 let mut out = String::new();
2475 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.";
2476 emit_yard_doc(&mut out, doc, " ");
2477 assert!(out.contains("# Extracts text from a file."));
2478 assert!(out.contains("# @param path The file path."));
2479 assert!(out.contains("# @return The extracted text."));
2480 assert!(out.contains("# @raise Returns error on failure."));
2481 }
2482
2483 #[test]
2484 fn test_emit_c_doxygen_simple_prose() {
2485 let mut out = String::new();
2486 emit_c_doxygen(&mut out, "Free a string.", "");
2487 assert!(out.contains("/// Free a string."), "got: {out}");
2488 }
2489
2490 #[test]
2491 fn test_emit_c_doxygen_with_sections() {
2492 let mut out = String::new();
2493 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.";
2494 emit_c_doxygen(&mut out, doc, "");
2495 assert!(out.contains("/// Extract content from a file."));
2496 assert!(out.contains("/// \\param path Path to the file."));
2497 assert!(out.contains("/// \\param mode Read mode."));
2498 assert!(out.contains("/// \\return A newly allocated string the caller owns."));
2499 assert!(out.contains("/// \\note Returns null when the file is unreadable."));
2500 }
2501
2502 #[test]
2503 fn test_emit_c_doxygen_safety_section_maps_to_note() {
2504 let mut out = String::new();
2505 let doc = "Free a buffer.\n\n# Safety\n\nPointer must have been returned by this library.";
2506 emit_c_doxygen(&mut out, doc, "");
2507 assert!(out.contains("/// \\note SAFETY: Pointer must have been returned by this library."));
2508 }
2509
2510 #[test]
2511 fn test_emit_c_doxygen_example_renders_code_fence() {
2512 let mut out = String::new();
2513 let doc = "Demo.\n\n# Example\n\n```rust\nlet x = run();\n```";
2514 emit_c_doxygen(&mut out, doc, "");
2515 assert!(out.contains("/// \\code"));
2516 assert!(out.contains("/// \\endcode"));
2517 assert!(out.contains("let x = run();"));
2518 }
2519
2520 #[test]
2521 fn test_emit_c_doxygen_strips_markdown_links() {
2522 let mut out = String::new();
2523 let doc = "See [the docs](https://example.com/x) for details.";
2524 emit_c_doxygen(&mut out, doc, "");
2525 assert!(
2526 out.contains("the docs (https://example.com/x)"),
2527 "expected flattened link, got: {out}"
2528 );
2529 assert!(!out.contains("](https://"));
2530 }
2531
2532 #[test]
2533 fn test_emit_c_doxygen_word_wraps_long_lines() {
2534 let mut out = String::new();
2535 let long = "a ".repeat(80);
2536 emit_c_doxygen(&mut out, long.trim(), "");
2537 for line in out.lines() {
2538 let body = line.trim_start_matches("/// ");
2541 assert!(body.len() <= 100, "line too long ({}): {line}", body.len());
2542 }
2543 }
2544
2545 #[test]
2546 fn test_emit_c_doxygen_empty_input_is_noop() {
2547 let mut out = String::new();
2548 emit_c_doxygen(&mut out, "", "");
2549 emit_c_doxygen(&mut out, " \n\t ", "");
2550 assert!(out.is_empty());
2551 }
2552
2553 #[test]
2554 fn test_emit_c_doxygen_indent_applied() {
2555 let mut out = String::new();
2556 emit_c_doxygen(&mut out, "Hello.", " ");
2557 assert!(out.starts_with(" /// Hello."));
2558 }
2559
2560 #[test]
2561 fn test_render_yard_sections() {
2562 let sections = fixture_sections();
2563 let out = render_yard_sections(§ions);
2564 assert!(out.contains("@param path The file path."));
2565 assert!(out.contains("@return The extracted text and metadata."));
2566 assert!(out.contains("@raise Returns an error when the file is unreadable."));
2567 assert!(!out.contains("@example"), "Rust example must not appear in YARD");
2569 assert!(!out.contains("```ruby"));
2570 assert!(!out.contains("```rust"));
2571 }
2572
2573 #[test]
2574 fn test_render_yard_sections_preserves_ruby_example() {
2575 let doc = "Do something.\n\n# Example\n\n```ruby\nputs :hi\n```";
2576 let sections = parse_rustdoc_sections(doc);
2577 let out = render_yard_sections(§ions);
2578 assert!(out.contains("@example"), "Ruby example must be preserved");
2579 assert!(out.contains("```ruby"));
2580 }
2581
2582 #[test]
2585 fn example_for_target_rust_fenced_suppressed_for_php() {
2586 let example = "```rust\nlet x = 1;\n```";
2587 assert_eq!(
2588 example_for_target(example, "php"),
2589 None,
2590 "rust-fenced example must be omitted for PHP target"
2591 );
2592 }
2593
2594 #[test]
2595 fn example_for_target_bare_fence_defaults_to_rust_suppressed_for_ruby() {
2596 let example = "```\nlet x = 1;\n```";
2597 assert_eq!(
2598 example_for_target(example, "ruby"),
2599 None,
2600 "bare fence is treated as Rust and must be omitted for Ruby target"
2601 );
2602 }
2603
2604 #[test]
2605 fn example_for_target_php_example_preserved_for_php() {
2606 let example = "```php\n$x = 1;\n```";
2607 let result = example_for_target(example, "php");
2608 assert!(result.is_some(), "PHP example must be preserved for PHP target");
2609 assert!(result.unwrap().contains("```php"));
2610 }
2611
2612 #[test]
2613 fn example_for_target_ruby_example_preserved_for_ruby() {
2614 let example = "```ruby\nputs :hi\n```";
2615 let result = example_for_target(example, "ruby");
2616 assert!(result.is_some(), "Ruby example must be preserved for Ruby target");
2617 assert!(result.unwrap().contains("```ruby"));
2618 }
2619
2620 #[test]
2621 fn render_phpdoc_sections_with_rust_example_emits_no_at_example_block() {
2622 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```";
2623 let sections = parse_rustdoc_sections(doc);
2624 let out = render_phpdoc_sections(§ions, "HtmlToMarkdownException");
2625 assert!(!out.contains("```php"), "no PHP @example block for Rust source");
2626 assert!(!out.contains("```rust"), "raw Rust must not leak into PHPDoc");
2627 assert!(out.contains("@param"), "other sections must still be emitted");
2628 }
2629
2630 #[test]
2633 fn test_emit_kdoc_ktfmt_canonical_short_single_line() {
2634 let mut out = String::new();
2635 emit_kdoc_ktfmt_canonical(&mut out, "Simple doc.", "");
2636 assert_eq!(
2637 out, "/** Simple doc. */\n",
2638 "short single-line comment should collapse to canonical format"
2639 );
2640 }
2641
2642 #[test]
2643 fn test_emit_kdoc_ktfmt_canonical_short_with_indent() {
2644 let mut out = String::new();
2645 emit_kdoc_ktfmt_canonical(&mut out, "Text node (most frequent - 100+ per document)", " ");
2646 assert_eq!(out, " /** Text node (most frequent - 100+ per document) */\n");
2647 }
2648
2649 #[test]
2650 fn test_emit_kdoc_ktfmt_canonical_long_comment_uses_multiline() {
2651 let mut out = String::new();
2652 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";
2653 emit_kdoc_ktfmt_canonical(&mut out, long_text, "");
2654 assert!(out.contains("/**\n"), "long comment should start with newline");
2655 assert!(out.contains(" * "), "long comment should use multi-line format");
2656 assert!(out.contains(" */\n"), "long comment should end with newline");
2657 }
2658
2659 #[test]
2660 fn test_emit_kdoc_ktfmt_canonical_multiline_comment() {
2661 let mut out = String::new();
2662 let doc = "First line.\n\nSecond paragraph.";
2663 emit_kdoc_ktfmt_canonical(&mut out, doc, "");
2664 assert!(out.contains("/**\n"), "multi-paragraph should use multi-line format");
2665 assert!(out.contains(" * First line."), "first paragraph preserved");
2666 assert!(out.contains(" *\n"), "blank line preserved");
2667 assert!(out.contains(" * Second paragraph."), "second paragraph preserved");
2668 }
2669
2670 #[test]
2671 fn test_emit_kdoc_ktfmt_canonical_empty_doc() {
2672 let mut out = String::new();
2673 emit_kdoc_ktfmt_canonical(&mut out, "", "");
2674 assert!(out.is_empty(), "empty doc should produce no output");
2675 }
2676
2677 #[test]
2678 fn test_emit_kdoc_ktfmt_canonical_fits_within_100_chars() {
2679 let mut out = String::new();
2680 let content = "a".repeat(93);
2683 emit_kdoc_ktfmt_canonical(&mut out, &content, "");
2684 let line = out.lines().next().unwrap();
2685 assert_eq!(
2686 line.len(),
2687 100,
2688 "should fit exactly at 100 chars and use single-line format"
2689 );
2690 assert!(out.starts_with("/**"), "should use single-line format");
2691 }
2692
2693 #[test]
2694 fn test_emit_kdoc_ktfmt_canonical_exceeds_100_chars() {
2695 let mut out = String::new();
2696 let content = "a".repeat(94);
2698 emit_kdoc_ktfmt_canonical(&mut out, &content, "");
2699 assert!(
2700 out.contains("/**\n"),
2701 "should use multi-line format when exceeding 100 chars"
2702 );
2703 assert!(out.contains(" * "), "multi-line format with ` * ` prefix");
2704 }
2705
2706 #[test]
2707 fn test_emit_kdoc_ktfmt_canonical_respects_indent() {
2708 let mut out = String::new();
2709 let content = "a".repeat(89);
2711 emit_kdoc_ktfmt_canonical(&mut out, &content, " ");
2712 let line = out.lines().next().unwrap();
2713 assert_eq!(line.len(), 100, "should respect indent in 100-char calculation");
2714 assert!(line.starts_with(" /** "), "should include indent");
2715 }
2716
2717 #[test]
2718 fn test_emit_kdoc_ktfmt_canonical_real_world_enum_variant() {
2719 let mut out = String::new();
2720 emit_kdoc_ktfmt_canonical(&mut out, "Text node (most frequent - 100+ per document)", " ");
2721 assert!(out.starts_with(" /** "), "should preserve 4-space indent");
2723 assert!(out.contains(" */\n"), "should end with newline");
2724 let line_count = out.lines().count();
2726 assert_eq!(line_count, 1, "should be single-line format");
2727 }
2728
2729 #[test]
2730 fn test_emit_kdoc_ktfmt_canonical_real_world_data_class_field() {
2731 let mut out = String::new();
2732 let doc = "Heading style to use in Markdown output (ATX `#` or Setext underline).";
2733 emit_kdoc_ktfmt_canonical(&mut out, doc, " ");
2734 let line_count = out.lines().count();
2736 assert_eq!(line_count, 1, "should be single-line format");
2737 assert!(out.starts_with(" /** "), "should have correct indent");
2738 }
2739
2740 #[test]
2743 fn sanitize_intradoc_link_with_path_separator_java() {
2744 let input = "See [`ConversionOptions::builder()`] for details.";
2745 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
2746 assert!(out.contains("`ConversionOptions.builder()`"), "got: {out}");
2747 assert!(!out.contains("[`"), "brackets must be removed, got: {out}");
2748 }
2749
2750 #[test]
2751 fn sanitize_intradoc_link_simple_type_php() {
2752 let input = "Returns a [`ConversionResult`].";
2753 let out = sanitize_rust_idioms(input, DocTarget::PhpDoc);
2754 assert!(out.contains("`ConversionResult`"), "got: {out}");
2755 assert!(!out.contains("[`"), "got: {out}");
2756 }
2757
2758 #[test]
2759 fn sanitize_none_to_null_javadoc() {
2760 let input = "Returns None when no value is found.";
2761 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
2762 assert!(out.contains("null"), "got: {out}");
2763 assert!(!out.contains("None"), "got: {out}");
2764 }
2765
2766 #[test]
2767 fn sanitize_none_to_undefined_tsdoc() {
2768 let input = "Returns None if absent.";
2769 let out = sanitize_rust_idioms(input, DocTarget::TsDoc);
2770 assert!(out.contains("undefined"), "got: {out}");
2771 assert!(!out.contains("None"), "got: {out}");
2772 }
2773
2774 #[test]
2775 fn sanitize_some_x_to_the_value_x() {
2776 let input = "Pass Some(value) to enable.";
2777 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
2778 assert!(out.contains("the value (value)"), "got: {out}");
2779 assert!(!out.contains("Some("), "got: {out}");
2780 }
2781
2782 #[test]
2783 fn sanitize_bare_some_followed_by_lowercase_noun_is_dropped() {
2784 let input =
2786 "Only specified fields (Some values) will override existing options; None values leave the previous";
2787 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
2788 assert!(
2789 out.contains("(values)"),
2790 "bare `Some ` before lowercase noun must be stripped; got: {out}"
2791 );
2792 assert!(
2793 out.contains("null values"),
2794 "bare `None ` must also be replaced; got: {out}"
2795 );
2796 assert!(!out.contains("Some "), "Some prefix must not survive; got: {out}");
2797 }
2798
2799 #[test]
2800 fn sanitize_bare_some_does_not_touch_identifiers_or_uppercase_followers() {
2801 let cases = [
2803 "SomeType lives on.",
2804 "Some.method() returns Self.",
2805 "Some Title",
2806 "Some(x) is a value.",
2807 ];
2808 for case in cases {
2809 let out = sanitize_rust_idioms(case, DocTarget::JavaDoc);
2810 if case.starts_with("Some(") {
2813 assert!(out.contains("the value (x)"), "got: {out}");
2814 } else {
2815 assert!(out.contains("Some"), "Some must survive in {case:?}; got: {out}");
2816 }
2817 }
2818 }
2819
2820 #[test]
2821 fn sanitize_option_t_to_nullable_php() {
2822 let input = "The result is Option<String>.";
2823 let out = sanitize_rust_idioms(input, DocTarget::PhpDoc);
2824 assert!(out.contains("String?"), "got: {out}");
2825 assert!(!out.contains("Option<"), "got: {out}");
2826 }
2827
2828 #[test]
2829 fn sanitize_option_t_to_or_null_java() {
2830 let input = "The result is Option<String>.";
2831 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
2832 assert!(out.contains("String | null"), "got: {out}");
2833 }
2834
2835 #[test]
2836 fn sanitize_option_t_to_or_undefined_tsdoc() {
2837 let input = "The result is Option<String>.";
2838 let out = sanitize_rust_idioms(input, DocTarget::TsDoc);
2839 assert!(out.contains("String | undefined"), "got: {out}");
2840 }
2841
2842 #[test]
2843 fn sanitize_vec_u8_per_target() {
2844 assert!(sanitize_rust_idioms("Takes Vec<u8>.", DocTarget::PhpDoc).contains("string"));
2845 assert!(sanitize_rust_idioms("Takes Vec<u8>.", DocTarget::JavaDoc).contains("byte[]"));
2846 assert!(sanitize_rust_idioms("Takes Vec<u8>.", DocTarget::TsDoc).contains("Uint8Array"));
2847 assert!(sanitize_rust_idioms("Takes Vec<u8>.", DocTarget::JsDoc).contains("Uint8Array"));
2848 }
2849
2850 #[test]
2851 fn sanitize_vec_t_to_array() {
2852 let input = "Returns Vec<String>.";
2853 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
2854 assert!(out.contains("String[]"), "got: {out}");
2855 assert!(!out.contains("Vec<"), "got: {out}");
2856 }
2857
2858 #[test]
2859 fn sanitize_hashmap_per_target() {
2860 let input = "Uses HashMap<String, u32>.";
2861 assert!(sanitize_rust_idioms(input, DocTarget::PhpDoc).contains("array<String, u32>"));
2862 assert!(sanitize_rust_idioms(input, DocTarget::JavaDoc).contains("Map<String, u32>"));
2863 assert!(sanitize_rust_idioms(input, DocTarget::TsDoc).contains("Record<String, u32>"));
2864 }
2865
2866 #[test]
2867 fn sanitize_arc_wrapper_stripped() {
2868 let input = "Holds Arc<Config>.";
2869 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
2870 assert!(out.contains("Config"), "got: {out}");
2871 assert!(!out.contains("Arc<"), "got: {out}");
2872 }
2873
2874 #[test]
2875 fn sanitize_box_mutex_rwlock_rc_cell_refcell_stripped() {
2876 for wrapper in &["Box", "Mutex", "RwLock", "Rc", "Cell", "RefCell"] {
2877 let input = format!("Contains {wrapper}<Inner>.");
2878 let out = sanitize_rust_idioms(&input, DocTarget::JavaDoc);
2879 assert!(out.contains("Inner"), "wrapper {wrapper} not stripped, got: {out}");
2880 assert!(
2881 !out.contains(&format!("{wrapper}<")),
2882 "wrapper {wrapper} still present, got: {out}"
2883 );
2884 }
2885 }
2886
2887 #[test]
2888 fn sanitize_send_sync_stripped() {
2889 let input = "The type is Send + Sync.";
2890 let out = sanitize_rust_idioms(input, DocTarget::TsDoc);
2891 assert!(!out.contains("Send"), "got: {out}");
2892 assert!(!out.contains("Sync"), "got: {out}");
2893 }
2894
2895 #[test]
2896 fn sanitize_static_lifetime_stripped() {
2897 let input = "Requires 'static lifetime.";
2898 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
2899 assert!(!out.contains("'static"), "got: {out}");
2900 }
2901
2902 #[test]
2903 fn sanitize_pub_fn_stripped() {
2904 let input = "Calls pub fn convert().";
2905 let out = sanitize_rust_idioms(input, DocTarget::PhpDoc);
2906 assert!(!out.contains("pub fn"), "got: {out}");
2907 assert!(out.contains("convert()"), "got: {out}");
2908 }
2909
2910 #[test]
2911 fn sanitize_crate_prefix_stripped() {
2912 let input = "See crate::error::ConversionError.";
2913 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
2914 assert!(!out.contains("crate::"), "got: {out}");
2915 assert!(out.contains("error.ConversionError"), "got: {out}");
2916 }
2917
2918 #[test]
2919 fn sanitize_unwrap_expect_stripped() {
2920 let input = "Call result.unwrap() or result.expect(\"msg\").";
2921 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
2922 assert!(!out.contains(".unwrap()"), "got: {out}");
2923 assert!(!out.contains(".expect("), "got: {out}");
2924 }
2925
2926 #[test]
2927 fn sanitize_no_mutation_inside_backticks() {
2928 let input = "Use `None` as the argument.";
2930 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
2931 assert!(out.contains("`None`"), "backtick span must be preserved, got: {out}");
2932 }
2933
2934 #[test]
2935 fn sanitize_rust_fence_dropped_for_tsdoc() {
2936 let input = "Intro.\n\n```rust\nlet x = 1;\n```\n\nTrailer.";
2937 let out = sanitize_rust_idioms(input, DocTarget::TsDoc);
2938 assert!(
2939 !out.contains("let x = 1;"),
2940 "rust fence content must be dropped, got: {out}"
2941 );
2942 assert!(!out.contains("```rust"), "got: {out}");
2943 assert!(out.contains("Trailer."), "text after fence must survive, got: {out}");
2944 }
2945
2946 #[test]
2947 fn sanitize_rust_fence_dropped_for_java() {
2948 let input = "Intro.\n\n```rust\nlet x = 1;\n```\n\nTrailer.";
2949 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
2950 assert!(
2952 !out.contains("let x = 1;"),
2953 "fence content must be dropped for Java, got: {out}"
2954 );
2955 assert!(!out.contains("```"), "fence markers must be dropped, got: {out}");
2956 assert!(out.contains("Intro."), "prose before fence kept: {out}");
2957 assert!(out.contains("Trailer."), "prose after fence kept: {out}");
2958 }
2959
2960 #[test]
2961 fn sanitize_non_rust_fence_passed_through() {
2962 let input = "Example:\n\n```typescript\nconst x = 1;\n```";
2963 let out = sanitize_rust_idioms(input, DocTarget::TsDoc);
2964 assert!(out.contains("```typescript"), "non-rust fence must survive, got: {out}");
2965 assert!(out.contains("const x = 1;"), "got: {out}");
2966 }
2967
2968 #[test]
2969 fn sanitize_backtick_code_span_not_mutated_option() {
2970 let input = "The type is `Option<String>`.";
2972 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
2973 assert!(
2975 out.contains("`Option<String>`"),
2976 "code span must be preserved, got: {out}"
2977 );
2978 }
2979
2980 #[test]
2981 fn sanitize_idempotent() {
2982 let input = "Returns None when Vec<String> is empty.";
2984 let once = sanitize_rust_idioms(input, DocTarget::JavaDoc);
2985 let twice = sanitize_rust_idioms(&once, DocTarget::JavaDoc);
2986 assert_eq!(once, twice, "sanitize_rust_idioms should be idempotent");
2987 }
2988
2989 #[test]
2990 fn sanitize_multiline_prose() {
2991 let input = "Convert HTML to Markdown.\n\nReturns None on failure.\nUse Option<String> for the result.";
2992 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
2993 assert!(out.contains("null"), "None must be replaced on line 2, got: {out}");
2994 assert!(
2995 out.contains("String | null"),
2996 "Option<String> must be replaced on line 3, got: {out}"
2997 );
2998 }
2999
3000 #[test]
3001 fn sanitize_attribute_line_dropped() {
3002 let input = "#[derive(Debug, Clone)]\nSome documentation.";
3003 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
3004 assert!(!out.contains("#[derive("), "attribute line must be dropped, got: {out}");
3005 assert!(out.contains("documentation."), "prose must survive, got: {out}");
3008 }
3009
3010 #[test]
3011 fn sanitize_path_separator_in_prose() {
3012 let input = "See std::collections::HashMap for details.";
3013 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
3014 assert!(out.contains("std.collections.HashMap"), ":: must become ., got: {out}");
3015 }
3016
3017 #[test]
3018 fn sanitize_none_not_replaced_inside_identifier() {
3019 let input = "Unlike NoneType in Python.";
3021 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
3022 assert!(out.contains("NoneType"), "NoneType must not be replaced, got: {out}");
3023 }
3024
3025 #[test]
3028 fn sanitize_csharp_drops_rust_section_headings_and_example_body() {
3029 let input = "Convert error to HTTP status code\n\n\
3033 Maps GraphQL error types to status codes.\n\n\
3034 # Examples\n\n\
3035 ```ignore\n\
3036 use spikard_graphql::error::GraphQLError;\n\
3037 let error = GraphQLError::AuthenticationError(\"Invalid token\".to_string());\n\
3038 assert_eq!(error.status_code(), 401);\n\
3039 ```\n";
3040 let out = sanitize_rust_idioms(input, DocTarget::CSharpDoc);
3041 assert!(
3042 out.contains("Convert error to HTTP status code"),
3043 "summary preserved: {out}"
3044 );
3045 assert!(out.contains("Maps GraphQL error types"), "prose preserved: {out}");
3046 assert!(!out.contains("# Examples"), "heading dropped: {out}");
3047 assert!(!out.contains("```"), "code fence dropped: {out}");
3048 assert!(!out.contains("Self::error_code"), "Self::method dropped: {out}");
3049 assert!(
3050 !out.contains("GraphQLError::AuthenticationError"),
3051 "rust path dropped: {out}"
3052 );
3053 }
3054
3055 #[test]
3056 fn sanitize_csharp_intradoc_link_with_path_separator() {
3057 let input = "See [`Self::error_code`] for the variant codes.";
3058 let out = sanitize_rust_idioms(input, DocTarget::CSharpDoc);
3059 assert!(out.contains("`Self.error_code`"), "intra-doc link normalised: {out}");
3060 assert!(!out.contains("[`"), "square brackets removed: {out}");
3061 assert!(!out.contains("::"), ":: replaced with .: {out}");
3062 }
3063
3064 #[test]
3065 fn sanitize_csharp_result_type_keeps_success_drops_error() {
3066 let input = "Returns Result<String, ConversionError> on failure.";
3067 let out = sanitize_rust_idioms(input, DocTarget::CSharpDoc);
3068 assert!(out.contains("String"), "success type kept: {out}");
3069 assert!(!out.contains("Result<"), "Result wrapper dropped: {out}");
3070 assert!(!out.contains("ConversionError"), "error type dropped: {out}");
3071 }
3072
3073 #[test]
3074 fn sanitize_csharp_option_becomes_nullable() {
3075 let input = "Returns Option<String>.";
3076 let out = sanitize_rust_idioms(input, DocTarget::CSharpDoc);
3077 assert!(out.contains("String?"), "Option<T> -> T?: {out}");
3079 assert!(!out.contains("Option<"), "Option dropped: {out}");
3080 }
3081
3082 #[test]
3083 fn sanitize_csharp_vec_u8_becomes_byte_array() {
3084 let input = "Accepts Vec<u8>.";
3085 let out = sanitize_rust_idioms(input, DocTarget::CSharpDoc);
3086 assert!(out.contains("byte[]"), "Vec<u8> -> byte[]: {out}");
3088 }
3089
3090 #[test]
3091 fn sanitize_csharp_hashmap_becomes_dictionary() {
3092 let input = "Holds HashMap<String, u32>.";
3093 let out = sanitize_rust_idioms(input, DocTarget::CSharpDoc);
3094 assert!(
3096 out.contains("Dictionary<String, u32>"),
3097 "HashMap -> Dictionary with XML-escaped brackets: {out}"
3098 );
3099 }
3100
3101 #[test]
3102 fn sanitize_csharp_none_to_null() {
3103 let input = "Returns None on miss.";
3104 let out = sanitize_rust_idioms(input, DocTarget::CSharpDoc);
3105 assert!(out.contains("null"), "None -> null: {out}");
3106 assert!(!out.contains("None"), "None replaced: {out}");
3107 }
3108
3109 #[test]
3110 fn sanitize_csharp_escapes_raw_angle_brackets_and_amp() {
3111 let input = "Accepts Box<dyn Trait> and combines a & b.";
3115 let out = sanitize_rust_idioms(input, DocTarget::CSharpDoc);
3116 assert!(out.contains("dyn Trait"), "Box<T> stripped: {out}");
3118 assert!(out.contains("&"), "ampersand escaped: {out}");
3119 }
3120
3121 #[test]
3122 fn sanitize_csharp_drops_rust_code_fence_entirely() {
3123 let input = "Intro.\n\n```rust\nlet x: Vec<u8> = vec![];\n```\n\nTrailer.";
3124 let out = sanitize_rust_idioms(input, DocTarget::CSharpDoc);
3125 assert!(!out.contains("let x"), "code fence body dropped: {out}");
3126 assert!(!out.contains("```"), "fence markers dropped: {out}");
3127 assert!(out.contains("Intro."), "prose before fence kept: {out}");
3128 assert!(out.contains("Trailer."), "prose after fence kept: {out}");
3129 }
3130
3131 #[test]
3132 fn sanitize_csharp_keep_sections_does_not_drop_headings() {
3133 let input = "Summary.\n\n# Arguments\n\n* `name` - the value.";
3136 let out = sanitize_rust_idioms_keep_sections(input, DocTarget::CSharpDoc);
3137 assert!(out.contains("# Arguments"), "heading preserved: {out}");
3138 assert!(out.contains("name"), "body preserved: {out}");
3139 }
3140
3141 #[test]
3142 fn sanitize_csharp_idempotent() {
3143 let input = "Returns Option<String> or None.";
3144 let once = sanitize_rust_idioms(input, DocTarget::CSharpDoc);
3145 let twice = sanitize_rust_idioms(&once, DocTarget::CSharpDoc);
3146 assert_eq!(once, twice, "CSharpDoc sanitisation must be idempotent");
3147 }
3148
3149 #[test]
3150 fn sanitize_phpdoc_drops_unmarked_rust_code_fences() {
3151 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```";
3154 let out = sanitize_rust_idioms(input, DocTarget::PhpDoc);
3155 assert!(
3156 !out.contains("use tree_sitter_language_pack"),
3157 "Rust use stmt dropped: {out}"
3158 );
3159 assert!(!out.contains("assert_eq!"), "Rust code dropped: {out}");
3160 assert!(!out.contains("```"), "fence markers dropped: {out}");
3161 assert!(out.contains("Detect language name"), "prose before fence kept: {out}");
3162 assert!(out.contains("unrecognized extensions"), "prose kept: {out}");
3163 }
3164
3165 #[test]
3166 fn sanitize_javadoc_drops_unmarked_rust_code_fences() {
3167 let input = "Process a file.\n\n```\nlet result = process(\"def hello(): pass\", &config).unwrap();\n```";
3170 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
3171 assert!(!out.contains("unwrap"), "Rust unwrap dropped: {out}");
3172 assert!(!out.contains("```"), "fence markers dropped: {out}");
3173 assert!(out.contains("Process a file"), "prose kept: {out}");
3174 }
3175
3176 #[test]
3177 fn sanitize_phpdoc_drops_explicit_rust_fences() {
3178 let input = "Summary.\n\n```rust\nuse std::path::PathBuf;\nlet p = PathBuf::from(\"/tmp\");\n```";
3180 let out = sanitize_rust_idioms(input, DocTarget::PhpDoc);
3181 assert!(!out.contains("use std::"), "Rust code dropped: {out}");
3182 assert!(!out.contains("PathBuf"), "Rust types dropped: {out}");
3183 assert!(!out.contains("```"), "fence markers dropped: {out}");
3184 assert!(out.contains("Summary"), "prose kept: {out}");
3185 }
3186
3187 #[test]
3190 fn sanitize_no_run_fence_dropped_for_tsdoc() {
3191 let input = "Intro.\n\n```no_run\nuse foo::bar;\nbar::init();\n```\n\nTrailer.";
3192 let out = sanitize_rust_idioms(input, DocTarget::TsDoc);
3193 assert!(!out.contains("use foo::bar"), "no_run fence body dropped: {out}");
3194 assert!(!out.contains("```"), "fence markers dropped: {out}");
3195 assert!(out.contains("Intro."), "prose before fence kept: {out}");
3196 assert!(out.contains("Trailer."), "prose after fence kept: {out}");
3197 }
3198
3199 #[test]
3200 fn sanitize_ignore_fence_dropped_for_phpdoc() {
3201 let input = "Summary.\n\n```ignore\nlet x = 1;\n// this would not compile\n```";
3202 let out = sanitize_rust_idioms(input, DocTarget::PhpDoc);
3203 assert!(!out.contains("let x = 1"), "ignore fence body dropped: {out}");
3204 assert!(!out.contains("```"), "fence markers dropped: {out}");
3205 assert!(out.contains("Summary"), "prose kept: {out}");
3206 }
3207
3208 #[test]
3209 fn sanitize_should_panic_fence_dropped_for_javadoc() {
3210 let input = "Panics on null.\n\n```should_panic\nlet _ = parse(null);\n```";
3211 let out = sanitize_rust_idioms(input, DocTarget::JavaDoc);
3212 assert!(!out.contains("parse(null)"), "should_panic fence body dropped: {out}");
3213 assert!(!out.contains("```"), "fence markers dropped: {out}");
3214 assert!(out.contains("Panics on null"), "prose kept: {out}");
3215 }
3216
3217 #[test]
3218 fn sanitize_compile_fail_fence_dropped_for_csharp() {
3219 let input = "Type safety demo.\n\n```compile_fail\nlet x: u32 = \"hello\";\n```";
3220 let out = sanitize_rust_idioms(input, DocTarget::CSharpDoc);
3221 assert!(!out.contains("let x:"), "compile_fail fence body dropped: {out}");
3222 assert!(!out.contains("```"), "fence markers dropped: {out}");
3223 assert!(out.contains("Type safety demo"), "prose kept: {out}");
3224 }
3225
3226 #[test]
3227 fn sanitize_edition_fence_dropped_for_tsdoc() {
3228 let input = "Edition example.\n\n```edition2021\nuse std::fmt;\n```\n\nSee also edition2018.";
3229 let out = sanitize_rust_idioms(input, DocTarget::TsDoc);
3230 assert!(!out.contains("use std::fmt"), "edition2021 fence body dropped: {out}");
3231 assert!(!out.contains("```"), "fence markers dropped: {out}");
3232 assert!(out.contains("Edition example"), "prose kept: {out}");
3233 }
3234
3235 #[test]
3236 fn sanitize_python_fence_preserved_for_tsdoc() {
3237 let input = "Example:\n\n```python\nimport foo\nfoo.bar()\n```";
3239 let out = sanitize_rust_idioms(input, DocTarget::TsDoc);
3240 assert!(out.contains("```python"), "python fence preserved: {out}");
3241 assert!(out.contains("import foo"), "python body preserved: {out}");
3242 }
3243
3244 #[test]
3245 fn sanitize_javascript_fence_preserved_for_phpdoc() {
3246 let input = "Usage:\n\n```javascript\nconst x = require('foo');\n```";
3247 let out = sanitize_rust_idioms(input, DocTarget::PhpDoc);
3248 assert!(out.contains("```javascript"), "javascript fence preserved: {out}");
3249 assert!(out.contains("require('foo')"), "javascript body preserved: {out}");
3250 }
3251
3252 #[test]
3253 fn example_for_target_no_run_fence_suppressed_for_typescript() {
3254 let example = "```no_run\nuse tree_sitter_language_pack::available_languages;\nlet langs = available_languages();\n```";
3255 assert_eq!(
3256 example_for_target(example, "typescript"),
3257 None,
3258 "no_run fence must be treated as Rust and suppressed for TypeScript"
3259 );
3260 }
3261
3262 #[test]
3263 fn example_for_target_ignore_fence_suppressed_for_php() {
3264 let example = "```ignore\nlet x = 1;\n```";
3265 assert_eq!(
3266 example_for_target(example, "php"),
3267 None,
3268 "ignore fence must be treated as Rust and suppressed for PHP"
3269 );
3270 }
3271
3272 #[test]
3273 fn example_for_target_compile_fail_fence_suppressed_for_java() {
3274 let example = "```compile_fail\nlet x: u32 = \"wrong\";\n```";
3275 assert_eq!(
3276 example_for_target(example, "java"),
3277 None,
3278 "compile_fail fence must be treated as Rust and suppressed for Java"
3279 );
3280 }
3281
3282 #[test]
3283 fn example_for_target_should_panic_fence_suppressed_for_ruby() {
3284 let example = "```should_panic\nlet _ = parse(None);\n```";
3285 assert_eq!(
3286 example_for_target(example, "ruby"),
3287 None,
3288 "should_panic fence must be treated as Rust and suppressed for Ruby"
3289 );
3290 }
3291
3292 #[test]
3293 fn example_for_target_edition_fence_suppressed_for_php() {
3294 let example = "```edition2021\nuse std::fmt;\n```";
3295 assert_eq!(
3296 example_for_target(example, "php"),
3297 None,
3298 "edition2021 fence must be treated as Rust and suppressed for PHP"
3299 );
3300 }
3301
3302 #[test]
3303 fn example_for_target_python_fence_preserved() {
3304 let example = "```python\nimport foo\n```";
3305 let result = example_for_target(example, "php");
3306 assert!(result.is_some(), "python fence must be preserved for PHP target");
3307 }
3308}