docstring.rs 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. //! getPrecedingDocstring / cleanCommentMarkers — faithful port of
  2. //! src/extraction/tree-sitter-helpers.ts (#780 wrapper-climb semantics).
  3. use regex::Regex;
  4. use std::sync::OnceLock;
  5. use tree_sitter::Node;
  6. /// DOCSTRING_WRAPPER_TYPES (tree-sitter-helpers.ts).
  7. fn is_wrapper(kind: &str) -> bool {
  8. matches!(
  9. kind,
  10. "export_statement"
  11. | "decorated_definition"
  12. | "lexical_declaration"
  13. | "variable_declaration"
  14. | "variable_declarator"
  15. | "ambient_declaration"
  16. )
  17. }
  18. fn is_comment(kind: &str) -> bool {
  19. matches!(
  20. kind,
  21. "comment" | "line_comment" | "block_comment" | "documentation_comment"
  22. )
  23. }
  24. struct Cleaners {
  25. block_open: Regex,
  26. block_close: Regex,
  27. lua_open: Regex,
  28. lua_close: Regex,
  29. paren_star_open: Regex,
  30. paren_star_close: Regex,
  31. brace_open: Regex,
  32. brace_close: Regex,
  33. slashes: Regex,
  34. dashes: Regex,
  35. hash: Regex,
  36. percent: Regex,
  37. star_cont: Regex,
  38. }
  39. fn cleaners() -> &'static Cleaners {
  40. static C: OnceLock<Cleaners> = OnceLock::new();
  41. C.get_or_init(|| Cleaners {
  42. block_open: Regex::new(r"^/\*+!?").unwrap(),
  43. block_close: Regex::new(r"\*+/$").unwrap(),
  44. lua_open: Regex::new(r"^--\[=*\[").unwrap(),
  45. lua_close: Regex::new(r"\]=*\]$").unwrap(),
  46. paren_star_open: Regex::new(r"^\(\*").unwrap(),
  47. paren_star_close: Regex::new(r"\*\)$").unwrap(),
  48. brace_open: Regex::new(r"^\{").unwrap(),
  49. brace_close: Regex::new(r"\}$").unwrap(),
  50. slashes: Regex::new(r"\A//[/!]?\s?").unwrap(),
  51. dashes: Regex::new(r"\A--\s?").unwrap(),
  52. hash: Regex::new(r"\A#\s?").unwrap(),
  53. percent: Regex::new(r"\A%+\s?").unwrap(),
  54. star_cont: Regex::new(r"\A\s*\*\s?").unwrap(),
  55. })
  56. }
  57. /// JS multiline `^` anchors after \n, \r, U+2028, U+2029; the regex crate's
  58. /// `(?m)^` anchors after `\n` only. On CRLF content the JS engine finds a line
  59. /// start after the `\r`, so a greedy leading `\s*` (the block-continuation
  60. /// rule) consumes the `\n` and leaves the bare `\r` in the docstring —
  61. /// byte-parity on CRLF checkouts (every Windows autocrlf clone) depends on
  62. /// reproducing exactly that.
  63. fn is_js_line_terminator(ch: char) -> bool {
  64. matches!(ch, '\n' | '\r' | '\u{2028}' | '\u{2029}')
  65. }
  66. /// JS-semantics `str.replace(/^<pat>/gm, "")`: try the \A-anchored `pat` at
  67. /// position 0 and after every JS line terminator, left to right, resuming
  68. /// after each match's end — a faithful /g replace. (Remaining known
  69. /// divergence: JS `\s` includes U+FEFF, Rust's does not; an embedded BOM
  70. /// inside a comment is accepted as unreachable.)
  71. fn js_multiline_strip(s: &str, pat: &Regex) -> String {
  72. let mut out = String::with_capacity(s.len());
  73. let mut last = 0usize;
  74. let mut pos = 0usize;
  75. while pos <= s.len() {
  76. let at_line_start = pos == 0
  77. || s[..pos].chars().next_back().is_some_and(is_js_line_terminator);
  78. if at_line_start {
  79. if let Some(m) = pat.find(&s[pos..]) {
  80. if !m.is_empty() {
  81. out.push_str(&s[last..pos]);
  82. last = pos + m.end();
  83. pos = last;
  84. continue;
  85. }
  86. }
  87. }
  88. match s[pos..].chars().next() {
  89. Some(c) => pos += c.len_utf8(),
  90. None => break,
  91. }
  92. }
  93. out.push_str(&s[last..]);
  94. out
  95. }
  96. /// cleanCommentMarkers — strip comment syntax, keep the prose.
  97. pub fn clean_comment_markers(comment: &str) -> String {
  98. let c = cleaners();
  99. let mut s = comment.trim().to_string();
  100. if s.starts_with("/*") {
  101. s = c.block_open.replace(&s, "").into_owned();
  102. s = c.block_close.replace(&s, "").into_owned();
  103. } else if s.starts_with("--[") {
  104. s = c.lua_open.replace(&s, "").into_owned();
  105. s = c.lua_close.replace(&s, "").into_owned();
  106. } else if s.starts_with("(*") {
  107. s = c.paren_star_open.replace(&s, "").into_owned();
  108. s = c.paren_star_close.replace(&s, "").into_owned();
  109. } else if s.starts_with('{') {
  110. s = c.brace_open.replace(&s, "").into_owned();
  111. s = c.brace_close.replace(&s, "").into_owned();
  112. }
  113. s = js_multiline_strip(&s, &c.slashes);
  114. s = js_multiline_strip(&s, &c.dashes);
  115. s = js_multiline_strip(&s, &c.hash);
  116. s = js_multiline_strip(&s, &c.percent);
  117. s = js_multiline_strip(&s, &c.star_cont);
  118. s.trim().to_string()
  119. }
  120. /// getPrecedingDocstring — collect the comment run immediately preceding the
  121. /// node (climbing out of declaration wrappers first), cleaned and joined.
  122. /// Returns None when there is no preceding comment (a PRESENT-but-empty
  123. /// docstring after cleaning still returns Some(""), matching the TS helper).
  124. pub fn preceding_docstring(node: Node, src: &str) -> Option<String> {
  125. let mut anchor = node;
  126. while let Some(parent) = anchor.parent() {
  127. if is_wrapper(parent.kind()) {
  128. anchor = parent;
  129. } else {
  130. break;
  131. }
  132. }
  133. let mut comments: Vec<&str> = Vec::new();
  134. let mut sibling = anchor.prev_named_sibling();
  135. while let Some(s) = sibling {
  136. if is_comment(s.kind()) {
  137. comments.push(&src[s.byte_range()]);
  138. sibling = s.prev_named_sibling();
  139. } else {
  140. break;
  141. }
  142. }
  143. if comments.is_empty() {
  144. return None;
  145. }
  146. comments.reverse(); // collected nearest-first; TS unshifts to keep source order
  147. Some(
  148. comments
  149. .iter()
  150. .map(|c| clean_comment_markers(c))
  151. .collect::<Vec<_>>()
  152. .join("\n")
  153. .trim()
  154. .to_string(),
  155. )
  156. }
  157. #[cfg(test)]
  158. mod tests {
  159. use super::*;
  160. #[test]
  161. fn strips_line_and_block_markers() {
  162. assert_eq!(clean_comment_markers("// hello"), "hello");
  163. assert_eq!(clean_comment_markers("/// doc line"), "doc line");
  164. assert_eq!(
  165. clean_comment_markers("/**\n * Adds things.\n * @param a first\n */"),
  166. "Adds things.\n@param a first"
  167. );
  168. }
  169. /// CRLF parity with the JS reference: multiline `^` matches after `\r`,
  170. /// so the block-continuation `\s*` eats the `\n` and the bare `\r`
  171. /// survives in the cleaned docstring (pinned against the wasm extractor
  172. /// on a CRLF checkout — the Windows autocrlf shape).
  173. #[test]
  174. fn crlf_matches_js_reference() {
  175. assert_eq!(
  176. clean_comment_markers("/**\r\n * Class docs.\r\n * Multi-line.\r\n */"),
  177. "Class docs.\rMulti-line."
  178. );
  179. assert_eq!(
  180. clean_comment_markers("// a\r\n// b"),
  181. "a\r\nb"
  182. );
  183. }
  184. }