sea_core/formatter/
comments.rs

1//! Comment extraction and preservation utilities.
2//!
3//! This module handles extracting comments from source code and associating
4//! them with their corresponding declarations.
5
6use std::collections::BTreeMap;
7
8/// A comment extracted from source code.
9#[derive(Debug, Clone, PartialEq)]
10pub struct Comment {
11    /// The comment text (without the leading //)
12    pub text: String,
13    /// Line number (1-indexed)
14    pub line: usize,
15    /// Whether this is a trailing comment (on same line as code)
16    pub is_trailing: bool,
17}
18
19/// Extracts comments from source code, preserving their positions.
20///
21/// Returns a map of line numbers to comments, where the line number
22/// indicates where the comment appears in the source.
23pub fn extract_comments(source: &str) -> BTreeMap<usize, Vec<Comment>> {
24    let mut comments: BTreeMap<usize, Vec<Comment>> = BTreeMap::new();
25
26    for (line_idx, line) in source.lines().enumerate() {
27        let line_num = line_idx + 1;
28        let trimmed = line.trim();
29
30        // Check for line comment
31        if let Some(comment_start) = trimmed.find("//") {
32            let before_comment = &line[..line.find("//").unwrap_or(0)];
33            let is_trailing = !before_comment.trim().is_empty();
34
35            let comment_text = trimmed[comment_start + 2..].trim();
36
37            comments.entry(line_num).or_default().push(Comment {
38                text: comment_text.to_string(),
39                line: line_num,
40                is_trailing,
41            });
42        }
43    }
44
45    comments
46}
47
48/// Groups comments with declarations.
49///
50/// Associates each leading comment block with the next declaration's line.
51/// Returns a map from declaration start line to its leading comments.
52pub fn associate_comments_with_lines(
53    comments: &BTreeMap<usize, Vec<Comment>>,
54    declaration_lines: &[usize],
55) -> BTreeMap<usize, Vec<Comment>> {
56    let mut associated: BTreeMap<usize, Vec<Comment>> = BTreeMap::new();
57
58    // Sort declaration lines
59    let mut decl_lines = declaration_lines.to_vec();
60    decl_lines.sort();
61
62    // For each declaration, find leading comments
63    for &decl_line in &decl_lines {
64        let mut leading_comments = Vec::new();
65
66        // Look backwards from the declaration for comment lines
67        let mut check_line = decl_line.saturating_sub(1);
68        while check_line > 0 {
69            if let Some(line_comments) = comments.get(&check_line) {
70                // Only consider non-trailing comments as leading
71                let non_trailing: Vec<_> = line_comments
72                    .iter()
73                    .filter(|c| !c.is_trailing)
74                    .cloned()
75                    .collect();
76
77                if non_trailing.is_empty() {
78                    break;
79                }
80
81                // Insert at beginning to maintain order
82                for c in non_trailing.into_iter().rev() {
83                    leading_comments.insert(0, c);
84                }
85                check_line -= 1;
86            } else {
87                break;
88            }
89        }
90
91        if !leading_comments.is_empty() {
92            associated.insert(decl_line, leading_comments);
93        }
94    }
95
96    associated
97}
98
99/// Represents a source file with comments tracked separately.
100#[derive(Debug, Clone)]
101pub struct CommentedSource {
102    /// The original source code
103    pub source: String,
104    /// Map of line numbers to comments
105    pub comments: BTreeMap<usize, Vec<Comment>>,
106    /// Leading comments to output before the file header
107    pub file_header_comments: Vec<Comment>,
108}
109
110impl CommentedSource {
111    /// Parse source code and extract comments.
112    pub fn new(source: &str) -> Self {
113        let comments = extract_comments(source);
114
115        // Find the first non-comment, non-empty line for header comments
116        let mut file_header_comments = Vec::new();
117        for (line_idx, line) in source.lines().enumerate() {
118            let line_num = line_idx + 1;
119            let trimmed = line.trim();
120
121            if trimmed.is_empty() {
122                continue;
123            }
124
125            if trimmed.starts_with("//") {
126                if let Some(line_comments) = comments.get(&line_num) {
127                    file_header_comments.extend(line_comments.iter().cloned());
128                }
129            } else {
130                // Found first non-comment line, stop
131                break;
132            }
133        }
134
135        Self {
136            source: source.to_string(),
137            comments,
138            file_header_comments,
139        }
140    }
141
142    /// Get leading comments for a specific line.
143    pub fn leading_comments_for(&self, line: usize) -> Vec<&Comment> {
144        // Look at previous lines for consecutive comments
145        let mut result = Vec::new();
146        let mut check_line = line.saturating_sub(1);
147
148        while check_line > 0 {
149            if let Some(comments) = self.comments.get(&check_line) {
150                let non_trailing: Vec<_> = comments.iter().filter(|c| !c.is_trailing).collect();
151
152                if non_trailing.is_empty() {
153                    break;
154                }
155
156                for c in non_trailing.into_iter().rev() {
157                    result.insert(0, c);
158                }
159                check_line -= 1;
160            } else {
161                break;
162            }
163        }
164
165        result
166    }
167
168    /// Check if there are any comments in the source.
169    pub fn has_comments(&self) -> bool {
170        !self.comments.is_empty()
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn test_extract_line_comments() {
180        let source = r#"
181// This is a comment
182Entity "Foo"
183"#;
184        let comments = extract_comments(source);
185        assert_eq!(comments.len(), 1);
186        let line2_comments = comments.get(&2).unwrap();
187        assert_eq!(line2_comments[0].text, "This is a comment");
188        assert!(!line2_comments[0].is_trailing);
189    }
190
191    #[test]
192    fn test_extract_trailing_comments() {
193        let source = r#"Entity "Foo" // trailing comment
194"#;
195        let comments = extract_comments(source);
196        let line1_comments = comments.get(&1).unwrap();
197        assert_eq!(line1_comments[0].text, "trailing comment");
198        assert!(line1_comments[0].is_trailing);
199    }
200
201    #[test]
202    fn test_multiple_comments() {
203        let source = r#"
204// Comment 1
205// Comment 2
206Entity "Foo"
207"#;
208        let comments = extract_comments(source);
209        assert_eq!(comments.len(), 2);
210        assert!(comments.contains_key(&2));
211        assert!(comments.contains_key(&3));
212    }
213
214    #[test]
215    fn test_commented_source() {
216        let source = r#"// File header comment
217Entity "Foo"
218"#;
219        let cs = CommentedSource::new(source);
220        assert_eq!(cs.file_header_comments.len(), 1);
221        assert_eq!(cs.file_header_comments[0].text, "File header comment");
222    }
223}