use std::ops::Range;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DetectedLink {
pub range: Range<usize>,
pub path: String,
pub line: Option<usize>,
pub column: Option<usize>,
}
fn is_opener(c: char) -> bool {
matches!(c, '"' | '\'' | '`' | '(' | '[' | '{' | '<')
}
fn is_trailing_punct(c: char) -> bool {
matches!(
c,
'.' | ',' | ';' | '!' | '?' | ')' | ']' | '}' | '>' | '"' | '\'' | '`'
)
}
pub fn detect_link_at(line: &str, col: usize) -> Option<DetectedLink> {
let chars: Vec<char> = line.chars().collect();
let n = chars.len();
if n == 0 {
return None;
}
let mut anchor = col.min(n - 1);
if chars[anchor].is_whitespace() {
if anchor == 0 || chars[anchor - 1].is_whitespace() {
return None;
}
anchor -= 1;
}
let mut start = anchor;
while start > 0 && !chars[start - 1].is_whitespace() {
start -= 1;
}
let mut end = anchor + 1;
while end < n && !chars[end].is_whitespace() {
end += 1;
}
while start < end && is_opener(chars[start]) {
start += 1;
}
if start >= end {
return None;
}
let token: String = chars[start..end].iter().collect();
let (path_len, suffix_end, line_no, col_no) = split_location_suffix(&token);
let mut path_end = start + path_len;
if line_no.is_none() {
while path_end > start && is_trailing_punct(chars[path_end - 1]) {
path_end -= 1;
}
}
if path_end <= start {
return None;
}
let path: String = chars[start..path_end].iter().collect();
if !looks_like_path(&path) {
return None;
}
let link_end = if line_no.is_some() {
start + suffix_end
} else {
path_end
};
Some(DetectedLink {
range: start..link_end,
path,
line: line_no,
column: col_no,
})
}
fn split_location_suffix(token: &str) -> (usize, usize, Option<usize>, Option<usize>) {
let chars: Vec<char> = token.chars().collect();
let full = chars.len();
let mut end = full;
if end > 0 && chars[end - 1] == ':' {
end -= 1;
}
if end > 0 && chars[end - 1] == ')' {
if let Some(open) = (0..end - 1).rev().find(|&i| chars[i] == '(') {
let inner: String = chars[open + 1..end - 1].iter().collect();
if open > 0 {
if let Some((l, c)) = parse_line_col_pair(&inner) {
return (open, end, Some(l), c);
}
}
}
}
let num1_start = rfind_digit_run(&chars, end);
if num1_start == end || num1_start == 0 || chars[num1_start - 1] != ':' {
return (full, full, None, None);
}
let num1: String = chars[num1_start..end].iter().collect();
let n1: usize = match num1.parse() {
Ok(v) => v,
Err(_) => return (full, full, None, None),
};
let colon1 = num1_start - 1;
if colon1 > 0 {
let num2_end = colon1;
let num2_start = rfind_digit_run(&chars, num2_end);
if num2_start < num2_end && num2_start > 0 && chars[num2_start - 1] == ':' {
let num2: String = chars[num2_start..num2_end].iter().collect();
if let Ok(n2) = num2.parse::<usize>() {
return (num2_start - 1, end, Some(n2), Some(n1));
}
}
}
(colon1, end, Some(n1), None)
}
fn parse_line_col_pair(inner: &str) -> Option<(usize, Option<usize>)> {
let inner = inner.trim();
if let Some((l, c)) = inner.split_once(',') {
let line = l.trim().parse().ok()?;
let col = c.trim().parse().ok()?;
Some((line, Some(col)))
} else {
Some((inner.parse().ok()?, None))
}
}
fn rfind_digit_run(chars: &[char], end: usize) -> usize {
let mut i = end;
while i > 0 && chars[i - 1].is_ascii_digit() {
i -= 1;
}
i
}
fn looks_like_path(path: &str) -> bool {
if path.is_empty() {
return false;
}
if path.contains('/') || path.contains('\\') || path.starts_with('~') || path.starts_with('.') {
return true;
}
path.contains('.')
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_relative_path() {
let line = "see src/main.rs for details";
let got = detect_link_at(line, 6).unwrap();
assert_eq!(got.path, "src/main.rs");
assert_eq!(got.line, None);
assert_eq!(got.column, None);
assert_eq!(&line[got.range], "src/main.rs");
}
#[test]
fn path_line_col() {
let line = "crates/fresh-editor/src/app.rs:128:5: error";
let got = detect_link_at(line, 10).unwrap();
assert_eq!(got.path, "crates/fresh-editor/src/app.rs");
assert_eq!(got.line, Some(128));
assert_eq!(got.column, Some(5));
assert_eq!(&line[got.range], "crates/fresh-editor/src/app.rs:128:5");
}
#[test]
fn path_line_only() {
let line = "at ./foo/bar.py:42";
let got = detect_link_at(line, 5).unwrap();
assert_eq!(got.path, "./foo/bar.py");
assert_eq!(got.line, Some(42));
assert_eq!(got.column, None);
assert_eq!(&line[got.range], "./foo/bar.py:42");
}
#[test]
fn trailing_colon_after_col() {
let line = "main.c:12:5:";
let got = detect_link_at(line, 2).unwrap();
assert_eq!(got.path, "main.c");
assert_eq!(got.line, Some(12));
assert_eq!(got.column, Some(5));
}
#[test]
fn paren_line_col() {
let line = "Program.cs(34,12): warning CS0168";
let got = detect_link_at(line, 3).unwrap();
assert_eq!(got.path, "Program.cs");
assert_eq!(got.line, Some(34));
assert_eq!(got.column, Some(12));
assert_eq!(&line[got.range], "Program.cs(34,12)");
}
#[test]
fn trailing_sentence_punctuation_trimmed() {
let line = "wrote /tmp/out/log.txt.";
let got = detect_link_at(line, 8).unwrap();
assert_eq!(got.path, "/tmp/out/log.txt");
assert_eq!(got.line, None);
assert_eq!(&line[got.range], "/tmp/out/log.txt");
}
#[test]
fn quoted_path() {
let line = "open \"my dir/file.rs\" now";
let got = detect_link_at(line, 14).unwrap();
assert_eq!(got.path, "dir/file.rs");
assert_eq!(&line[got.range], "dir/file.rs");
}
#[test]
fn click_on_whitespace_between_words() {
let line = "foo bar.rs";
assert_eq!(detect_link_at(line, 4), None);
}
#[test]
fn bare_word_is_not_a_path() {
let line = "this is a warning message";
assert_eq!(detect_link_at("warning", 0), None);
assert!(detect_link_at(line, 10).is_none());
}
#[test]
fn non_numeric_colon_suffix_kept_in_path() {
assert_eq!(detect_link_at("note:something", 2), None);
}
#[test]
fn click_at_trailing_edge() {
let line = "src/lib.rs done";
let got = detect_link_at(line, 10).unwrap();
assert_eq!(got.path, "src/lib.rs");
}
#[test]
fn empty_line() {
assert_eq!(detect_link_at("", 0), None);
}
#[test]
fn extensionless_with_separator() {
let line = "cd /usr/local/bin";
let got = detect_link_at(line, 5).unwrap();
assert_eq!(got.path, "/usr/local/bin");
}
#[test]
fn col_beyond_line_clamps() {
let line = "x.rs";
let got = detect_link_at(line, 999).unwrap();
assert_eq!(got.path, "x.rs");
}
}