Skip to content

Commit 252e134

Browse files
committed
feat(anodized-fmt): handle arrays formatting in specs by refactoring comment extraction and formatting
1 parent 187d5a8 commit 252e134

9 files changed

Lines changed: 343 additions & 400 deletions

File tree

Lines changed: 94 additions & 161 deletions
Original file line numberDiff line numberDiff line change
@@ -1,211 +1,144 @@
11
use std::collections::HashMap;
22

33
use crop::Rope;
4-
use proc_macro2::{LineColumn, Span, TokenStream};
4+
use proc_macro2::LineColumn;
55

6-
/// Extract comments and empty lines from the gaps between tokens.
6+
/// Extract standalone comments (comments on their own line, no code before them).
77
///
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.
1111
///
1212
/// 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
1414
/// - 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,
1818
) -> 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);
4633
}
34+
} else if trimmed.is_empty() {
35+
// Empty line (no comment, no code)
36+
result.insert(line_index, None);
4737
}
48-
last_span = Some(span);
49-
});
38+
}
5039

51-
whitespace_and_comments
40+
result
5241
}
5342

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;
6255
}
63-
_ => cb(token.span()),
6456
}
6557
}
66-
}
6758

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;
7260

73-
rope.byte_slice(start_byte..end_byte).to_string()
74-
}
61+
for line in source.lines() {
62+
let trimmed = line.trim();
7563

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);
8867

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+
_ => {}
12573
}
74+
}
12675

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;
13178
}
13279
}
13380

13481
false
13582
}
13683

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+
13792
#[cfg(test)]
13893
mod tests {
13994
use super::*;
14095

14196
#[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+
155101
assert_eq!(
156-
comments.get(&2),
102+
comments.get(&1),
157103
Some(&Some("This is a comment".to_string()))
158104
);
105+
assert!(!comments.contains_key(&0)); // Code line, not a comment
106+
assert!(!comments.contains_key(&2)); // Code line, not a comment
159107
}
160108

161109
#[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
171118
}
172119

173120
#[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));
193124
}
194125

195126
#[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+
}
204131

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+
}
206137

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));
210143
}
211144
}

0 commit comments

Comments
 (0)