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> {
if let Some(cap) = dashed_after_dot_re().captures(path)
&& let Some(key_match) = cap.get(1)
{
let key = key_match.as_str();
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
}
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 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}");
}
}