helm_schema_core/pattern_dialect.rs
1use helm_schema_json_schema_walk::visit_subschemas_mut;
2use serde_json::Value;
3
4/// Normalize regex dialects in every schema-position `pattern` keyword and
5/// `patternProperties` key. Provider schemas carry Go/RE2 spellings —
6/// notably a leading global `(?i)` — that Draft-07's ECMA-262 dialect
7/// rejects; conforming validators refuse the whole schema over one such
8/// pattern. Runs once at provider-fragment ingestion, the boundary where
9/// foreign dialect text enters the system, so every downstream consumer
10/// sees portable spellings. Rewrites are language-exact (a case fold,
11/// never a widening), so an untranslatable pattern stays as-is for the
12/// fixture hygiene gate to report rather than silently changing what the
13/// schema accepts.
14pub fn normalize_schema_pattern_dialects(schema: &mut Value) {
15 if let Some(object) = schema.as_object_mut() {
16 if let Some(Value::String(pattern)) = object.get_mut("pattern")
17 && let Some(normalized) = ecma_case_folded_pattern(pattern)
18 {
19 *pattern = normalized;
20 }
21 if let Some(Value::Object(pattern_properties)) = object.get_mut("patternProperties") {
22 let renames: Vec<(String, String)> = pattern_properties
23 .keys()
24 .filter_map(|key| {
25 let normalized = ecma_case_folded_pattern(key)?;
26 // A collision with an existing key would clobber a
27 // sibling constraint; keep the original spelling.
28 (!pattern_properties.contains_key(&normalized))
29 .then(|| (key.clone(), normalized))
30 })
31 .collect();
32 for (key, normalized) in renames {
33 if let Some(subschema) = pattern_properties.remove(&key) {
34 pattern_properties.insert(normalized, subschema);
35 }
36 }
37 }
38 }
39 visit_subschemas_mut(schema, &mut normalize_schema_pattern_dialects);
40}
41
42/// Rewrite a leading global case-insensitivity group (`(?i)…` or `^(?i)…`)
43/// into an explicit per-letter case fold: `^(?i)(abort|warn)?$` becomes
44/// `^([aA][bB][oO][rR][tT]|[wW][aA][rR][nN])?$`. The fold preserves the
45/// accepted language exactly, including RE2's Unicode simple-fold partners
46/// for `k` (U+212A KELVIN SIGN) and `s` (U+017F LONG S), and the rewritten
47/// pattern stays valid in both the ECMA-262 and Go dialects. Returns `None`
48/// — leave the pattern unchanged — when there is no leading `(?i)` or the
49/// tail uses any construct whose fold is not provably exact (letter-typed
50/// escapes, class ranges, groups beyond `(?:`, non-ASCII text).
51fn ecma_case_folded_pattern(pattern: &str) -> Option<String> {
52 let (anchor, rest) = match pattern.strip_prefix('^') {
53 Some(rest) => ("^", rest),
54 None => ("", pattern),
55 };
56 let tail = rest.strip_prefix("(?i)")?;
57
58 let mut out = String::with_capacity(anchor.len() + tail.len() * 2);
59 out.push_str(anchor);
60 let mut chars = tail.chars().peekable();
61 let mut in_class = false;
62 while let Some(character) = chars.next() {
63 match character {
64 '\\' => {
65 let escaped = chars.next()?;
66 // Escapes that denote letters indirectly (`\x41`, `\u`,
67 // `\p{L}`), reference groups, or change case semantics
68 // (`\Q…\E`) cannot fold character-wise.
69 if matches!(
70 escaped,
71 'x' | 'u' | 'p' | 'P' | 'k' | 'Q' | 'E' | 'A' | 'z' | 'Z' | '1'..='9'
72 ) {
73 return None;
74 }
75 out.push('\\');
76 out.push(escaped);
77 }
78 '[' if !in_class => {
79 in_class = true;
80 out.push('[');
81 if chars.peek() == Some(&'^') {
82 chars.next();
83 out.push('^');
84 }
85 // POSIX classes (`[[:alpha:]]`) are RE2-only; a leading
86 // literal `]` complicates class parsing — both abstain.
87 if matches!(chars.peek(), Some(&'[' | &']')) {
88 return None;
89 }
90 }
91 ']' if in_class => {
92 in_class = false;
93 out.push(']');
94 }
95 // A range endpoint may be a letter, and folding a range
96 // member-wise is wrong (`[a-z]` is not `[aA]-[zZ]`); a `-`
97 // that is not the class's last member abstains.
98 '-' if in_class => {
99 if chars.peek() != Some(&']') {
100 return None;
101 }
102 out.push('-');
103 }
104 '(' if !in_class => {
105 out.push('(');
106 if chars.peek() == Some(&'?') {
107 chars.next();
108 // Only the non-capturing group folds transparently;
109 // lookarounds, names, and inline flags abstain.
110 if chars.next() != Some(':') {
111 return None;
112 }
113 out.push_str("?:");
114 }
115 }
116 'a'..='z' | 'A'..='Z' => {
117 let lower = character.to_ascii_lowercase();
118 let upper = character.to_ascii_uppercase();
119 if !in_class {
120 out.push('[');
121 }
122 out.push(lower);
123 out.push(upper);
124 match lower {
125 'k' => out.push('\u{212A}'),
126 's' => out.push('\u{017F}'),
127 _ => {}
128 }
129 if !in_class {
130 out.push(']');
131 }
132 }
133 character if !character.is_ascii() => return None,
134 character => out.push(character),
135 }
136 }
137 if in_class {
138 return None;
139 }
140 Some(out)
141}
142
143#[cfg(test)]
144#[path = "tests/pattern_dialect.rs"]
145mod tests;