Skip to main content

helm_schema_syntax/
yaml_scan.rs

1//! Line-local YAML lexical helpers: mapping-colon detection (template- and
2//! quote-aware) and scalar quoting. These are the shared lexical layer under
3//! both the layout parser and its query API.
4
5/// Parses a YAML mapping key from text ending immediately before its colon.
6#[must_use]
7pub fn parse_yaml_key(after: &str) -> Option<String> {
8    fn finalize_yaml_key(key: String) -> Option<String> {
9        if key.is_empty() {
10            return None;
11        }
12        Some(key)
13    }
14
15    let after = after.trim_end();
16    if after.starts_with("{{") {
17        return None;
18    }
19
20    if let Some(quote) = after.chars().next().filter(|c| matches!(c, '"' | '\'')) {
21        let quoted = &after[quote.len_utf8()..];
22        let mut chars = quoted.char_indices().peekable();
23        let mut key = String::new();
24        while let Some((idx, ch)) = chars.next() {
25            match (quote, ch) {
26                ('"', '\\') => {
27                    let (_, escaped) = chars.next()?;
28                    key.push(escaped);
29                }
30                ('\'', '\'') if chars.peek().is_some_and(|(_, next)| *next == '\'') => {
31                    let _ = chars.next();
32                    key.push('\'');
33                }
34                (_, ch) if ch == quote => {
35                    return quoted[(idx + ch.len_utf8())..]
36                        .trim_start()
37                        .starts_with(':')
38                        .then(|| finalize_yaml_key(key))
39                        .flatten();
40                }
41                _ => key.push(ch),
42            }
43        }
44        return None;
45    }
46
47    let chars = after.chars();
48    let mut key = String::new();
49    for ch in chars {
50        if ch == ':' {
51            return finalize_yaml_key(key.trim().to_string());
52        }
53        if ch.is_whitespace() {
54            return None;
55        }
56        if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | '/') {
57            key.push(ch);
58            continue;
59        }
60        return None;
61    }
62    None
63}
64
65/// Strip one matching pair of surrounding single or double quotes from a
66/// YAML scalar, returning the inner text. Unquoted (or mismatched-quote)
67/// input is returned unchanged.
68#[must_use]
69pub fn unquote_yaml_scalar(value: &str) -> &str {
70    value
71        .strip_prefix('"')
72        .and_then(|value| value.strip_suffix('"'))
73        .or_else(|| {
74            value
75                .strip_prefix('\'')
76                .and_then(|value| value.strip_suffix('\''))
77        })
78        .unwrap_or(value)
79}
80
81/// Whether the colon at `colon` separates a mapping key from its value.
82/// YAML only treats `:` as a mapping separator when it ends the line or is
83/// followed by whitespace; `key:value` is a plain scalar.
84pub fn mapping_colon_is_structural(text: &str, colon: usize) -> bool {
85    text[colon + 1..]
86        .chars()
87        .next()
88        .is_none_or(char::is_whitespace)
89}
90
91/// The offset of the first colon that YAML treats as a mapping key/value
92/// separator, or `None` when the line has no such colon.
93#[must_use]
94pub fn structural_mapping_colon(text: &str) -> Option<usize> {
95    first_mapping_colon_offset(text).filter(|&colon| mapping_colon_is_structural(text, colon))
96}
97
98/// Finds the first mapping colon while skipping template actions and quoted scalars.
99#[expect(
100    clippy::indexing_slicing,
101    reason = "this hot byte scanner maintains explicit bounds before every direct access"
102)]
103pub fn first_mapping_colon_offset(line: &str) -> Option<usize> {
104    let bytes = line.as_bytes();
105    let mut idx = 0usize;
106    while idx < bytes.len() {
107        match bytes[idx] {
108            b'{' if bytes.get(idx + 1) == Some(&b'{') => {
109                idx += 2;
110                while idx + 1 < bytes.len() {
111                    if bytes[idx] == b'}' && bytes[idx + 1] == b'}' {
112                        idx += 2;
113                        break;
114                    }
115                    idx += 1;
116                }
117            }
118            b'"' => {
119                idx += 1;
120                while idx < bytes.len() {
121                    match bytes[idx] {
122                        b'\\' => idx += 2,
123                        b'"' => {
124                            idx += 1;
125                            break;
126                        }
127                        _ => idx += 1,
128                    }
129                }
130            }
131            b'\'' => {
132                idx += 1;
133                while idx < bytes.len() {
134                    if bytes[idx] == b'\'' {
135                        if bytes.get(idx + 1) == Some(&b'\'') {
136                            idx += 2;
137                            continue;
138                        }
139                        idx += 1;
140                        break;
141                    }
142                    idx += 1;
143                }
144            }
145            b':' => return Some(idx),
146            _ => idx += 1,
147        }
148    }
149    None
150}
151
152#[cfg(test)]
153#[path = "tests/yaml_scan.rs"]
154mod tests;