use std::ops::Range;
const SCHEMES: &[&str] = &["https://", "http://", "file://", "ftp://", "mailto:"];
pub fn openable(uri: &str) -> bool {
SCHEMES.iter().any(|s| uri.starts_with(s))
}
pub fn url_at(text: &str, index: usize) -> Option<(Range<usize>, String)> {
let chars: Vec<char> = text.chars().collect();
let mut start = 0;
while start < chars.len() {
let rest: String = chars[start..].iter().take(8).collect();
if !SCHEMES.iter().any(|s| rest.starts_with(s)) || boundary_before(&chars, start) {
start += 1;
continue;
}
let mut end = start;
while end < chars.len() && !stops_url(chars[end]) {
end += 1;
}
while end > start {
let last = chars[end - 1];
let opens = chars[start..end].iter().filter(|c| **c == '(').count();
let closes = chars[start..end].iter().filter(|c| **c == ')').count();
if matches!(last, '.' | ',' | ';' | ':' | '!' | '?' | '\'' | ']' | '}')
|| (last == ')' && closes > opens)
{
end -= 1;
} else {
break;
}
}
let url: String = chars[start..end].iter().collect();
let bare_scheme = SCHEMES.iter().any(|s| url == *s);
if (start..end).contains(&index) && !bare_scheme {
return Some((start..end, url));
}
start = end.max(start + 1);
}
None
}
fn boundary_before(chars: &[char], start: usize) -> bool {
start > 0 && chars[start - 1].is_alphanumeric()
}
fn stops_url(c: char) -> bool {
c.is_whitespace() || matches!(c, '<' | '>' | '"' | '`' | '│' | '\u{0}')
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FileRef {
pub path: String,
pub line: u32,
pub col: Option<u32>,
}
pub fn file_ref_at(text: &str, index: usize) -> Option<(Range<usize>, FileRef)> {
use regex::Regex;
use std::sync::OnceLock;
static PATTERNS: OnceLock<Vec<Regex>> = OnceLock::new();
let patterns = PATTERNS.get_or_init(|| {
let path = r"(?:~|\.{1,2})?/?(?:[\w.+@-]+/)*[\w+@-][\w.+@-]*";
[
r#"File "(?P<path>[^"]+)", line (?P<line>\d+)"#.to_string(),
format!(r"(?P<path>{path})\((?P<line>\d+)(?:,(?P<col>\d+))?\)"),
format!(r"(?P<path>{path}):(?P<line>\d+)(?::(?P<col>\d+))?"),
]
.iter()
.filter_map(|p| Regex::new(p).ok())
.collect()
});
let byte = text.char_indices().nth(index)?.0;
let to_chars = |b: usize| text[..b].chars().count();
for re in patterns {
for caps in re.captures_iter(text) {
let whole = caps.get(0)?;
if !(whole.start()..whole.end()).contains(&byte) {
continue;
}
let path = caps.name("path")?.as_str();
let looks_like_path = path.contains('/')
|| path.rsplit_once('.').is_some_and(|(stem, ext)| {
!stem.is_empty()
&& !ext.is_empty()
&& ext.chars().all(|c| c.is_ascii_alphanumeric())
});
let word_start = text[..whole.start()]
.rfind(char::is_whitespace)
.map_or(0, |i| i + 1);
let in_url = text[word_start..whole.end()].contains("://");
if !looks_like_path || in_url {
continue;
}
let line = caps.name("line")?.as_str().parse().ok()?;
let col = caps.name("col").and_then(|c| c.as_str().parse().ok());
return Some((
to_chars(whole.start())..to_chars(whole.end()),
FileRef {
path: path.to_string(),
line,
col,
},
));
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_url_under_the_pointer() {
let text = "see https://example.com/a?b=1 for details";
let (range, url) = url_at(text, 10).unwrap();
assert_eq!(url, "https://example.com/a?b=1");
assert_eq!(range, 4..29);
assert!(url_at(text, 2).is_none());
assert!(url_at(text, 31).is_none());
}
#[test]
fn trims_sentence_punctuation_but_keeps_balanced_parens() {
assert_eq!(
url_at("go to http://x.org/page.", 8).unwrap().1,
"http://x.org/page"
);
assert_eq!(
url_at("(https://en.wikipedia.org/wiki/Rust_(language))", 5)
.unwrap()
.1,
"https://en.wikipedia.org/wiki/Rust_(language)"
);
}
#[test]
fn handles_multiple_urls_and_unicode() {
let text = "é http://a.io and file:///tmp/x";
assert_eq!(url_at(text, 3).unwrap().1, "http://a.io");
assert_eq!(url_at(text, 20).unwrap().1, "file:///tmp/x");
}
#[test]
fn only_known_schemes_are_openable() {
assert!(openable("https://x"));
assert!(openable("mailto:me@x"));
assert!(!openable("javascript:alert(1)"));
assert!(!openable("cyberterm-mark:prompt"));
}
fn file_ref(text: &str, at: &str) -> Option<FileRef> {
let index = text[..text.find(at).unwrap()].chars().count();
file_ref_at(text, index).map(|(_, r)| r)
}
#[test]
fn finds_compiler_and_linter_references() {
let r = file_ref("error[E0308]: --> src/app/mod.rs:42:17", "mod.rs").unwrap();
assert_eq!(
r,
FileRef {
path: "src/app/mod.rs".into(),
line: 42,
col: Some(17)
}
);
let r = file_ref("main.go:12: undefined: x", "main").unwrap();
assert_eq!((r.path.as_str(), r.line, r.col), ("main.go", 12, None));
let r = file_ref(" at render (/home/me/app/ui.js:10:5)", "ui.js").unwrap();
assert_eq!(r.path, "/home/me/app/ui.js");
let r = file_ref("grep: ./notes/todo.md:3:fix it", "todo").unwrap();
assert_eq!((r.path.as_str(), r.line), ("./notes/todo.md", 3));
let r = file_ref("Program.cs(12,5): error CS1002", "Program").unwrap();
assert_eq!((r.line, r.col), (12, Some(5)));
}
#[test]
fn finds_python_tracebacks() {
let text = r#" File "/srv/app/views.py", line 88, in handler"#;
let r = file_ref(text, "views").unwrap();
assert_eq!((r.path.as_str(), r.line), ("/srv/app/views.py", 88));
}
#[test]
fn ignores_things_that_are_not_paths() {
assert!(file_ref("at 12:30:45 today", "30").is_none());
assert!(file_ref("see http://example.com:8080/x", "example").is_none());
assert!(file_ref("ratio 3:2", "3").is_none());
let text = "warning in lib.rs:7 here";
let (range, _) = file_ref_at(text, 12).unwrap();
assert_eq!(&text[range], "lib.rs:7");
}
}