alint_core/
jsonpath_diagnostics.rs1use std::sync::OnceLock;
25
26use regex::Regex;
27
28fn dashed_after_dot_re() -> &'static Regex {
29 static RE: OnceLock<Regex> = OnceLock::new();
30 RE.get_or_init(|| {
31 Regex::new(r"\.([A-Za-z_][A-Za-z0-9_]*-[A-Za-z0-9_-]+)").expect("static regex")
35 })
36}
37
38pub fn diagnose_path(path: &str) -> Option<String> {
44 if let Some(cap) = dashed_after_dot_re().captures(path)
48 && let Some(key_match) = cap.get(1)
49 {
50 let key = key_match.as_str();
51 return Some(format!(
52 "JSONPath dot-notation requires identifier-shape keys (RFC 9535). For dashed keys, use \
53 bracket notation: `$['{key}']` instead of `$.{key}` (or `@['{key}']` instead of \
54 `@.{key}` inside a filter). See `docs/development/CONFIG-AUTHORING.md` § 10.",
55 ));
56 }
57
58 None
59}
60
61pub fn format_parse_error(path: &str, err: impl std::fmt::Display) -> String {
65 let base = format!("invalid JSONPath {path:?}: {err}");
66 match diagnose_path(path) {
67 Some(hint) => format!("{base}\n hint: {hint}"),
68 None => base,
69 }
70}
71
72#[cfg(test)]
73mod tests {
74 use super::*;
75
76 #[test]
77 fn dashed_key_inside_filter_gets_hint() {
78 let path = "$.updates[?(@.package-ecosystem == 'github-actions')]";
84 let hint = diagnose_path(path).expect("should diagnose");
85 assert!(hint.contains("$['package-ecosystem']"), "hint: {hint}");
86 assert!(
87 hint.contains("@['package-ecosystem']"),
88 "should mention filter form: {hint}",
89 );
90 }
91
92 #[test]
93 fn dashed_key_after_dot_gets_hint() {
94 let path = "$.package-name";
95 let hint = diagnose_path(path).expect("should diagnose");
96 assert!(hint.contains("$['package-name']"), "hint: {hint}");
97 assert!(hint.contains("§ 10"), "hint: {hint}");
98 }
99
100 #[test]
101 fn dashed_key_in_middle_path_gets_hint() {
102 let path = "$.foo.dashed-key.bar";
103 let hint = diagnose_path(path).expect("should diagnose");
104 assert!(hint.contains("$['dashed-key']"), "hint: {hint}");
105 }
106
107 #[test]
108 fn already_correct_bracket_notation_no_hint() {
109 let path = "$['package-name']";
110 assert!(diagnose_path(path).is_none());
111 }
112
113 #[test]
114 fn correct_filter_no_hint() {
115 let path = "$.updates[?@.bar == 'baz']";
116 assert!(diagnose_path(path).is_none());
117 }
118
119 #[test]
120 fn outer_parens_alone_no_hint() {
121 let path = "$.updates[?(@.bar == 'baz')]";
124 assert!(diagnose_path(path).is_none());
125 }
126
127 #[test]
128 fn plain_dot_path_no_hint() {
129 let path = "$.package.edition";
130 assert!(diagnose_path(path).is_none());
131 }
132
133 #[test]
134 fn format_parse_error_includes_hint_when_diagnosed() {
135 let out = format_parse_error("$.foo-bar", "syntax error at column 7");
136 assert!(out.contains("invalid JSONPath"), "out: {out}");
137 assert!(out.contains("syntax error"), "out: {out}");
138 assert!(out.contains("hint:"), "out: {out}");
139 assert!(out.contains("$['foo-bar']"), "out: {out}");
140 }
141
142 #[test]
143 fn format_parse_error_no_hint_when_undiagnosed() {
144 let out = format_parse_error("$.foo[", "unterminated bracket");
145 assert!(out.contains("invalid JSONPath"), "out: {out}");
146 assert!(out.contains("unterminated bracket"), "out: {out}");
147 assert!(!out.contains("hint:"), "out: {out}");
148 }
149}