use crate::location::Locations;
use std::borrow::Cow;
use std::collections::HashMap;
use std::mem;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum PathKind {
Key,
Index,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub(crate) struct PathSegment {
pub(crate) kind: PathKind,
pub(crate) name: String,
}
impl From<&str> for PathSegment {
fn from(value: &str) -> Self {
Self {
kind: PathKind::Key,
name: value.to_owned(),
}
}
}
impl From<String> for PathSegment {
fn from(value: String) -> Self {
Self {
kind: PathKind::Key,
name: value,
}
}
}
impl<'a> From<Cow<'a, str>> for PathSegment {
fn from(value: Cow<'a, str>) -> Self {
Self {
kind: PathKind::Key,
name: value.into_owned(),
}
}
}
impl From<usize> for PathSegment {
fn from(value: usize) -> Self {
Self {
kind: PathKind::Index,
name: value.to_string(),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Default)]
pub struct PathKey {
pub(crate) segments: Vec<PathSegment>,
}
impl PathKey {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn join_key(mut self, key: impl Into<String>) -> Self {
self.segments.push(PathSegment {
kind: PathKind::Key,
name: key.into(),
});
self
}
#[must_use]
pub fn join_index(mut self, index: usize) -> Self {
self.segments.push(PathSegment {
kind: PathKind::Index,
name: index.to_string(),
});
self
}
pub fn segments(&self) -> impl Iterator<Item = (PathKind, &str)> {
self.iter_segments().map(|(kind, name)| (*kind, name))
}
pub(crate) fn empty() -> Self {
Self::new()
}
pub(crate) fn take(&mut self) -> Self {
Self {
segments: mem::take(&mut self.segments),
}
}
pub(crate) fn join<T: Into<PathSegment>>(mut self, seg: T) -> Self {
self.segments.push(seg.into());
self
}
pub(crate) fn len(&self) -> usize {
self.segments.len()
}
pub(crate) fn is_empty(&self) -> bool {
self.segments.is_empty()
}
pub(crate) fn leaf_string(&self) -> Option<String> {
self.segments.last().map(|seg| seg.name.clone())
}
pub(crate) fn truncate(&self, len: usize) -> Self {
Self {
segments: self.segments.iter().take(len).cloned().collect(),
}
}
pub(crate) fn parent(&self) -> Option<Self> {
let len = self.segments.len();
if len == 0 {
None
} else {
Some(self.truncate(len - 1))
}
}
fn iter_segments(&self) -> impl Iterator<Item = (&PathKind, &str)> {
self.segments
.iter()
.map(|seg| (&seg.kind, seg.name.as_str()))
}
}
pub(crate) fn format_path_with_resolved_leaf(path: &PathKey, resolved_leaf: &str) -> String {
if path.is_empty() {
return resolved_leaf.to_owned();
}
let mut out = String::new();
let last_index = path.segments.len().saturating_sub(1);
for (idx, seg) in path.segments.iter().enumerate() {
match seg.kind {
PathKind::Index => {
out.push('[');
out.push_str(&seg.name);
out.push(']');
}
PathKind::Key => {
if idx > 0 {
out.push('.');
}
if idx == last_index {
out.push_str(resolved_leaf);
} else {
out.push_str(&seg.name);
}
}
}
}
if out.is_empty() {
"<root>".to_owned()
} else {
out
}
}
#[cfg(feature = "garde")]
pub(crate) fn path_key_from_garde(path: &garde::error::Path) -> PathKey {
use garde::error::Kind;
let mut segs: Vec<PathSegment> = path
.__iter()
.map(|(k, s)| match k {
Kind::Index => PathSegment {
kind: PathKind::Index,
name: s.as_str().to_owned(),
},
_ => PathSegment {
kind: PathKind::Key,
name: s.as_str().to_owned(),
},
})
.collect();
segs.reverse();
PathKey { segments: segs }
}
#[derive(Debug, Clone)]
pub struct PathMap {
pub(crate) map: HashMap<PathKey, Locations>,
}
impl PathMap {
#[must_use]
pub fn locations_for(&self, path: &PathKey) -> Option<Locations> {
self.search_with_ancestor_fallback(path)
.map(|(locations, _)| locations)
.or_else(|| self.map.get(&PathKey::new()).copied())
}
pub(crate) fn new() -> Self {
Self {
map: HashMap::new(),
}
}
pub(crate) fn insert(&mut self, path: PathKey, locations: Locations) {
self.map.insert(path, locations);
}
pub(crate) fn search(&self, path: &PathKey) -> Option<(Locations, String)> {
if let Some(loc) = self.map.get(path) {
let leaf = path.leaf_string()?;
return Some((*loc, leaf));
}
self.find_unique_by(path, segments_equal_case_insensitive)
.or_else(|| self.find_unique_by(path, segments_equal_tokenized_case_insensitive))
.or_else(|| self.find_unique_by(path, segments_equal_collapsed_case_insensitive))
.or_else(|| self.find_unique_by(path, segments_equal_key_to_index_fallback))
}
pub(crate) fn search_with_ancestor_fallback(
&self,
path: &PathKey,
) -> Option<(Locations, String)> {
if let Some(found) = self.search(path) {
return Some(found);
}
let original_leaf = path.leaf_string();
let mut p = path.parent();
while let Some(cur) = p {
if let Some((locs, resolved_leaf)) = self.search(&cur) {
return Some((locs, original_leaf.unwrap_or(resolved_leaf)));
}
p = cur.parent();
}
None
}
fn find_unique_by(
&self,
target: &PathKey,
mut matches: impl FnMut(&PathKey, &PathKey) -> bool,
) -> Option<(Locations, String)> {
if target.is_empty() {
return None;
}
let mut found: Option<(Locations, String)> = None;
for (candidate, loc) in &self.map {
if matches(target, candidate) {
if found.is_some() {
return None; }
found = Some((*loc, candidate.leaf_string()?));
}
}
found
}
}
fn segments_equal_key_to_index_fallback(target: &PathKey, candidate: &PathKey) -> bool {
if target.len() != candidate.len() {
return false;
}
target
.iter_segments()
.zip(candidate.iter_segments())
.all(|((tk, ts), (ck, cs))| match (tk, ck) {
(PathKind::Index, PathKind::Index) => ts == cs,
(PathKind::Key, PathKind::Key) => strip_raw_identifier_prefix(ts)
.eq_ignore_ascii_case(strip_raw_identifier_prefix(cs)),
(PathKind::Key, PathKind::Index) => !ts.is_empty() && !cs.is_empty(),
(PathKind::Index, PathKind::Key) => false,
})
}
fn strip_raw_identifier_prefix(s: &str) -> &str {
s.strip_prefix("r#").unwrap_or(s)
}
fn segments_equal_case_insensitive(target: &PathKey, candidate: &PathKey) -> bool {
if target.len() != candidate.len() {
return false;
}
target
.iter_segments()
.zip(candidate.iter_segments())
.all(|((tk, ts), (ck, cs))| {
tk == ck
&& match tk {
PathKind::Index => ts == cs,
PathKind::Key => strip_raw_identifier_prefix(ts)
.eq_ignore_ascii_case(strip_raw_identifier_prefix(cs)),
}
})
}
fn collapse_non_alnum_ascii_lower(s: &str) -> String {
let mut out = String::with_capacity(s.len());
out.extend(
s.chars()
.filter(char::is_ascii_alphanumeric)
.map(|c| c.to_ascii_lowercase()),
);
out
}
fn segments_equal_collapsed_case_insensitive(target: &PathKey, candidate: &PathKey) -> bool {
if target.len() != candidate.len() {
return false;
}
target
.iter_segments()
.zip(candidate.iter_segments())
.all(|((tk, ts), (ck, cs))| {
tk == ck
&& match tk {
PathKind::Index => ts == cs,
PathKind::Key => {
collapse_non_alnum_ascii_lower(strip_raw_identifier_prefix(ts))
== collapse_non_alnum_ascii_lower(strip_raw_identifier_prefix(cs))
}
}
})
}
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
enum CharClass {
Lower,
Upper,
Digit,
Other,
}
fn classify_ascii(c: char) -> CharClass {
if c.is_ascii_lowercase() {
CharClass::Lower
} else if c.is_ascii_uppercase() {
CharClass::Upper
} else if c.is_ascii_digit() {
CharClass::Digit
} else {
CharClass::Other
}
}
fn tokenize_segment(s: &str) -> Vec<String> {
let mut tokens = Vec::new();
for piece in s
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|p| !p.is_empty())
{
let chars: Vec<char> = piece.chars().collect();
if chars.is_empty() {
continue;
}
let mut start = 0usize;
for i in 1..chars.len() {
let prev = classify_ascii(chars[i - 1]);
let curr = classify_ascii(chars[i]);
let next = chars.get(i + 1).copied().map(classify_ascii);
let boundary = match (prev, curr) {
(CharClass::Lower, CharClass::Upper) => true,
(CharClass::Digit, CharClass::Lower | CharClass::Upper) => true,
(CharClass::Lower | CharClass::Upper, CharClass::Digit) => true,
(CharClass::Upper, CharClass::Upper) if matches!(next, Some(CharClass::Lower)) => {
true
}
_ => false,
};
if boundary {
if start < i {
let tok: String = chars[start..i]
.iter()
.map(char::to_ascii_lowercase)
.collect();
if !tok.is_empty() {
tokens.push(tok);
}
}
start = i;
}
}
if start < chars.len() {
let tok: String = chars[start..]
.iter()
.map(char::to_ascii_lowercase)
.collect();
if !tok.is_empty() {
tokens.push(tok);
}
}
}
tokens
}
fn segments_equal_tokenized_case_insensitive(target: &PathKey, candidate: &PathKey) -> bool {
if target.len() != candidate.len() {
return false;
}
target
.iter_segments()
.zip(candidate.iter_segments())
.all(|((tk, ts), (ck, cs))| {
tk == ck
&& match tk {
PathKind::Index => ts == cs,
PathKind::Key => {
tokenize_segment(strip_raw_identifier_prefix(ts))
== tokenize_segment(strip_raw_identifier_prefix(cs))
}
}
})
}
pub(crate) struct PathRecorder {
pub(crate) current: PathKey,
pub(crate) map: PathMap,
}
impl PathRecorder {
pub(crate) fn new() -> Self {
Self {
current: PathKey::empty(),
map: PathMap::new(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Location;
fn locs(line: usize, column: usize) -> Locations {
let l = Location::new(line, column);
Locations {
reference_location: l,
defined_location: l,
}
}
fn p2(a: &str, b: &str) -> PathKey {
PathKey::empty().join(a).join(b)
}
#[rstest::rstest]
#[case::root(PathKey::new(), "ROOT", "ROOT")]
#[case::empty_root_label(PathKey::new(), "", "")]
#[case::default_root_label(PathKey::new(), "<root>", "<root>")]
#[case::key(PathKey::new().join_key("field"), "renamedField", "renamedField")]
#[case::nested(
PathKey::new().join_key("items").join_index(2).join_key("name"),
"displayName",
"items[2].displayName"
)]
#[case::index(PathKey::new().join_index(2), "unused", "[2]")]
#[case::empty_key(PathKey::new().join_key(""), "", "<root>")]
fn format_path_preserves_root_labels_and_existing_segments(
#[case] path: PathKey,
#[case] resolved_leaf: &str,
#[case] expected: &str,
) {
assert_eq!(
format_path_with_resolved_leaf(&path, resolved_leaf),
expected
);
}
#[test]
fn search_direct_hit() {
let mut m = PathMap::new();
let k = p2("gp", "a1");
m.insert(k.clone(), locs(3, 7));
assert_eq!(m.search(&k), Some((locs(3, 7), "a1".to_string())));
}
#[test]
fn public_lookup_exposes_recorded_root_without_requiring_a_leaf_label() {
let mut map = PathMap::new();
let root = PathKey::new();
let missing = root.clone().join_key("missing").join_key("value");
assert_eq!(map.locations_for(&root), None);
assert_eq!(map.locations_for(&missing), None);
map.insert(root.clone(), locs(1, 1));
assert_eq!(map.locations_for(&root), Some(locs(1, 1)));
assert_eq!(map.locations_for(&missing), Some(locs(1, 1)));
map.insert(root.join_key("missing"), locs(2, 3));
assert_eq!(map.locations_for(&missing), Some(locs(2, 3)));
}
#[test]
fn public_lookup_prefers_exact_matches_and_only_uses_unique_fuzzy_candidates() {
let mut map = PathMap::new();
let parent = PathKey::new().join_key("config");
let camel_case = parent.clone().join_key("userId");
let snake_case = parent.clone().join_key("user_id");
map.insert(camel_case.clone(), locs(2, 9));
assert_eq!(map.locations_for(&snake_case), Some(locs(2, 9)));
map.insert(snake_case.clone(), locs(3, 10));
assert_eq!(map.locations_for(&camel_case), Some(locs(2, 9)));
assert_eq!(map.locations_for(&snake_case), Some(locs(3, 10)));
let ambiguous = parent.clone().join_key("User-Id");
assert_eq!(map.locations_for(&ambiguous), None);
map.insert(PathKey::new(), locs(1, 1));
assert_eq!(map.locations_for(&ambiguous), Some(locs(1, 1)));
map.insert(parent, locs(1, 9));
assert_eq!(map.locations_for(&ambiguous), Some(locs(1, 9)));
}
#[test]
fn search_case_insensitive_unique() {
let mut m = PathMap::new();
m.insert(p2("opwKinematics", "a1"), locs(10, 2));
assert_eq!(
m.search(&p2("OPWKINEMATICS", "A1")),
Some((locs(10, 2), "a1".to_string()))
);
}
#[test]
fn search_case_insensitive_ambiguous() {
let mut m = PathMap::new();
m.insert(p2("FOO", "bar"), locs(1, 1));
m.insert(p2("foo", "bar"), locs(2, 2));
assert_eq!(m.search(&p2("Foo", "BAR")), None);
}
#[test]
fn search_tokenized_unique_snake_vs_camel() {
let mut m = PathMap::new();
m.insert(p2("userId", "a1"), locs(5, 9));
assert_eq!(
m.search(&p2("user_id", "a1")),
Some((locs(5, 9), "a1".to_string()))
);
}
#[test]
fn search_tokenized_unique_separators_equivalent() {
let mut m = PathMap::new();
m.insert(p2("user-id", "a1"), locs(7, 3));
assert_eq!(
m.search(&p2("user.id", "a1")),
Some((locs(7, 3), "a1".to_string()))
);
assert_eq!(
m.search(&p2("user id", "a1")),
Some((locs(7, 3), "a1".to_string()))
);
assert_eq!(
m.search(&p2("UserID", "a1")),
Some((locs(7, 3), "a1".to_string()))
);
}
#[test]
fn search_tokenized_unique_digit_boundaries() {
let mut m = PathMap::new();
m.insert(p2("sha_256_sum", "a1"), locs(9, 4));
assert_eq!(
m.search(&p2("sha256Sum", "a1")),
Some((locs(9, 4), "a1".to_string()))
);
}
#[test]
fn search_tokenized_unique_acronym_boundary() {
let mut m = PathMap::new();
m.insert(p2("http_server", "a1"), locs(11, 2));
assert_eq!(
m.search(&p2("HTTPServer", "a1")),
Some((locs(11, 2), "a1".to_string()))
);
}
#[test]
fn search_collapsed_fallback_avoids_token_collision() {
let mut m = PathMap::new();
m.insert(p2("ab_c", "x"), locs(1, 1));
m.insert(p2("a_bc", "x"), locs(2, 2));
assert_eq!(
m.search(&p2("abC", "x")),
Some((locs(1, 1), "x".to_string()))
);
}
#[test]
fn search_collapsed_match_unique_after_token_pass_fails() {
let mut m = PathMap::new();
m.insert(p2("userid", "a1"), locs(12, 6));
assert_eq!(
m.search(&p2("userId", "a1")),
Some((locs(12, 6), "a1".to_string()))
);
}
#[test]
fn search_collapsed_match_ambiguous() {
let mut m = PathMap::new();
m.insert(p2("ab_c", "x"), locs(1, 1));
m.insert(p2("a_bc", "x"), locs(2, 2));
assert_eq!(m.search(&p2("abc", "x")), None);
}
#[test]
fn search_returns_resolved_leaf_segment_when_leaf_is_renamed() {
let mut m = PathMap::new();
m.insert(PathKey::empty().join("myField"), locs(1, 10));
assert_eq!(
m.search(&PathKey::empty().join("my_field")),
Some((locs(1, 10), "myField".to_string()))
);
}
#[test]
fn search_strips_raw_identifier_prefix() {
let mut m = PathMap::new();
m.insert(PathKey::empty().join("type"), locs(9, 3));
assert_eq!(
m.search(&PathKey::empty().join("r#type")),
Some((locs(9, 3), "type".to_string()))
);
}
#[test]
fn search_handles_index_segments() {
let mut m = PathMap::new();
let path = PathKey::empty().join("items").join(2usize).join("name");
m.insert(path.clone(), locs(5, 8));
assert_eq!(
m.search(&PathKey::empty().join("items").join(2usize).join("name")),
Some((locs(5, 8), "name".to_string()))
);
}
}