use saphyr::{LoadableYamlNode, Scalar, Yaml};
use super::position::PositionIndex;
use super::{Path, Position, heuristics};
pub(crate) fn extract(content: &str) -> Vec<Path> {
let Ok(documents) = Yaml::load_from_str(content) else {
return Vec::new();
};
let index = PositionIndex::new(content);
let mut locator = Locator::new(content, &index);
let mut paths = Vec::new();
for document in &documents {
walk(document, &mut paths, &mut locator);
}
paths
}
fn walk(node: &Yaml<'_>, paths: &mut Vec<Path>, locator: &mut Locator) {
match node {
Yaml::Value(Scalar::String(text)) => claim(text, "YAML value", paths, locator),
Yaml::Sequence(items) => {
for item in items {
walk(item, paths, locator);
}
}
Yaml::Mapping(entries) => {
for (key, value) in entries {
if let Yaml::Value(Scalar::String(text)) = key {
claim(text, "YAML key", paths, locator);
}
walk(value, paths, locator);
}
}
_ => {}
}
}
fn claim(text: &str, context: &str, paths: &mut Vec<Path>, locator: &mut Locator) {
if !heuristics::is_path_like(text) {
return;
}
paths.push(Path {
value: text.to_string(),
kind: heuristics::classify_path_type(text),
position: locator.locate(text),
context: context.to_string(),
});
}
struct Locator<'a> {
content: &'a str,
index: &'a PositionIndex<'a>,
search_from: usize,
}
impl<'a> Locator<'a> {
fn new(content: &'a str, index: &'a PositionIndex<'a>) -> Self {
Self {
content,
index,
search_from: 0,
}
}
fn locate(&mut self, value: &str) -> Position {
if let Some(offset) = self.content[self.search_from..]
.find(value)
.map(|at| at + self.search_from)
{
self.search_from = offset + value.len();
return self.index.at(offset);
}
if let Some(anywhere) = self.content.find(value) {
return self.index.at(anywhere);
}
Position { line: 1, column: 1 }
}
}
#[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 a_blank_document_yields_nothing() {
assert!(extract("").is_empty());
assert!(extract(" \n ").is_empty());
}
#[test]
fn a_document_that_does_not_parse_yields_nothing() {
assert!(extract("a: [unterminated").is_empty());
}
#[test]
fn a_scalar_value_is_a_path() {
let paths = extract("log: /var/log/app.log\n");
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].value, "/var/log/app.log");
assert_eq!(paths[0].kind, PathType::Absolute);
assert_eq!(paths[0].context, "YAML value");
assert_eq!(paths[0].position.line, 1);
assert_eq!(paths[0].position.column, 6);
}
#[test]
fn a_key_that_looks_like_a_path_counts_too() {
let paths = extract("config/app.yaml: contents\n");
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].value, "config/app.yaml");
assert_eq!(paths[0].context, "YAML key");
}
#[test]
fn sequences_and_nesting_are_followed() {
assert_eq!(
values("jobs:\n build:\n steps:\n - ./scripts/a.sh\n - ./scripts/b.sh\n"),
["./scripts/a.sh", "./scripts/b.sh"]
);
}
#[test]
fn every_document_in_the_file_is_read() {
assert_eq!(
values("a: ./one.ts\n---\nb: ./two.ts\n"),
["./one.ts", "./two.ts"]
);
}
#[test]
fn non_string_scalars_are_skipped() {
assert!(values("port: 8080\nenabled: true\nempty: null\n").is_empty());
}
#[test]
fn mapping_order_follows_the_document() {
let paths = extract("z: ./first.ts\na: ./second.ts\n");
assert_eq!(paths[0].value, "./first.ts");
assert_eq!(paths[0].position.line, 1);
assert_eq!(paths[1].value, "./second.ts");
assert_eq!(paths[1].position.line, 2);
}
#[test]
fn repeated_values_land_on_successive_occurrences() {
let paths = extract("a: ./x.ts\nb: ./x.ts\n");
assert_eq!(paths[0].position.line, 1);
assert_eq!(paths[1].position.line, 2);
}
#[test]
fn an_aliased_value_resolves_to_its_anchor() {
let paths = extract("base: &dir ./shared/lib.ts\nuse: *dir\n");
assert_eq!(paths.len(), 2);
assert_eq!(paths[1].value, "./shared/lib.ts");
assert_eq!(paths[1].position.line, 1);
}
#[test]
fn an_unlocatable_value_falls_back_to_the_first_position() {
let paths = extract("p: >-\n ./spread\n /out.ts\n");
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].value, "./spread /out.ts");
assert_eq!(paths[0].position, Position { line: 1, column: 1 });
}
#[test]
fn a_quoted_scalar_is_located_past_its_quote() {
let paths = extract("p: \"./quoted.ts\"\n");
assert_eq!(paths[0].position.column, 5);
}
#[test]
fn a_comment_is_not_a_value() {
assert!(values("# see ./notes.md\na: 1\n").is_empty());
}
}