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