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/// 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;