|
1 | 1 | use std::collections::HashMap; |
2 | 2 |
|
3 | 3 | use crop::Rope; |
4 | | -use proc_macro2::{LineColumn, Span, TokenStream}; |
| 4 | +use proc_macro2::LineColumn; |
5 | 5 |
|
6 | | -/// Extract comments and empty lines from the gaps between tokens. |
| 6 | +/// Extract standalone comments (comments on their own line, no code before them). |
7 | 7 | /// |
8 | | -/// This function traverses a TokenStream and examines the text between consecutive |
9 | | -/// tokens. When tokens appear on different lines, it extracts any comments (lines |
10 | | -/// starting with //) and records empty lines. |
| 8 | +/// Uses a scan_gap-like approach but only extracts comments that are on their own line. |
| 9 | +/// This avoids issues with inline comments and comments inside nested structures |
| 10 | +/// which would be lost during prettyplease formatting. |
11 | 11 | /// |
12 | 12 | /// Returns a HashMap mapping line numbers (0-indexed) to optional comment text: |
13 | | -/// - Some(comment_text) for lines with comments |
| 13 | +/// - Some(comment_text) for lines with standalone comments |
14 | 14 | /// - None for empty lines (preserves vertical spacing) |
15 | | -pub(crate) fn extract_whitespace_and_comments( |
16 | | - source: &Rope, |
17 | | - tokens: TokenStream, |
| 15 | +pub(crate) fn extract_standalone_comments( |
| 16 | + source: &str, |
| 17 | + start_line: usize, |
18 | 18 | ) -> HashMap<usize, Option<String>> { |
19 | | - let mut whitespace_and_comments = HashMap::new(); |
20 | | - let mut last_span: Option<Span> = None; |
21 | | - |
22 | | - traverse_token_stream(tokens, &mut |span: Span| { |
23 | | - if let Some(last_span) = last_span |
24 | | - && last_span.end().line != span.start().line |
25 | | - { |
26 | | - let text = get_text_between_spans(source, last_span.end(), span.start()); |
27 | | - for (idx, line) in text.lines().enumerate() { |
28 | | - let comment = line |
29 | | - .to_string() |
30 | | - .split_once("//") |
31 | | - .map(|(_, txt)| txt) |
32 | | - .map(str::trim) |
33 | | - .map(ToOwned::to_owned); |
34 | | - |
35 | | - let line_index = last_span.end().line - 1 + idx; |
36 | | - |
37 | | - // Skip empty lines at token boundaries, but keep comments |
38 | | - if comment.is_none() |
39 | | - && (line_index == last_span.end().line - 1 |
40 | | - || line_index == span.start().line - 1) |
41 | | - { |
42 | | - continue; |
43 | | - } |
44 | | - |
45 | | - whitespace_and_comments.insert(line_index, comment); |
| 19 | + let mut result = HashMap::new(); |
| 20 | + |
| 21 | + for (idx, line) in source.lines().enumerate() { |
| 22 | + let line_index = start_line + idx; |
| 23 | + let trimmed = line.trim(); |
| 24 | + |
| 25 | + // Check if line is a standalone comment (only whitespace before //) |
| 26 | + if let Some(comment_text) = trimmed.strip_prefix("//") { |
| 27 | + let comment_text = comment_text.trim().to_string(); |
| 28 | + if !comment_text.is_empty() { |
| 29 | + result.insert(line_index, Some(comment_text)); |
| 30 | + } else { |
| 31 | + // Empty comment line |
| 32 | + result.insert(line_index, None); |
46 | 33 | } |
| 34 | + } else if trimmed.is_empty() { |
| 35 | + // Empty line (no comment, no code) |
| 36 | + result.insert(line_index, None); |
47 | 37 | } |
48 | | - last_span = Some(span); |
49 | | - }); |
| 38 | + } |
50 | 39 |
|
51 | | - whitespace_and_comments |
| 40 | + result |
52 | 41 | } |
53 | 42 |
|
54 | | -/// Recursively traverse a TokenStream, calling the callback for each token's span. |
55 | | -fn traverse_token_stream(tokens: TokenStream, cb: &mut impl FnMut(Span)) { |
56 | | - for token in tokens { |
57 | | - match token { |
58 | | - proc_macro2::TokenTree::Group(group) => { |
59 | | - cb(group.span_open()); |
60 | | - traverse_token_stream(group.stream(), cb); |
61 | | - cb(group.span_close()); |
| 43 | +/// Check if source text has inline comments (comments after code on the same line) |
| 44 | +/// or comments inside block/paren delimiters. |
| 45 | +/// |
| 46 | +/// Comments inside `[]` brackets are allowed — those are handled by the formatter's |
| 47 | +/// comment-aware array formatting. Comments inside `{}` or `()` are problematic |
| 48 | +/// because prettyplease strips them when reformatting expressions. |
| 49 | +pub(crate) fn has_problematic_comments(source: &str) -> bool { |
| 50 | + for line in source.lines() { |
| 51 | + if let Some((before, _after)) = line.split_once("//") { |
| 52 | + let before_trimmed = before.trim(); |
| 53 | + if !before_trimmed.is_empty() && !before_trimmed.starts_with("//") { |
| 54 | + return true; |
62 | 55 | } |
63 | | - _ => cb(token.span()), |
64 | 56 | } |
65 | 57 | } |
66 | | -} |
67 | 58 |
|
68 | | -/// Extract text from the source Rope between two line/column positions. |
69 | | -fn get_text_between_spans(rope: &Rope, start: LineColumn, end: LineColumn) -> String { |
70 | | - let start_byte = line_column_to_byte(rope, start); |
71 | | - let end_byte = line_column_to_byte(rope, end); |
| 59 | + let mut block_depth: i32 = 0; |
72 | 60 |
|
73 | | - rope.byte_slice(start_byte..end_byte).to_string() |
74 | | -} |
| 61 | + for line in source.lines() { |
| 62 | + let trimmed = line.trim(); |
75 | 63 |
|
76 | | -/// Convert a LineColumn position to a byte offset in the Rope. |
77 | | -pub fn line_column_to_byte(source: &Rope, point: LineColumn) -> usize { |
78 | | - let line_byte = source.byte_of_line(point.line - 1); |
79 | | - let line = source.line(point.line - 1); |
80 | | - let char_byte: usize = line.chars().take(point.column).map(|c| c.len_utf8()).sum(); |
81 | | - line_byte + char_byte |
82 | | -} |
83 | | - |
84 | | -/// Check if a TokenStream contains comments inside nested structures. |
85 | | -pub fn has_nested_structure_comments(source: &Rope, tokens: TokenStream) -> bool { |
86 | | - has_nested_structure_comments_inner(source, tokens, 0) |
87 | | -} |
| 64 | + let code_part = trimmed |
| 65 | + .split_once("//") |
| 66 | + .map_or(trimmed, |(before, _)| before); |
88 | 67 |
|
89 | | -// Recursively check for comments inside nested groups, tracking depth to avoid false positives. |
90 | | -fn has_nested_structure_comments_inner(source: &Rope, tokens: TokenStream, _depth: usize) -> bool { |
91 | | - for token in tokens { |
92 | | - if let proc_macro2::TokenTree::Group(group) = token { |
93 | | - // Check for comments inside any nested structure (arrays, blocks, structs, etc.) |
94 | | - // We need to include the opening delimiter to check for comments after the opening brace/bracket |
95 | | - let mut group_comments = HashMap::new(); |
96 | | - let opening_span = group.span_open(); |
97 | | - |
98 | | - // Check gaps between tokens inside the group, including after the opening delimiter |
99 | | - let mut last_span: Option<Span> = Some(opening_span); |
100 | | - traverse_token_stream(group.stream(), &mut |span: Span| { |
101 | | - if let Some(last) = last_span |
102 | | - && last.end().line != span.start().line |
103 | | - { |
104 | | - let text = get_text_between_spans(source, last.end(), span.start()); |
105 | | - for (idx, line) in text.lines().enumerate() { |
106 | | - let comment = line |
107 | | - .to_string() |
108 | | - .split_once("//") |
109 | | - .map(|(_, txt)| txt) |
110 | | - .map(str::trim) |
111 | | - .map(ToOwned::to_owned); |
112 | | - |
113 | | - let line_index = last.end().line - 1 + idx; |
114 | | - |
115 | | - if comment.is_some() { |
116 | | - group_comments.insert(line_index, comment); |
117 | | - } |
118 | | - } |
119 | | - } |
120 | | - last_span = Some(span); |
121 | | - }); |
122 | | - |
123 | | - if !group_comments.is_empty() { |
124 | | - return true; |
| 68 | + for ch in code_part.chars() { |
| 69 | + match ch { |
| 70 | + '{' => block_depth += 1, |
| 71 | + '}' => block_depth = block_depth.saturating_sub(1), |
| 72 | + _ => {} |
125 | 73 | } |
| 74 | + } |
126 | 75 |
|
127 | | - // Recursively check nested groups at increased depth |
128 | | - if has_nested_structure_comments_inner(source, group.stream(), _depth + 1) { |
129 | | - return true; |
130 | | - } |
| 76 | + if block_depth > 0 && trimmed.starts_with("//") { |
| 77 | + return true; |
131 | 78 | } |
132 | 79 | } |
133 | 80 |
|
134 | 81 | false |
135 | 82 | } |
136 | 83 |
|
| 84 | +/// Convert a LineColumn position to a byte offset in the Rope. |
| 85 | +pub fn line_column_to_byte(source: &Rope, point: LineColumn) -> usize { |
| 86 | + let line_byte = source.byte_of_line(point.line - 1); |
| 87 | + let line = source.line(point.line - 1); |
| 88 | + let char_byte: usize = line.chars().take(point.column).map(|c| c.len_utf8()).sum(); |
| 89 | + line_byte + char_byte |
| 90 | +} |
| 91 | + |
137 | 92 | #[cfg(test)] |
138 | 93 | mod tests { |
139 | 94 | use super::*; |
140 | 95 |
|
141 | 96 | #[test] |
142 | | - fn test_extract_simple_comment() { |
143 | | - let source_text = r#" |
144 | | - requires: x > 0, |
145 | | - // This is a comment |
146 | | - ensures: *output > 0 |
147 | | - "#; |
148 | | - let rope = Rope::from(source_text); |
149 | | - let tokens: TokenStream = source_text.parse().unwrap(); |
150 | | - |
151 | | - let comments = extract_whitespace_and_comments(&rope, tokens); |
152 | | - |
153 | | - // Should find the comment on line 2 (0-indexed) |
154 | | - assert!(comments.contains_key(&2)); |
| 97 | + fn test_extract_standalone_comments() { |
| 98 | + let source = "requires: x > 0,\n// This is a comment\nensures: *output > 0"; |
| 99 | + let comments = extract_standalone_comments(source, 0); |
| 100 | + |
155 | 101 | assert_eq!( |
156 | | - comments.get(&2), |
| 102 | + comments.get(&1), |
157 | 103 | Some(&Some("This is a comment".to_string())) |
158 | 104 | ); |
| 105 | + assert!(!comments.contains_key(&0)); // Code line, not a comment |
| 106 | + assert!(!comments.contains_key(&2)); // Code line, not a comment |
159 | 107 | } |
160 | 108 |
|
161 | 109 | #[test] |
162 | | - fn test_no_comments() { |
163 | | - let source_text = r#"requires: x > 0, ensures: *output > 0"#; |
164 | | - let rope = Rope::from(source_text); |
165 | | - let tokens: TokenStream = source_text.parse().unwrap(); |
166 | | - |
167 | | - let comments = extract_whitespace_and_comments(&rope, tokens); |
168 | | - |
169 | | - // Should be empty - no gaps between lines |
170 | | - assert!(comments.is_empty()); |
| 110 | + fn test_extract_multiple_comments_and_empty_lines() { |
| 111 | + let source = "// First comment\n\n// Second comment\nrequires: x > 0"; |
| 112 | + let comments = extract_standalone_comments(source, 5); |
| 113 | + |
| 114 | + assert_eq!(comments.get(&5), Some(&Some("First comment".to_string()))); |
| 115 | + assert_eq!(comments.get(&6), Some(&None)); // Empty line |
| 116 | + assert_eq!(comments.get(&7), Some(&Some("Second comment".to_string()))); |
| 117 | + assert!(!comments.contains_key(&8)); // Code line |
171 | 118 | } |
172 | 119 |
|
173 | 120 | #[test] |
174 | | - fn test_multiple_comments() { |
175 | | - let source_text = r#" |
176 | | - requires: x > 0, |
177 | | - // First comment |
178 | | - // Second comment |
179 | | - ensures: *output > 0, |
180 | | - // Third comment |
181 | | - binds: result |
182 | | - "#; |
183 | | - let rope = Rope::from(source_text); |
184 | | - let tokens: TokenStream = source_text.parse().unwrap(); |
185 | | - |
186 | | - let comments = extract_whitespace_and_comments(&rope, tokens); |
187 | | - |
188 | | - // Should find all three comments |
189 | | - assert!(comments.len() == 3); |
190 | | - assert_eq!(comments.get(&2), Some(&Some("First comment".to_string()))); |
191 | | - assert_eq!(comments.get(&3), Some(&Some("Second comment".to_string()))); |
192 | | - assert_eq!(comments.get(&5), Some(&Some("Third comment".to_string()))); |
| 121 | + fn test_has_problematic_comments_inline() { |
| 122 | + let source = "requires: x > 0, // inline comment\nensures: *output > 0"; |
| 123 | + assert!(has_problematic_comments(source)); |
193 | 124 | } |
194 | 125 |
|
195 | 126 | #[test] |
196 | | - fn test_empty_lines() { |
197 | | - let source_text = r#" |
198 | | - requires: x > 0, |
199 | | -
|
200 | | - ensures: *output > 0 |
201 | | - "#; |
202 | | - let rope = Rope::from(source_text); |
203 | | - let tokens: TokenStream = source_text.parse().unwrap(); |
| 127 | + fn test_has_problematic_comments_in_block() { |
| 128 | + let source = "requires: {\n // comment in block\n x > 0\n}"; |
| 129 | + assert!(has_problematic_comments(source)); |
| 130 | + } |
204 | 131 |
|
205 | | - let comments = extract_whitespace_and_comments(&rope, tokens); |
| 132 | + #[test] |
| 133 | + fn test_has_no_problematic_comments_in_array() { |
| 134 | + let source = "requires: [\n // comment in array\n x > 0,\n y > 0\n]"; |
| 135 | + assert!(!has_problematic_comments(source)); |
| 136 | + } |
206 | 137 |
|
207 | | - // Should record the empty line as None |
208 | | - let has_empty_line = comments.values().any(|v| v.is_none()); |
209 | | - assert!(has_empty_line); |
| 138 | + #[test] |
| 139 | + fn test_has_no_problematic_comments_standalone() { |
| 140 | + let source = |
| 141 | + "// standalone comment\nrequires: x > 0,\n// another standalone\nensures: *output > 0"; |
| 142 | + assert!(!has_problematic_comments(source)); |
210 | 143 | } |
211 | 144 | } |
0 commit comments