use std::sync::OnceLock;
use regex::Regex;
fn dashed_after_dot_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"\.([A-Za-z_][A-Za-z0-9_]*-[A-Za-z0-9_-]+)").expect("static regex")
})
}
pub fn diagnose_path(path: &str) -> Option<String> {
let masked = mask_quoted_spans(path);
if let Some(cap) = dashed_after_dot_re().captures(&masked)
&& let Some(key_match) = cap.get(1)
{
let key = &path[key_match.range()];
return Some(format!(
"JSONPath dot-notation requires identifier-shape keys (RFC 9535). For dashed keys, use \
bracket notation: `$['{key}']` instead of `$.{key}` (or `@['{key}']` instead of \
`@.{key}` inside a filter). See `docs/development/CONFIG-AUTHORING.md` § 10.",
));
}
None
}
fn mask_quoted_spans(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut quote: Option<char> = None;
let mut escaped = false;
for c in s.chars() {
if let Some(q) = quote {
if escaped {
escaped = false;
push_blank(&mut out, c);
} else if c == '\\' {
escaped = true;
push_blank(&mut out, c);
} else if c == q {
quote = None;
out.push(c);
} else {
push_blank(&mut out, c);
}
} else {
if c == '\'' || c == '"' {
quote = Some(c);
}
out.push(c);
}
}
out
}
fn push_blank(out: &mut String, c: char) {
for _ in 0..c.len_utf8() {
out.push(' ');
}
}
pub fn format_parse_error(path: &str, err: impl std::fmt::Display) -> String {
let base = format!("invalid JSONPath {path:?}: {err}");
match diagnose_path(path) {
Some(hint) => format!("{base}\n hint: {hint}"),
None => base,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dashed_key_inside_filter_gets_hint() {
let path = "$.updates[?(@.package-ecosystem == 'github-actions')]";
let hint = diagnose_path(path).expect("should diagnose");
assert!(hint.contains("$['package-ecosystem']"), "hint: {hint}");
assert!(
hint.contains("@['package-ecosystem']"),
"should mention filter form: {hint}",
);
}
#[test]
fn dashed_key_after_dot_gets_hint() {
let path = "$.package-name";
let hint = diagnose_path(path).expect("should diagnose");
assert!(hint.contains("$['package-name']"), "hint: {hint}");
assert!(hint.contains("§ 10"), "hint: {hint}");
}
#[test]
fn dashed_key_in_middle_path_gets_hint() {
let path = "$.foo.dashed-key.bar";
let hint = diagnose_path(path).expect("should diagnose");
assert!(hint.contains("$['dashed-key']"), "hint: {hint}");
}
#[test]
fn already_correct_bracket_notation_no_hint() {
let path = "$['package-name']";
assert!(diagnose_path(path).is_none());
}
#[test]
fn dashed_key_inside_string_literal_no_hint() {
assert!(diagnose_path("$.foo[?@.bar == 'a.dashed-value']").is_none());
assert!(diagnose_path("$.items[?@.kind == \"x.y-z\"]").is_none());
}
#[test]
fn real_dashed_key_outside_literal_still_hints() {
let hint = diagnose_path("$.foo[?@.real-key == 'a.dashed-value']")
.expect("should diagnose the real dashed key");
assert!(hint.contains("$['real-key']"), "hint: {hint}");
}
#[test]
fn correct_filter_no_hint() {
let path = "$.updates[?@.bar == 'baz']";
assert!(diagnose_path(path).is_none());
}
#[test]
fn outer_parens_alone_no_hint() {
let path = "$.updates[?(@.bar == 'baz')]";
assert!(diagnose_path(path).is_none());
}
#[test]
fn plain_dot_path_no_hint() {
let path = "$.package.edition";
assert!(diagnose_path(path).is_none());
}
#[test]
fn format_parse_error_includes_hint_when_diagnosed() {
let out = format_parse_error("$.foo-bar", "syntax error at column 7");
assert!(out.contains("invalid JSONPath"), "out: {out}");
assert!(out.contains("syntax error"), "out: {out}");
assert!(out.contains("hint:"), "out: {out}");
assert!(out.contains("$['foo-bar']"), "out: {out}");
}
#[test]
fn format_parse_error_no_hint_when_undiagnosed() {
let out = format_parse_error("$.foo[", "unterminated bracket");
assert!(out.contains("invalid JSONPath"), "out: {out}");
assert!(out.contains("unterminated bracket"), "out: {out}");
assert!(!out.contains("hint:"), "out: {out}");
}
}