use super::position::PositionIndex;
use super::{Path, heuristics, js};
const CONTEXT: &str = "Text scan";
const BREAKS: [char; 9] = ['(', ')', '[', ']', '{', '}', ',', ';', '='];
const QUOTES: [char; 3] = ['\'', '"', '`'];
const STRUCTURE: [char; 3] = ['/', '\\', '.'];
pub(crate) fn extract(content: &str) -> Vec<Path> {
let index = PositionIndex::new(content);
let mut paths = Vec::new();
scan(content, |offset, token, delimited| {
claim(offset, token, delimited, &index, &mut paths);
});
paths
}
fn scan(content: &str, mut emit: impl FnMut(usize, &str, bool)) {
let mut offset = 0;
while let Some(current) = content[offset..].chars().next() {
let width = current.len_utf8();
if QUOTES.contains(¤t) {
offset = quoted(content, offset + width, current, &mut emit);
continue;
}
if js::is_js_whitespace(current) || BREAKS.contains(¤t) {
offset += width;
continue;
}
let end = content[offset..]
.find(is_break)
.map_or(content.len(), |at| offset + at);
emit(offset, &content[offset..end], false);
offset = end;
}
}
fn quoted(
content: &str,
start: usize,
quote: char,
emit: &mut impl FnMut(usize, &str, bool),
) -> usize {
let Some(close) = content[start..]
.find([quote, '\n'])
.map(|at| start + at)
.filter(|at| content[*at..].starts_with(quote))
else {
return start;
};
emit(start, &content[start..close], true);
close + quote.len_utf8()
}
fn is_break(character: char) -> bool {
js::is_js_whitespace(character) || BREAKS.contains(&character) || QUOTES.contains(&character)
}
fn claim(
offset: usize,
token: &str,
delimited: bool,
index: &PositionIndex,
paths: &mut Vec<Path>,
) {
let body = js::trim_start(token);
let leading = token.len() - body.len();
let value = if delimited {
js::trim(body)
} else {
body.trim_end_matches(['.', ':'])
};
if value
.chars()
.all(|character| STRUCTURE.contains(&character))
{
return;
}
if !delimited && !value.contains(['/', '\\']) {
return;
}
if !heuristics::is_path_like(value) {
return;
}
paths.push(Path {
value: value.to_string(),
kind: heuristics::classify_path_type(value),
position: index.at(offset + leading),
context: CONTEXT.to_string(),
});
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extract::PathType;
fn values(content: &str) -> Vec<String> {
extract(content)
.into_iter()
.map(|path| path.value)
.collect()
}
#[test]
fn an_empty_document_yields_nothing() {
assert!(extract("").is_empty());
assert!(extract(" \n\t ").is_empty());
}
#[test]
fn the_strong_shapes_are_claimed_undelimited() {
assert_eq!(
values("see /var/log/app.log and ./src/a.ts and ../up/b.ts"),
["/var/log/app.log", "./src/a.ts", "../up/b.ts"]
);
}
#[test]
fn a_windows_drive_letter_is_claimed() {
assert_eq!(values(r"copy C:\Temp\cache there"), [r"C:\Temp\cache"]);
}
#[test]
fn an_attribute_access_is_not_a_file_but_a_quoted_name_is() {
assert!(values("import os.path\nos.path.join(BASE)").is_empty());
assert_eq!(values("open(\"data.csv\")"), ["data.csv"]);
}
#[test]
fn a_bare_name_with_an_extension_is_never_claimed_undelimited() {
assert!(values("README.md is the file").is_empty());
assert_eq!(values("`README.md` is the file"), ["README.md"]);
}
#[test]
fn an_undelimited_run_with_a_separator_is_claimed() {
assert_eq!(
values("# see docs/architecture.md"),
["docs/architecture.md"]
);
}
#[test]
fn a_comment_marker_is_not_an_absolute_path() {
assert_eq!(values("// a note about ./x.ts"), ["./x.ts"]);
assert!(values("///").is_empty());
assert!(values("/* block */").is_empty());
}
#[test]
fn punctuation_around_a_path_is_not_part_of_it() {
assert_eq!(values("[docs](./guide.md)"), ["./guide.md"]);
assert_eq!(values("load(['./a.ts', './b.ts'])"), ["./a.ts", "./b.ts"]);
assert_eq!(values("PATH=/usr/local/bin"), ["/usr/local/bin"]);
}
#[test]
fn a_trailing_sentence_mark_is_dropped() {
assert_eq!(values("Read ./docs/a.md."), ["./docs/a.md"]);
assert_eq!(values("at ./src/a.ts:"), ["./src/a.ts"]);
}
#[test]
fn a_quoted_token_may_contain_spaces() {
assert_eq!(
values("f(\"/Users/me/My Files/a.txt\")"),
["/Users/me/My Files/a.txt"]
);
assert!(
values("/Users/me/My Files/a.txt").len() > 1,
"unquoted, it is two runs"
);
}
#[test]
fn an_unterminated_quote_is_not_a_delimiter() {
assert_eq!(values("# don't forget ./setup.sh"), ["./setup.sh"]);
}
#[test]
fn a_quoted_run_is_not_also_read_as_bare_text() {
assert_eq!(values("x = \"./once.ts\""), ["./once.ts"]);
}
#[test]
fn positions_are_where_the_path_starts() {
let paths = extract("first line\nrun ./tool.sh now\n");
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].position.line, 2);
assert_eq!(paths[0].position.column, 5);
}
#[test]
fn a_quoted_paths_position_skips_the_quote() {
let paths = extract("x(\"./a.ts\")");
assert_eq!(paths[0].position.column, 4);
}
#[test]
fn columns_count_utf16_code_units() {
let paths = extract("# 🎯 ./a.ts");
assert_eq!(paths[0].position.column, 6);
}
#[test]
fn every_claim_carries_the_scan_context_and_a_kind() {
let paths = extract("/abs/a.ts ./rel.ts https://example.com/x");
assert_eq!(paths.len(), 3);
assert!(paths.iter().all(|path| path.context == CONTEXT));
assert_eq!(paths[0].kind, PathType::Absolute);
assert_eq!(paths[1].kind, PathType::Relative);
assert_eq!(paths[2].kind, PathType::Url);
}
#[test]
fn a_glob_is_rejected_whole() {
assert!(values("src/**/*.ts").is_empty());
}
#[test]
fn a_version_string_is_still_not_a_path() {
assert!(values("version 1.8.1 and 192.168.1.1").is_empty());
}
#[test]
fn multibyte_text_does_not_shift_the_scan() {
assert_eq!(values("café ./a.ts café"), ["./a.ts"]);
}
#[test]
fn a_path_in_another_script_survives_the_structure_guard() {
assert_eq!(values("開く /文書/報告"), ["/文書/報告"]);
}
#[test]
fn token_boundaries_use_javascripts_whitespace_set() {
assert_eq!(values("x /a/b\u{feff}c"), ["/a/b"]);
assert_eq!(values("x /a/b\u{85}c"), ["/a/b\u{85}c"]);
}
}