Skip to main content

squeeze/
path.rs

1use super::Finder;
2use std::ops::Range;
3
4#[derive(Default)]
5pub struct Path {}
6
7impl Path {
8    fn is_boundary(b: u8) -> bool {
9        // `=` and `>` cover `--flag=/path`, `VAR=/path` and shell redirects
10        // (`>/tmp/out`, `2>/dev/null`); the match itself still starts at the
11        // path prefix character.
12        b.is_ascii_whitespace()
13            || matches!(
14                b,
15                b'(' | b'[' | b'{' | b'<' | b'"' | b'\'' | b'`' | b'=' | b'>'
16            )
17    }
18
19    /// Strips trailing punctuation that is likely sentence- or wrapper-level
20    /// rather than part of the path. Loops to a fixpoint so any mix of
21    /// closers, quotes, colons and periods is peeled off (`/a/b:.` →
22    /// `/a/b`). Closing brackets are only stripped when unbalanced within
23    /// the candidate itself, like linkifiers do: `/tmp/file(1)` keeps its
24    /// `)`, `(see /etc/hosts)` loses it. Bracket occurrences are counted
25    /// once and updated incrementally so the whole pass stays O(len).
26    fn strip_trailing(input: &[u8], start: usize, min_end: usize, mut end: usize) -> usize {
27        let (mut parens, mut brackets, mut braces) = (0i32, 0i32, 0i32);
28        for &b in &input[start..end] {
29            match b {
30                b'(' => parens += 1,
31                b')' => parens -= 1,
32                b'[' => brackets += 1,
33                b']' => brackets -= 1,
34                b'{' => braces += 1,
35                b'}' => braces -= 1,
36                _ => {}
37            }
38        }
39        while end > min_end {
40            let b = input[end - 1];
41            let strip = match b {
42                b',' | b';' | b'>' | b'\'' | b'"' | b'`' | b':' | b'.' => true,
43                // Negative balance means more closers than openers, so the
44                // trailing closer cannot belong to the path.
45                b')' => parens < 0,
46                b']' => brackets < 0,
47                b'}' => braces < 0,
48                _ => false,
49            };
50            if !strip {
51                break;
52            }
53            match b {
54                b')' => parens += 1,
55                b']' => brackets += 1,
56                b'}' => braces += 1,
57                _ => {}
58            }
59            end -= 1;
60        }
61        end
62    }
63
64    /// Extends a path from its prefix up to whitespace or a backtick (a
65    /// Markdown code span delimiter), then strips trailing punctuation that's
66    /// likely sentence-level, not path-level. `:digits` line references
67    /// survive because the colon is not trailing — it's followed by digits.
68    ///
69    /// Returns `None` unless the body names something: `//`, `///` and `/*`
70    /// are comment markers.
71    fn match_end(input: &[u8], start: usize, prefix_len: usize) -> Option<usize> {
72        let body_start = start + prefix_len;
73        let mut end = body_start;
74        while end < input.len() && !input[end].is_ascii_whitespace() && input[end] != b'`' {
75            end += 1;
76        }
77        let end = Self::strip_trailing(input, start, body_start, end);
78        let names_something = input[body_start..end]
79            .iter()
80            .any(|&b| b.is_ascii_alphanumeric() || b >= 0x80 || matches!(b, b'.' | b'_' | b'-'));
81        names_something.then_some(end)
82    }
83
84    fn find_prefix(&self, input: &[u8], from: usize) -> Option<(usize, usize)> {
85        let mut idx = from;
86        while idx < input.len() {
87            match input[idx] {
88                b'~' if idx + 1 < input.len() && input[idx + 1] == b'/' => {
89                    if idx == 0 || Self::is_boundary(input[idx - 1]) {
90                        return Some((idx, 2));
91                    }
92                    idx += 2;
93                }
94                b'.' => {
95                    if idx + 2 < input.len()
96                        && input[idx + 1] == b'.'
97                        && input[idx + 2] == b'/'
98                        && (idx == 0 || Self::is_boundary(input[idx - 1]))
99                    {
100                        return Some((idx, 3));
101                    }
102                    if idx + 1 < input.len()
103                        && input[idx + 1] == b'/'
104                        && (idx == 0 || Self::is_boundary(input[idx - 1]))
105                    {
106                        return Some((idx, 2));
107                    }
108                    idx += 1;
109                }
110                b'/' => {
111                    if idx > 0 && input[idx - 1] == b':' {
112                        idx += 1;
113                        continue;
114                    }
115                    if (idx == 0 || Self::is_boundary(input[idx - 1]))
116                        && idx + 1 < input.len()
117                        && !input[idx + 1].is_ascii_whitespace()
118                    {
119                        return Some((idx, 1));
120                    }
121                    idx += 1;
122                }
123                _ => {
124                    idx += 1;
125                }
126            }
127        }
128        None
129    }
130}
131
132impl Finder for Path {
133    fn line_agnostic(&self) -> bool {
134        // Matches never contain a line terminator and `\n`/`\r` end every
135        // walk exactly like the end of the input does.
136        true
137    }
138
139    fn id(&self) -> &'static str {
140        "path"
141    }
142
143    fn dispatchable(&self) -> bool {
144        true
145    }
146
147    fn could_start_at(&self, byte: u8) -> bool {
148        matches!(byte, b'/' | b'.' | b'~')
149    }
150
151    fn could_start_after(&self, prev: u8, _cur: u8) -> bool {
152        Self::is_boundary(prev)
153    }
154
155    fn could_continue_with(&self, cur: u8, next: u8) -> bool {
156        match cur {
157            b'~' => next == b'/',
158            b'.' => next == b'.' || next == b'/',
159            _ => !next.is_ascii_whitespace(),
160        }
161    }
162
163    fn try_at(&self, input: &[u8], pos: usize) -> Option<Range<usize>> {
164        let prefix_len = match input[pos] {
165            b'~' if pos + 1 < input.len() && input[pos + 1] == b'/' => {
166                if pos > 0 && !Self::is_boundary(input[pos - 1]) {
167                    return None;
168                }
169                2
170            }
171            b'.' if pos + 2 < input.len() && input[pos + 1] == b'.' && input[pos + 2] == b'/' => {
172                if pos > 0 && !Self::is_boundary(input[pos - 1]) {
173                    return None;
174                }
175                3
176            }
177            b'.' if pos + 1 < input.len() && input[pos + 1] == b'/' => {
178                if pos > 0 && !Self::is_boundary(input[pos - 1]) {
179                    return None;
180                }
181                2
182            }
183            b'/' => {
184                if pos > 0 && input[pos - 1] == b':' {
185                    return None;
186                }
187                if pos > 0 && !Self::is_boundary(input[pos - 1]) {
188                    return None;
189                }
190                if pos + 1 >= input.len() || input[pos + 1].is_ascii_whitespace() {
191                    return None;
192                }
193                1
194            }
195            _ => return None,
196        };
197
198        Self::match_end(input, pos, prefix_len).map(|end| pos..end)
199    }
200
201    fn find(&self, s: &str) -> Option<Range<usize>> {
202        let input = s.as_bytes();
203        let mut search_from = 0;
204
205        while search_from < input.len() {
206            let (start, prefix_len) = self.find_prefix(input, search_from)?;
207            if let Some(end) = Self::match_end(input, start, prefix_len) {
208                return Some(start..end);
209            }
210            search_from = start + 1;
211        }
212
213        None
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220
221    #[test]
222    fn id_should_return_path() {
223        let finder = Path::default();
224        assert_eq!("path", finder.id());
225    }
226
227    // Absolute paths
228    #[test]
229    fn find_should_extract_absolute_path() {
230        let finder = Path::default();
231        let input = "see /etc/hosts for details";
232        let range = finder.find(input).unwrap();
233        assert_eq!("/etc/hosts", &input[range]);
234    }
235
236    #[test]
237    fn find_should_extract_deep_absolute_path() {
238        let finder = Path::default();
239        let input = "binary at /usr/local/bin/squeeze";
240        let range = finder.find(input).unwrap();
241        assert_eq!("/usr/local/bin/squeeze", &input[range]);
242    }
243
244    #[test]
245    fn find_should_extract_absolute_path_at_start() {
246        let finder = Path::default();
247        let input = "/var/log/syslog contains errors";
248        let range = finder.find(input).unwrap();
249        assert_eq!("/var/log/syslog", &input[range]);
250    }
251
252    // Relative paths
253    #[test]
254    fn find_should_extract_relative_path() {
255        let finder = Path::default();
256        let input = "edit ./src/main.rs to fix it";
257        let range = finder.find(input).unwrap();
258        assert_eq!("./src/main.rs", &input[range]);
259    }
260
261    #[test]
262    fn find_should_extract_parent_relative_path() {
263        let finder = Path::default();
264        let input = "see ../README.md";
265        let range = finder.find(input).unwrap();
266        assert_eq!("../README.md", &input[range]);
267    }
268
269    #[test]
270    fn find_should_extract_home_path() {
271        let finder = Path::default();
272        let input = "config at ~/config/settings.json";
273        let range = finder.find(input).unwrap();
274        assert_eq!("~/config/settings.json", &input[range]);
275    }
276
277    // Line references
278    #[test]
279    fn find_should_include_line_number() {
280        let finder = Path::default();
281        let input = "error in ./src/main.rs:42 here";
282        let range = finder.find(input).unwrap();
283        assert_eq!("./src/main.rs:42", &input[range]);
284    }
285
286    #[test]
287    fn find_should_include_line_and_column() {
288        let finder = Path::default();
289        let input = "error at ./src/main.rs:42:10 found";
290        let range = finder.find(input).unwrap();
291        assert_eq!("./src/main.rs:42:10", &input[range]);
292    }
293
294    // Punctuation stripping
295    #[test]
296    fn find_should_strip_trailing_comma() {
297        let finder = Path::default();
298        let input = "files /etc/hosts, /etc/passwd";
299        let range = finder.find(input).unwrap();
300        assert_eq!("/etc/hosts", &input[range]);
301    }
302
303    #[test]
304    fn find_should_strip_trailing_period() {
305        let finder = Path::default();
306        let input = "see /etc/hosts.";
307        let range = finder.find(input).unwrap();
308        assert_eq!("/etc/hosts", &input[range]);
309    }
310
311    #[test]
312    fn find_should_strip_trailing_paren() {
313        let finder = Path::default();
314        let input = "(see /etc/hosts)";
315        let range = finder.find(input).unwrap();
316        assert_eq!("/etc/hosts", &input[range]);
317    }
318
319    #[test]
320    fn find_should_strip_trailing_colon() {
321        let finder = Path::default();
322        let input = "/etc/hosts: permission denied";
323        let range = finder.find(input).unwrap();
324        assert_eq!("/etc/hosts", &input[range]);
325    }
326
327    // Should NOT match
328    #[test]
329    fn find_should_not_match_uri_path() {
330        let finder = Path::default();
331        assert!(finder.find("https://example.com/path").is_none());
332    }
333
334    #[test]
335    fn find_should_not_match_bare_slash() {
336        let finder = Path::default();
337        assert!(finder.find("a / b").is_none());
338    }
339
340    #[test]
341    fn find_should_not_match_empty_input() {
342        let finder = Path::default();
343        assert!(finder.find("").is_none());
344    }
345
346    #[test]
347    fn find_should_not_match_no_paths() {
348        let finder = Path::default();
349        assert!(finder.find("just some text").is_none());
350    }
351
352    // Iterative extraction
353    #[test]
354    fn find_should_extract_multiple_paths_iteratively() {
355        let finder = Path::default();
356        let input = "copy /etc/hosts to /tmp/hosts";
357
358        let mut results = Vec::new();
359        let mut idx = 0;
360        while idx < input.len() {
361            if let Some(range) = finder.find(&input[idx..]) {
362                results.push(&input[idx + range.start..idx + range.end]);
363                idx += range.end;
364            } else {
365                break;
366            }
367        }
368
369        assert_eq!(vec!["/etc/hosts", "/tmp/hosts"], results);
370    }
371
372    // Path in quotes/brackets
373    #[test]
374    fn find_should_extract_path_in_quotes() {
375        let finder = Path::default();
376        let input = r#"open "/var/log/app.log" now"#;
377        let range = finder.find(input).unwrap();
378        assert_eq!("/var/log/app.log", &input[range]);
379    }
380
381    #[test]
382    fn find_should_extract_path_in_brackets() {
383        let finder = Path::default();
384        let input = "see [/etc/config] for details";
385        let range = finder.find(input).unwrap();
386        assert_eq!("/etc/config", &input[range]);
387    }
388
389    #[test]
390    fn find_should_extract_path_in_backticks() {
391        let finder = Path::default();
392        let input = "run `./script.sh` to start";
393        let range = finder.find(input).unwrap();
394        assert_eq!("./script.sh", &input[range]);
395    }
396
397    // Dotfiles
398    #[test]
399    fn find_should_handle_dotfiles() {
400        let finder = Path::default();
401        let input = "edit ~/.bashrc for settings";
402        let range = finder.find(input).unwrap();
403        assert_eq!("~/.bashrc", &input[range]);
404    }
405
406    #[test]
407    fn find_should_handle_hidden_dirs() {
408        let finder = Path::default();
409        let input = "see ~/.config/app/settings.toml";
410        let range = finder.find(input).unwrap();
411        assert_eq!("~/.config/app/settings.toml", &input[range]);
412    }
413
414    // Extension preservation
415    #[test]
416    fn find_should_preserve_file_extension() {
417        let finder = Path::default();
418        let input = "compile ./src/lib.rs now";
419        let range = finder.find(input).unwrap();
420        assert_eq!("./src/lib.rs", &input[range]);
421    }
422
423    #[test]
424    fn find_should_handle_multiple_extensions() {
425        let finder = Path::default();
426        let input = "extract /archive/data.tar.gz here";
427        let range = finder.find(input).unwrap();
428        assert_eq!("/archive/data.tar.gz", &input[range]);
429    }
430
431    #[test]
432    fn find_should_not_match_mid_word_slash() {
433        let finder = Path::default();
434        assert!(finder.find("and/or").is_none());
435    }
436
437    // Slash followed by space should not match
438    #[test]
439    fn find_should_not_match_slash_space() {
440        let finder = Path::default();
441        assert!(finder.find("/ foo").is_none());
442    }
443
444    #[test]
445    fn try_at_absolute_path() {
446        let finder = Path::default();
447        let input = b"/etc/hosts rest";
448        assert_eq!(finder.try_at(input, 0), Some(0..10));
449    }
450
451    #[test]
452    fn try_at_relative_path() {
453        let finder = Path::default();
454        let input = b"./src/main.rs rest";
455        assert_eq!(finder.try_at(input, 0), Some(0..13));
456    }
457
458    #[test]
459    fn try_at_parent_path() {
460        let finder = Path::default();
461        let input = b"../README.md rest";
462        assert_eq!(finder.try_at(input, 0), Some(0..12));
463    }
464
465    #[test]
466    fn try_at_home_path() {
467        let finder = Path::default();
468        let input = b"~/.bashrc rest";
469        assert_eq!(finder.try_at(input, 0), Some(0..9));
470    }
471
472    #[test]
473    fn try_at_rejects_mid_word() {
474        let finder = Path::default();
475        let input = b"and/or";
476        assert!(finder.try_at(input, 3).is_none());
477    }
478
479    #[test]
480    fn try_at_slash_at_end() {
481        let finder = Path::default();
482        let input = b"/";
483        assert!(finder.try_at(input, 0).is_none());
484    }
485
486    #[test]
487    fn try_at_tilde_no_slash() {
488        let finder = Path::default();
489        let input = b"~x";
490        assert!(finder.try_at(input, 0).is_none());
491    }
492
493    #[test]
494    fn try_at_dot_no_slash() {
495        let finder = Path::default();
496        let input = b".x";
497        assert!(finder.try_at(input, 0).is_none());
498    }
499
500    #[test]
501    fn find_strips_trailing_multiple_periods() {
502        let finder = Path::default();
503        let input = "/etc/hosts...";
504        let range = finder.find(input).unwrap();
505        assert_eq!("/etc/hosts", &input[range]);
506    }
507
508    #[test]
509    fn find_preserves_internal_dots() {
510        let finder = Path::default();
511        let input = "/path/to/file.tar.gz more";
512        let range = finder.find(input).unwrap();
513        assert_eq!("/path/to/file.tar.gz", &input[range]);
514    }
515
516    #[test]
517    fn find_strips_trailing_colons() {
518        let finder = Path::default();
519        let input = "/etc/hosts: error";
520        let range = finder.find(input).unwrap();
521        assert_eq!("/etc/hosts", &input[range]);
522    }
523}