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 b.is_ascii_whitespace()
13 || matches!(
14 b,
15 b'(' | b'[' | b'{' | b'<' | b'"' | b'\'' | b'`' | b'=' | b'>'
16 )
17 }
18
19 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}