Skip to main content

zerodds_config/
parse.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3//! The YAML subset the daemon configs already used, with line numbers kept.
4//!
5//! Three crates carried this parser in three copies and each header said it
6//! was identical to the other two. This is that parser, once, with two
7//! additions the copies did not have: every scalar remembers the line it came
8//! from, and every construct outside the subset is refused by name instead of
9//! being read as something it is not.
10
11extern crate alloc;
12
13use alloc::collections::BTreeMap;
14use alloc::string::{String, ToString};
15use alloc::vec::Vec;
16
17use crate::{ConfigError, Node, Origin};
18
19/// Expands `${VAR}` and `${VAR:-default}` against the process environment.
20///
21/// An unset variable with no default expands to nothing, which is what the
22/// three copies did and what a config that names an optional path expects.
23#[must_use]
24pub fn expand_env_vars(input: &str) -> String {
25    let mut out = String::with_capacity(input.len());
26    let chars: Vec<char> = input.chars().collect();
27    let mut i = 0;
28    while i < chars.len() {
29        if i + 1 < chars.len() && chars[i] == '$' && chars[i + 1] == '{' {
30            if let Some(end) = chars[i + 2..].iter().position(|&c| c == '}') {
31                let inner: String = chars[i + 2..i + 2 + end].iter().collect();
32                let (name, default) = match inner.split_once(":-") {
33                    Some((n, d)) => (n.to_string(), Some(d.to_string())),
34                    None => (inner, None),
35                };
36                let value = std::env::var(&name).ok().or(default).unwrap_or_default();
37                out.push_str(&value);
38                i += 2 + end + 1;
39                continue;
40            }
41        }
42        out.push(chars[i]);
43        i += 1;
44    }
45    out
46}
47
48/// One physical line, already stripped of its comment and measured.
49#[derive(Clone)]
50struct Line {
51    indent: usize,
52    text: String,
53    number: usize,
54}
55
56/// Reads `text` as the configuration subset.
57///
58/// `path` names the file in every [`Origin`] and every error this produces —
59/// it is what the reader will look at, so it is passed rather than derived.
60///
61/// # Errors
62/// [`ConfigError::Syntax`] with the line, for anything outside the subset.
63pub fn parse_str(text: &str, path: &str) -> Result<Node, ConfigError> {
64    let lines = significant_lines(text, path)?;
65    if lines.is_empty() {
66        return Ok(Node::Map(BTreeMap::new()));
67    }
68    let mut at = 0usize;
69    let node = parse_block(&lines, &mut at, lines[0].indent, path, 0)?;
70    Ok(node)
71}
72
73/// Drops comments and blank lines, measures indentation, refuses what the
74/// subset does not cover.
75fn significant_lines(text: &str, path: &str) -> Result<Vec<Line>, ConfigError> {
76    let mut out = Vec::new();
77    for (idx, raw) in text.lines().enumerate() {
78        let number = idx + 1;
79        if raw.contains('\t') {
80            return Err(ConfigError::Syntax {
81                path: path.to_string(),
82                line: number,
83                message: "tab in indentation; this reader takes spaces".to_string(),
84            });
85        }
86        let without_comment = strip_comment(raw);
87        let trimmed = without_comment.trim_end();
88        if trimmed.trim().is_empty() {
89            continue;
90        }
91        let body = trimmed.trim_start();
92        if let Some(unsupported) = unsupported_construct(body) {
93            return Err(ConfigError::Syntax {
94                path: path.to_string(),
95                line: number,
96                message: unsupported,
97            });
98        }
99        out.push(Line {
100            indent: trimmed.len() - body.len(),
101            text: body.to_string(),
102            number,
103        });
104    }
105    Ok(out)
106}
107
108/// `#` starts a comment unless it is inside quotes.
109fn strip_comment(raw: &str) -> &str {
110    let bytes = raw.as_bytes();
111    let mut in_single = false;
112    let mut in_double = false;
113    for (i, b) in bytes.iter().enumerate() {
114        match b {
115            b'\'' if !in_double => in_single = !in_single,
116            b'"' if !in_single => in_double = !in_double,
117            b'#' if !in_single && !in_double => return &raw[..i],
118            _ => {}
119        }
120    }
121    raw
122}
123
124/// Names a construct this reader does not implement, so it can say so.
125///
126/// The three copies read `key: [a, b]` as the literal string `[a, b]` and a
127/// `*alias` as the literal `*alias`. That is worse than refusing: the value
128/// looks accepted and is wrong at the far end.
129fn unsupported_construct(body: &str) -> Option<String> {
130    let value = body.split_once(": ").map(|(_, v)| v.trim()).unwrap_or("");
131    if body.starts_with("---") || body.starts_with("...") {
132        return Some("multi-document streams are not read here".to_string());
133    }
134    if body.starts_with('&') || value.starts_with('&') {
135        return Some("anchors (`&name`) are not read here".to_string());
136    }
137    if body.starts_with('*') || value.starts_with('*') {
138        return Some("aliases (`*name`) are not read here".to_string());
139    }
140    if value.starts_with('{') && flow_map(value).is_none() {
141        return Some(
142            "only a flat map of scalars (`{k: v, k2: \"w\"}`) with distinct keys is read in \
143             flow form; use block form"
144                .to_string(),
145        );
146    }
147    if value.starts_with('[') && flow_list(value).is_none() {
148        return Some(
149            "only a flat list of scalars (`[a, \"b\"]`) is read in flow form; use block form"
150                .to_string(),
151        );
152    }
153    if value == "|" || value == ">" || value.starts_with("|-") || value.starts_with(">-") {
154        return Some("block scalars (`|`, `>`) are not read here".to_string());
155    }
156    None
157}
158
159/// Parses every line at `indent`, descending where a line opens a block.
160///
161/// `depth` is the nesting level, refused past [`crate::MAX_NESTING_DEPTH`]:
162/// this recurses once per indentation level of a file somebody else wrote,
163/// and a file is not allowed to decide how much stack it gets.
164///
165/// zerodds-lint: recursion-depth 32
166fn parse_block(
167    lines: &[Line],
168    at: &mut usize,
169    indent: usize,
170    path: &str,
171    depth: usize,
172) -> Result<Node, ConfigError> {
173    if depth > crate::MAX_NESTING_DEPTH {
174        return Err(ConfigError::TooDeep(crate::MAX_NESTING_DEPTH));
175    }
176    if lines.get(*at).is_some_and(|l| l.text.starts_with("- ")) {
177        return parse_seq(lines, at, indent, path, depth);
178    }
179    let mut map = BTreeMap::new();
180    while let Some(line) = lines.get(*at) {
181        if line.indent < indent {
182            break;
183        }
184        if line.indent > indent {
185            return Err(ConfigError::Syntax {
186                path: path.to_string(),
187                line: line.number,
188                message: "indented further than the key it belongs to".to_string(),
189            });
190        }
191        let (key, rest) = split_key(&line.text).ok_or_else(|| ConfigError::Syntax {
192            path: path.to_string(),
193            line: line.number,
194            message: "expected `key: value` or `key:`".to_string(),
195        })?;
196        let number = line.number;
197        *at += 1;
198        if rest.is_empty() {
199            // A block follows, or the key is deliberately empty.
200            let deeper = lines.get(*at).map_or(0, |l| l.indent);
201            if deeper > indent {
202                map.insert(key, parse_block(lines, at, deeper, path, depth + 1)?);
203            } else {
204                map.insert(
205                    key,
206                    Node::Scalar(
207                        String::new(),
208                        Origin::File {
209                            path: path.to_string(),
210                            line: number,
211                        },
212                    ),
213                );
214            }
215        } else {
216            map.insert(key, value_node(rest, path, number));
217        }
218    }
219    Ok(Node::Map(map))
220}
221
222/// A `key: value` value: a flat flow list becomes a sequence, a flat flow
223/// map a map, anything else a scalar. [`unsupported_construct`] has already
224/// refused the other flow forms.
225fn value_node(rest: &str, path: &str, line: usize) -> Node {
226    let origin = Origin::File {
227        path: path.to_string(),
228        line,
229    };
230    if let Some(entries) = flow_map(rest) {
231        return Node::Map(
232            entries
233                .into_iter()
234                .map(|(k, v)| {
235                    (
236                        k,
237                        Node::Scalar(unquote(&expand_env_vars(&v)), origin.clone()),
238                    )
239                })
240                .collect(),
241        );
242    }
243    match flow_list(rest) {
244        Some(items) => Node::Seq(
245            items
246                .into_iter()
247                .map(|item| Node::Scalar(unquote(&expand_env_vars(&item)), origin.clone()))
248                .collect(),
249        ),
250        None => Node::Scalar(unquote(&expand_env_vars(rest)), origin),
251    }
252}
253
254/// `{kind: keep_last, depth: 10}` as its entries, or `None` when the text is
255/// not a flat map of scalars: no nested `[`/`{`, balanced quotes, every
256/// entry `key: value`, no key twice, a closing `}` last.
257fn flow_map(value: &str) -> Option<Vec<(String, String)>> {
258    let inner = value.trim().strip_prefix('{')?.strip_suffix('}')?;
259    if inner.trim().is_empty() {
260        return Some(Vec::new());
261    }
262    let items = split_flow_items(inner)?;
263    let mut out: Vec<(String, String)> = Vec::new();
264    for item in items {
265        let (k, v) = item.split_once(':')?;
266        let key = unquote(k.trim());
267        let v = v.trim();
268        if key.is_empty() || v.is_empty() || out.iter().any(|(existing, _)| *existing == key) {
269            return None;
270        }
271        out.push((key, v.to_string()));
272    }
273    Some(out)
274}
275
276/// The comma-separated items of a flow collection's inside, quotes kept;
277/// `None` for a nested collection, an open quote or an empty item.
278fn split_flow_items(inner: &str) -> Option<Vec<String>> {
279    let mut items = Vec::new();
280    let mut current = String::new();
281    let mut quote: Option<char> = None;
282    for c in inner.chars() {
283        match (quote, c) {
284            (Some(q), c) if c == q => {
285                quote = None;
286                current.push(c);
287            }
288            (Some(_), c) => current.push(c),
289            (None, '"' | '\'') => {
290                quote = Some(c);
291                current.push(c);
292            }
293            (None, '[' | ']' | '{' | '}') => return None,
294            (None, ',') => items.push(core::mem::take(&mut current).trim().to_string()),
295            (None, c) => current.push(c),
296        }
297    }
298    if quote.is_some() {
299        return None;
300    }
301    let last = current.trim().to_string();
302    if !last.is_empty() || !items.is_empty() {
303        items.push(last);
304    }
305    if items.iter().any(String::is_empty) {
306        return None;
307    }
308    Some(items)
309}
310
311/// `[a, "b, c", 'd']` as its items, or `None` when the text is not a flat
312/// list of scalars: no nested `[`/`{`, balanced quotes, a closing `]` last.
313fn flow_list(value: &str) -> Option<Vec<String>> {
314    let inner = value.trim().strip_prefix('[')?.strip_suffix(']')?;
315    if inner.trim().is_empty() {
316        return Some(Vec::new());
317    }
318    split_flow_items(inner)
319}
320
321/// Parses `- item` lines at `indent`.
322///
323/// An item that starts with `key: …` is a map, read as a whole block: its
324/// first key sits two columns past the dash and the item's further lines
325/// are indented past the dash, so `- name: a` followed by `  qos:` and a
326/// deeper `    depth: 5` nests as written. An item without a key is a
327/// scalar and may not continue on deeper lines.
328///
329/// zerodds-lint: recursion-depth 32
330fn parse_seq(
331    lines: &[Line],
332    at: &mut usize,
333    indent: usize,
334    path: &str,
335    depth: usize,
336) -> Result<Node, ConfigError> {
337    let mut items = Vec::new();
338    while let Some(line) = lines.get(*at) {
339        if line.indent != indent || !line.text.starts_with("- ") {
340            break;
341        }
342        let item = line.text[2..].trim();
343        let number = line.number;
344        *at += 1;
345        let mut block: Vec<Line> = Vec::new();
346        while let Some(next) = lines.get(*at) {
347            if next.indent <= indent {
348                break;
349            }
350            block.push(next.clone());
351            *at += 1;
352        }
353        if item.starts_with("- ") {
354            return Err(ConfigError::Syntax {
355                path: path.to_string(),
356                line: number,
357                message: "a sequence directly inside a sequence item is not read here".to_string(),
358            });
359        }
360        if split_key(item).is_some() {
361            let mut item_lines = Vec::with_capacity(block.len() + 1);
362            item_lines.push(Line {
363                indent: indent + 2,
364                text: item.to_string(),
365                number,
366            });
367            item_lines.extend(block);
368            let mut i = 0;
369            let node = parse_block(&item_lines, &mut i, indent + 2, path, depth + 1)?;
370            if let Some(rest) = item_lines.get(i) {
371                return Err(ConfigError::Syntax {
372                    path: path.to_string(),
373                    line: rest.number,
374                    message: "not aligned with the keys of its sequence item".to_string(),
375                });
376            }
377            items.push(node);
378        } else {
379            if let Some(next) = block.first() {
380                return Err(ConfigError::Syntax {
381                    path: path.to_string(),
382                    line: next.number,
383                    message: "a scalar sequence item does not continue on deeper lines".to_string(),
384                });
385            }
386            items.push(value_node(item, path, number));
387        }
388    }
389    Ok(Node::Seq(items))
390}
391
392/// Splits `key: value` and `key:`; `None` when there is no key at all.
393fn split_key(text: &str) -> Option<(String, &str)> {
394    if let Some((k, v)) = text.split_once(": ") {
395        return Some((k.trim().to_string(), v.trim()));
396    }
397    let stripped = text.strip_suffix(':')?;
398    Some((stripped.trim().to_string(), ""))
399}
400
401/// Removes one matching pair of surrounding quotes.
402fn unquote(value: &str) -> String {
403    let bytes = value.as_bytes();
404    if bytes.len() >= 2 {
405        let first = bytes[0];
406        let last = bytes[bytes.len() - 1];
407        if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
408            return value[1..value.len() - 1].to_string();
409        }
410    }
411    value.to_string()
412}