Skip to main content

fff_query_parser/
location.rs

1//! Location parsing for file:line:col patterns
2//!
3//! Parses various location formats like:
4//! - `file:12` - Line number
5//! - `file:12:4` - Line and column
6//! - `file:12-114` - Line range
7//! - `file:12:4-20` - Column range on same line
8//! - `file:12:4-14:20` - Position range
9//! - `file(12)` - Visual Studio style line
10//! - `file(12,4)` - Visual Studio style line and column
11
12#[derive(Debug, Eq, PartialEq, Copy, Clone)]
13pub enum Location {
14    Line(i32),
15    Range { start: (i32, i32), end: (i32, i32) },
16    Position { line: i32, col: i32 },
17}
18
19fn parse_number_pair(location: &str, split_char: char) -> Option<(i32, i32)> {
20    let mut iter = location.split(split_char);
21
22    let start_str = iter.next()?;
23    let end_str = iter.next()?;
24
25    // if there are more than 2 parts it's not the range treat as normal query
26    if iter.next().is_some() {
27        return None;
28    }
29
30    let start = start_str.parse::<i32>().ok()?;
31    let end = end_str.parse::<i32>().ok()?;
32
33    Some((start, end))
34}
35
36/// Parse "line-line" format
37fn parse_simple_range(location: &str) -> Option<Location> {
38    let (start, end) = parse_number_pair(location, '-')?;
39    if end < start {
40        return Some(Location::Line(start));
41    }
42
43    Some(Location::Range {
44        start: (start, 0),
45        end: (end, 0),
46    })
47}
48
49/// Parse "line:col-col" format (column range on same line)
50fn parse_column_range(start_part: &str, end_part: &str) -> Option<Location> {
51    let (line_str, start_col_str) = start_part.split_once(':')?;
52    let line = line_str.parse::<i32>().ok()?;
53    let start_col = start_col_str.parse::<i32>().ok()?;
54    let end_col = end_part.parse::<i32>().ok()?;
55
56    if end_col < start_col {
57        return Some(Location::Line(line));
58    }
59
60    Some(Location::Range {
61        start: (line, start_col),
62        end: (line, end_col),
63    })
64}
65
66/// Parse "line:col-line:col" format (position range)
67fn parse_position_range(start_part: &str, end_part: &str) -> Option<Location> {
68    let (start_line, start_col) = parse_number_pair(start_part, ':')?;
69    let (end_line, end_col) = parse_number_pair(end_part, ':')?;
70
71    if end_line < start_line || (end_line == start_line && end_col < start_col) {
72        return Some(Location::Position {
73            line: start_line,
74            col: start_col,
75        });
76    }
77
78    Some(Location::Range {
79        start: (start_line, start_col),
80        end: (end_line, end_col),
81    })
82}
83
84/// Try to parse range patterns (contains '-')
85fn try_parse_column_range(location: &str) -> Option<Location> {
86    if !location.contains('-') {
87        return None;
88    }
89
90    let (start_part, end_part) = location.split_once('-')?;
91
92    // Try position range (line:col-line:col)
93    if start_part.contains(':') && end_part.contains(':') {
94        return parse_position_range(start_part, end_part);
95    }
96
97    // Try column range (line:col-col)
98    if start_part.contains(':') {
99        return parse_column_range(start_part, end_part);
100    }
101
102    // Try simple line range (line-line)
103    parse_simple_range(location)
104}
105
106/// Try to parse position patterns (contains ':' but not '-')
107fn try_parse_column_position(location: &str) -> Option<Location> {
108    if !location.contains(':') {
109        return None;
110    }
111
112    let (line_str, col_str) = location.split_once(':')?;
113    let line = line_str.parse::<i32>().ok()?;
114    let col = col_str.parse::<i32>().ok()?;
115
116    Some(Location::Position { line, col })
117}
118
119/// Parses various location formats like file:12, file:12:4, file:12-114
120fn parse_column_location(query: &str) -> Option<(&str, Location)> {
121    // Left to right, because `file:12:4` splits at its first colon. A Windows
122    // drive letter (`C:\...`) just makes that first colon fail and we move on.
123    let mut from = 0;
124    while let Some(offset) = query[from..].find(':') {
125        let at = from + offset;
126        let location_part = &query[at + 1..];
127
128        if let Some(range_location) = try_parse_column_range(location_part) {
129            return Some((&query[..at], range_location));
130        }
131
132        if let Some(position_location) = try_parse_column_position(location_part) {
133            return Some((&query[..at], position_location));
134        }
135
136        if let Ok(line_location) = location_part.parse::<i32>() {
137            return Some((&query[..at], Location::Line(line_location)));
138        }
139
140        from = at + 1;
141    }
142
143    None
144}
145
146fn parse_vstudio_location(query: &str) -> Option<(&str, Location)> {
147    if !query.ends_with(')') {
148        return None;
149    }
150
151    let (file_path, location_with_paren) = query.rsplit_once('(')?;
152    let location = location_with_paren.trim_end_matches(')');
153
154    if let Ok(line) = location.parse::<i32>() {
155        return Some((file_path, Location::Line(line)));
156    }
157
158    if let Some((line, col)) = parse_number_pair(location, ',') {
159        return Some((file_path, Location::Position { line, col }));
160    }
161
162    None
163}
164
165/// Parse location from the end of a query string.
166///
167/// Returns the query without the location suffix, and the parsed location if found.
168///
169/// # Examples
170/// ```
171/// use fff_query_parser::location::{parse_location, Location};
172///
173/// let (query, loc) = parse_location("file:12");
174/// assert_eq!(query, "file");
175/// assert_eq!(loc, Some(Location::Line(12)));
176///
177/// let (query, loc) = parse_location("search term");
178/// assert_eq!(query, "search term");
179/// assert_eq!(loc, None);
180/// ```
181pub fn parse_location(query: &str) -> (&str, Option<Location>) {
182    // simply ignore the last semicolon even if there are no additional location info
183    let query = query.trim_end_matches([':', '-', '(']);
184    if let Some((path, location)) = parse_column_location(query) {
185        return (path, Some(location));
186    }
187
188    if let Some((path, location)) = parse_vstudio_location(query) {
189        return (path, Some(location));
190    }
191
192    (query, None)
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn test_location_parsing() {
201        assert_eq!(
202            parse_location("new_file:12"),
203            ("new_file", Some(Location::Line(12)))
204        );
205        assert_eq!(parse_location("new_file:12ab"), ("new_file:12ab", None));
206
207        assert_eq!(parse_location("something"), ("something", None));
208        assert_eq!(
209            parse_location("file:12:4"),
210            ("file", Some(Location::Position { line: 12, col: 4 }))
211        );
212
213        assert_eq!(
214            parse_location("file:12-114"),
215            (
216                "file",
217                Some(Location::Range {
218                    start: (12, 0),
219                    end: (114, 0)
220                })
221            )
222        );
223
224        assert_eq!(
225            parse_location("file:12:4-20"),
226            (
227                "file",
228                Some(Location::Range {
229                    start: (12, 4),
230                    end: (12, 20)
231                })
232            )
233        );
234
235        assert_eq!(
236            parse_location("file:100:4-14:20"),
237            ("file", Some(Location::Position { line: 100, col: 4 }))
238        );
239
240        assert_eq!(
241            parse_location("file:12:4-14:20"),
242            (
243                "file",
244                Some(Location::Range {
245                    start: (12, 4),
246                    end: (14, 20)
247                })
248            )
249        );
250    }
251
252    #[test]
253    fn test_vstudio_parsing() {
254        assert_eq!(
255            parse_location("file(12)"),
256            ("file", Some(Location::Line(12)))
257        );
258        assert_eq!(
259            parse_location("file(12,4)"),
260            ("file", Some(Location::Position { line: 12, col: 4 }))
261        );
262    }
263
264    #[test]
265    fn trimes_end_character() {
266        assert_eq!(
267            parse_location("file:12-"),
268            ("file", Some(Location::Line(12)))
269        );
270        assert_eq!(parse_location("file:-"), ("file", None));
271        assert_eq!(parse_location("file("), ("file", None));
272    }
273
274    #[test]
275    fn parses_location_after_a_windows_drive_letter() {
276        assert_eq!(
277            parse_location(r"C:\Users\me\file.rs:12"),
278            (r"C:\Users\me\file.rs", Some(Location::Line(12)))
279        );
280        assert_eq!(
281            parse_location(r"C:\src\main.rs:12:4"),
282            (
283                r"C:\src\main.rs",
284                Some(Location::Position { line: 12, col: 4 })
285            )
286        );
287
288        // A colon that starts no location still leaves the query untouched.
289        assert_eq!(
290            parse_location(r"C:\Users\me\file.rs"),
291            (r"C:\Users\me\file.rs", None)
292        );
293        assert_eq!(parse_location("foo:bar"), ("foo:bar", None));
294    }
295}