Skip to main content

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;