cargo_perf/engine/
context.rs

1//! Analysis context and utilities for rule implementations.
2
3use crate::Config;
4use std::path::Path;
5
6/// Pre-computed line index for O(log n) line/column lookups.
7///
8/// Instead of iterating from the start of the file for each lookup,
9/// we build an index of line start positions once and use binary search.
10pub struct LineIndex {
11    /// Byte offsets where each line starts (0-indexed internally, 1-indexed for API)
12    line_starts: Vec<usize>,
13}
14
15impl LineIndex {
16    /// Build a line index from source text.
17    ///
18    /// Time: O(n) where n is the length of the source
19    /// Space: O(lines) for storing line start positions
20    pub fn new(source: &str) -> Self {
21        let mut line_starts = vec![0]; // Line 1 starts at byte 0
22
23        for (i, c) in source.char_indices() {
24            if c == '\n' {
25                // Next line starts at the byte after the newline
26                line_starts.push(i + 1);
27            }
28        }
29
30        Self { line_starts }
31    }
32
33    /// Convert a byte offset to (line, column), both 1-indexed.
34    ///
35    /// Time: O(log n) where n is the number of lines
36    ///
37    /// # Panics
38    /// Does not panic; returns the last valid position if offset is past end.
39    pub fn line_col(&self, offset: usize) -> (usize, usize) {
40        // Binary search for the line containing this offset
41        // partition_point returns the index where offset would be inserted
42        // to maintain sorted order, which is one past the line we want
43        let line_idx = self
44            .line_starts
45            .partition_point(|&start| start <= offset)
46            .saturating_sub(1);
47
48        let line = line_idx + 1; // Convert to 1-indexed
49        let line_start = self.line_starts[line_idx];
50        let column = offset.saturating_sub(line_start) + 1; // 1-indexed
51
52        (line, column)
53    }
54
55    /// Get the byte offset where a line starts (1-indexed line number).
56    pub fn line_start(&self, line: usize) -> Option<usize> {
57        self.line_starts.get(line.saturating_sub(1)).copied()
58    }
59
60    /// Get the total number of lines.
61    pub fn line_count(&self) -> usize {
62        self.line_starts.len()
63    }
64
65    /// Convert (line, column) to byte offset. Both are 1-indexed.
66    ///
67    /// Returns None if line is out of bounds or if the result would overflow.
68    /// Column is clamped to line length if too large.
69    pub fn byte_offset(&self, line: usize, column: usize) -> Option<usize> {
70        let line_start = self.line_start(line)?;
71        // Column is 1-indexed, so subtract 1
72        // Use checked_add to prevent overflow on malicious/malformed input
73        line_start.checked_add(column.saturating_sub(1))
74    }
75}
76
77/// Context passed to rules during analysis.
78///
79/// Contains all information needed to analyze a single file.
80pub struct AnalysisContext<'a> {
81    pub file_path: &'a Path,
82    pub source: &'a str,
83    pub ast: &'a syn::File,
84    pub config: &'a Config,
85    line_index: LineIndex,
86}
87
88impl<'a> AnalysisContext<'a> {
89    /// Create a new analysis context.
90    pub fn new(
91        file_path: &'a Path,
92        source: &'a str,
93        ast: &'a syn::File,
94        config: &'a Config,
95    ) -> Self {
96        Self {
97            file_path,
98            source,
99            ast,
100            config,
101            line_index: LineIndex::new(source),
102        }
103    }
104
105    /// Get line and column from a byte offset (1-indexed).
106    ///
107    /// This is O(log n) where n is the number of lines.
108    #[inline]
109    pub fn line_col(&self, offset: usize) -> (usize, usize) {
110        self.line_index.line_col(offset)
111    }
112
113    /// Get the source line at the given line number (1-indexed).
114    pub fn get_line(&self, line_num: usize) -> Option<&str> {
115        self.source.lines().nth(line_num.saturating_sub(1))
116    }
117
118    /// Get a reference to the line index for advanced lookups.
119    pub fn line_index(&self) -> &LineIndex {
120        &self.line_index
121    }
122
123    /// Convert a proc_macro2 span to byte range (start, end).
124    ///
125    /// Returns None if the span positions are invalid.
126    pub fn span_to_byte_range(&self, span: proc_macro2::Span) -> Option<(usize, usize)> {
127        let start = span.start();
128        let end = span.end();
129
130        let start_byte = self.line_index.byte_offset(start.line, start.column + 1)?;
131        let end_byte = self.line_index.byte_offset(end.line, end.column + 1)?;
132
133        Some((start_byte, end_byte))
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140
141    #[test]
142    fn test_line_index_simple() {
143        let source = "line1\nline2\nline3";
144        let index = LineIndex::new(source);
145
146        assert_eq!(index.line_count(), 3);
147
148        // First line
149        assert_eq!(index.line_col(0), (1, 1)); // 'l'
150        assert_eq!(index.line_col(4), (1, 5)); // '1'
151
152        // Second line (after first newline at offset 5)
153        assert_eq!(index.line_col(6), (2, 1)); // 'l'
154        assert_eq!(index.line_col(10), (2, 5)); // '2'
155
156        // Third line (after second newline at offset 11)
157        assert_eq!(index.line_col(12), (3, 1)); // 'l'
158    }
159
160    #[test]
161    fn test_line_index_empty() {
162        let source = "";
163        let index = LineIndex::new(source);
164
165        assert_eq!(index.line_count(), 1);
166        assert_eq!(index.line_col(0), (1, 1));
167    }
168
169    #[test]
170    fn test_line_index_single_line() {
171        let source = "hello world";
172        let index = LineIndex::new(source);
173
174        assert_eq!(index.line_count(), 1);
175        assert_eq!(index.line_col(0), (1, 1));
176        assert_eq!(index.line_col(5), (1, 6));
177        assert_eq!(index.line_col(10), (1, 11));
178    }
179
180    #[test]
181    fn test_line_index_trailing_newline() {
182        let source = "line1\nline2\n";
183        let index = LineIndex::new(source);
184
185        assert_eq!(index.line_count(), 3); // Empty line 3 after trailing newline
186        assert_eq!(index.line_col(12), (3, 1)); // Position after second newline
187    }
188
189    #[test]
190    fn test_line_index_unicode() {
191        let source = "héllo\nwörld";
192        let index = LineIndex::new(source);
193
194        // 'héllo' is 6 bytes (é is 2 bytes), newline at byte 6
195        // 'wörld' starts at byte 7
196        assert_eq!(index.line_col(0), (1, 1)); // 'h'
197        assert_eq!(index.line_col(7), (2, 1)); // 'w'
198    }
199
200    #[test]
201    fn test_line_start() {
202        let source = "line1\nline2\nline3";
203        let index = LineIndex::new(source);
204
205        assert_eq!(index.line_start(1), Some(0));
206        assert_eq!(index.line_start(2), Some(6));
207        assert_eq!(index.line_start(3), Some(12));
208        assert_eq!(index.line_start(4), None);
209    }
210
211    #[test]
212    fn test_byte_offset_overflow_protection() {
213        let source = "hello";
214        let index = LineIndex::new(source);
215
216        // Normal case should work
217        assert_eq!(index.byte_offset(1, 1), Some(0));
218        assert_eq!(index.byte_offset(1, 3), Some(2));
219
220        // Very large column with saturating_sub(1) doesn't overflow but returns a large value
221        // This tests that we don't panic on extreme inputs
222        let result = index.byte_offset(1, usize::MAX);
223        assert!(result.is_some()); // Doesn't overflow since line_start=0
224
225        // Edge case: column 0 is treated as column 1 (saturating_sub prevents underflow)
226        assert_eq!(index.byte_offset(1, 0), Some(0));
227    }
228
229    #[test]
230    fn test_byte_offset_invalid_line() {
231        let source = "hello";
232        let index = LineIndex::new(source);
233
234        // Line 0 is clamped to line 1 due to saturating_sub (same as line_start behavior)
235        assert_eq!(index.byte_offset(0, 1), Some(0));
236
237        // Line beyond file returns None
238        assert_eq!(index.byte_offset(100, 1), None);
239    }
240
241    #[test]
242    fn test_span_to_byte_range() {
243        use crate::Config;
244
245        // Simple source with known positions
246        let source = "fn foo() {}";
247        let ast = syn::parse_file(source).expect("Failed to parse");
248        let config = Config::default();
249        let ctx = AnalysisContext::new(std::path::Path::new("test.rs"), source, &ast, &config);
250
251        // Get the span of the function name "foo"
252        if let syn::Item::Fn(item_fn) = &ast.items[0] {
253            let ident_span = item_fn.sig.ident.span();
254            let (start, end) = ctx.span_to_byte_range(ident_span).unwrap();
255
256            // "foo" starts at position 3 (after "fn ") and ends at position 6
257            assert_eq!(start, 3, "Start byte should be 3");
258            assert_eq!(end, 6, "End byte should be 6");
259
260            // Verify by extracting the text
261            let extracted = &source[start..end];
262            assert_eq!(extracted, "foo", "Extracted text should be 'foo'");
263        } else {
264            panic!("Expected a function item");
265        }
266    }
267
268    #[test]
269    fn test_span_to_byte_range_multiline() {
270        use crate::Config;
271
272        // Multiline source
273        let source = "fn test() {\n    let x = 1;\n}";
274        let ast = syn::parse_file(source).expect("Failed to parse");
275        let config = Config::default();
276        let ctx = AnalysisContext::new(std::path::Path::new("test.rs"), source, &ast, &config);
277
278        // Get the span of the local variable "x"
279        if let syn::Item::Fn(item_fn) = &ast.items[0] {
280            if let syn::Stmt::Local(local) = &item_fn.block.stmts[0] {
281                if let syn::Pat::Ident(pat_ident) = &local.pat {
282                    let ident_span = pat_ident.ident.span();
283                    let (start, end) = ctx.span_to_byte_range(ident_span).unwrap();
284
285                    // Extract and verify
286                    let extracted = &source[start..end];
287                    assert_eq!(extracted, "x", "Extracted text should be 'x'");
288                }
289            }
290        }
291    }
292}