use crate::document::{DocumentError, DocumentResult};
pub fn parse_path(path: &str) -> DocumentResult<Vec<String>> {
if path.is_empty() {
return Err(DocumentError::EmptyPath);
}
let mut segments = Vec::new();
let mut segment = String::new();
let mut escaped = false;
let mut wrote_bare_star = false;
let mut literal = false;
for character in path.chars() {
if escaped {
match character {
'.' | '\\' | '*' => {
segment.push(character);
literal = true;
}
other => {
return Err(DocumentError::ParseError {
format: "path".to_string(),
detail: format!("invalid escape `\\{other}`"),
});
}
}
escaped = false;
} else {
match character {
'\\' => escaped = true,
'.' => {
if segment == "*" && !literal {
wrote_bare_star = true;
}
segments.push(std::mem::take(&mut segment));
literal = false;
}
other => segment.push(other),
}
}
}
if escaped {
return Err(DocumentError::ParseError {
format: "path".to_string(),
detail: "trailing path escape".to_string(),
});
}
if segment == "*" && !literal {
wrote_bare_star = true;
}
segments.push(segment);
if wrote_bare_star {
return Err(DocumentError::ParseError {
format: "path".to_string(),
detail: "a bare `*` segment is a pattern; write `\\*` for a literal star key, or use \
a command that expands patterns"
.to_string(),
});
}
Ok(segments)
}
pub fn join_path(segments: &[String]) -> String {
segments
.iter()
.map(|segment| {
segment
.replace('\\', "\\\\")
.replace('.', "\\.")
.replace('*', "\\*")
})
.collect::<Vec<_>>()
.join(".")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PatternSegment {
Key(String),
Wildcard,
}
pub fn parse_path_pattern(path: &str) -> DocumentResult<Vec<PatternSegment>> {
if path.is_empty() {
return Err(DocumentError::EmptyPath);
}
let mut segments = Vec::new();
let mut segment = String::new();
let mut escaped = false;
let mut literal = false;
for character in path.chars() {
if escaped {
match character {
'.' | '\\' | '*' => {
segment.push(character);
literal = true;
}
other => {
return Err(DocumentError::ParseError {
format: "path".to_string(),
detail: format!("invalid escape `\\{other}`"),
});
}
}
escaped = false;
} else {
match character {
'\\' => escaped = true,
'.' => {
segments.push(finish_pattern_segment(
std::mem::take(&mut segment),
literal,
));
literal = false;
}
other => segment.push(other),
}
}
}
if escaped {
return Err(DocumentError::ParseError {
format: "path".to_string(),
detail: "trailing path escape".to_string(),
});
}
segments.push(finish_pattern_segment(segment, literal));
Ok(segments)
}
fn finish_pattern_segment(segment: String, literal: bool) -> PatternSegment {
if segment == "*" && !literal {
PatternSegment::Wildcard
} else {
PatternSegment::Key(segment)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_key_sequence_round_trips_through_its_spelling() {
let cases: &[&[&str]] = &[
&["packages", "", "version"],
&["packages", ""],
&["", "a"],
&["a", "", "", "b"],
&["plain"],
&["a.dotted.key", "b"],
&["back\\slash"],
&["node_modules/@esbuild/darwin-arm64"],
];
for segments in cases {
let owned: Vec<String> = segments.iter().map(|s| (*s).to_string()).collect();
let spelling = join_path(&owned);
let parsed = parse_path(&spelling)
.unwrap_or_else(|error| panic!("{owned:?} spelled `{spelling}`: {error}"));
assert_eq!(parsed, owned, "spelled `{spelling}`");
}
}
#[test]
fn an_empty_segment_is_a_key_not_an_error() {
assert_eq!(
parse_path("packages..version").unwrap(),
vec!["packages".to_string(), String::new(), "version".to_string()]
);
assert_eq!(
parse_path("packages.").unwrap(),
vec!["packages".to_string(), String::new()]
);
assert_eq!(
parse_path(".leading").unwrap(),
vec![String::new(), "leading".to_string()]
);
}
#[test]
fn a_star_key_survives_the_wildcard_reservation() {
assert_eq!(join_path(&["*".to_string()]), r"\*");
assert_eq!(parse_path(r"\*").unwrap(), vec!["*".to_string()]);
assert_eq!(
parse_path(r"a.\*.b").unwrap(),
vec!["a".to_string(), "*".to_string(), "b".to_string()]
);
let segments = vec!["a".to_string(), "*".to_string()];
assert_eq!(parse_path(&join_path(&segments)).unwrap(), segments);
}
#[test]
fn a_bare_star_is_reserved_for_patterns() {
assert!(parse_path("*").is_err());
assert!(parse_path("package.*.name").is_err());
assert_eq!(
parse_path_pattern("package.*.name").unwrap(),
vec![
PatternSegment::Key("package".to_string()),
PatternSegment::Wildcard,
PatternSegment::Key("name".to_string()),
]
);
assert_eq!(
parse_path_pattern(r"a.\*").unwrap(),
vec![
PatternSegment::Key("a".to_string()),
PatternSegment::Key("*".to_string()),
]
);
}
#[test]
fn the_empty_string_still_names_no_path() {
assert!(matches!(parse_path(""), Err(DocumentError::EmptyPath)));
assert_eq!(join_path(&[String::new()]), "");
}
#[test]
fn malformed_escapes_are_still_refused() {
assert!(parse_path(r"trailing\").is_err());
assert!(parse_path(r"bad\qescape").is_err());
}
}