Skip to main content

taba_cli/yaml/
mod.rs

1//! The subset of YAML that `taba-cli` reads and writes (doc 19b, section 18.11.2): maps nested by
2//! indentation and texts (plain, in single or double quotes), with `#` comments. No lists,
3//! anchors, multi-line blocks or flow maps (except an empty `{}`).
4
5use std::fmt;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub enum Node {
9    Map(Vec<(String, Node)>),
10    Text(String),
11}
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct YamlError {
15    pub line: usize,
16    pub message: String,
17}
18
19impl fmt::Display for YamlError {
20    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
21        write!(f, "line {}: {}", self.line, self.message)
22    }
23}
24
25impl std::error::Error for YamlError {}
26
27fn fail<T>(line: usize, message: impl Into<String>) -> Result<T, YamlError> {
28    Err(YamlError {
29        line,
30        message: message.into(),
31    })
32}
33
34struct Line {
35    number: usize,
36    indent: usize,
37    key: String,
38    /// `None` when there is nothing after the colon (a child map follows, or an empty map).
39    value: Option<String>,
40}
41
42/// Reads a document. The result is always a map (empty if the text only has comments).
43pub fn parse(text: &str) -> Result<Vec<(String, Node)>, YamlError> {
44    let mut lines = Vec::new();
45    for (n, raw) in text.lines().enumerate() {
46        let number = n + 1;
47        let raw = if number == 1 {
48            raw.trim_start_matches('\u{feff}')
49        } else {
50            raw
51        };
52        let content = strip_comment(raw, number)?;
53        if content.trim().is_empty() {
54            continue;
55        }
56        if content.starts_with('\t') || content.trim_start_matches(' ').starts_with('\t') {
57            return fail(number, "tab in the indentation (use spaces)");
58        }
59        let indent = content.len() - content.trim_start_matches(' ').len();
60        let body = content.trim();
61        if body.starts_with("- ") || body == "-" {
62            return fail(number, "lists are not supported");
63        }
64        if body == "---" || body == "..." {
65            return fail(number, "document markers are not supported");
66        }
67        let (key, rest) = split_key(body, number)?;
68        let value = match rest.trim() {
69            "" => None,
70            "{}" => Some("{}".to_owned()),
71            r if r.starts_with('{') || r.starts_with('[') => {
72                return fail(number, "flow maps and lists are not supported");
73            }
74            r if r.starts_with(['&', '*', '!']) => {
75                return fail(number, "anchors, aliases and tags are not supported");
76            }
77            r if r.starts_with(['|', '>']) => {
78                return fail(number, "multi-line blocks are not supported");
79            }
80            r => Some(r.to_owned()),
81        };
82        lines.push(Line {
83            number,
84            indent,
85            key,
86            value,
87        });
88    }
89    let mut pos = 0;
90    let map = parse_map(&lines, &mut pos, 0)?;
91    if pos < lines.len() {
92        return fail(lines[pos].number, "unexpected indentation");
93    }
94    Ok(map)
95}
96
97fn parse_map(
98    lines: &[Line],
99    pos: &mut usize,
100    indent: usize,
101) -> Result<Vec<(String, Node)>, YamlError> {
102    let mut out: Vec<(String, Node)> = Vec::new();
103    while *pos < lines.len() {
104        let line = &lines[*pos];
105        if line.indent < indent {
106            break;
107        }
108        if line.indent > indent {
109            return fail(line.number, "unexpected indentation");
110        }
111        if out.iter().any(|(k, _)| *k == line.key) {
112            return fail(line.number, format!("the key `{}` appears twice", line.key));
113        }
114        *pos += 1;
115        let node = match &line.value {
116            Some(v) if v == "{}" => Node::Map(Vec::new()),
117            Some(v) => Node::Text(scalar(v, line.number)?),
118            None => match lines.get(*pos) {
119                Some(next) if next.indent > indent => {
120                    Node::Map(parse_map(lines, pos, next.indent)?)
121                }
122                _ => Node::Map(Vec::new()),
123            },
124        };
125        out.push((line.key.clone(), node));
126    }
127    Ok(out)
128}
129
130/// Strips the comment (`#` at the start or after a space, outside quotes).
131fn strip_comment(line: &str, number: usize) -> Result<String, YamlError> {
132    let mut quote: Option<char> = None;
133    let mut prev = ' ';
134    let mut chars = line.char_indices().peekable();
135    while let Some((i, c)) = chars.next() {
136        match quote {
137            Some('"') => {
138                if c == '\\' {
139                    chars.next();
140                } else if c == '"' {
141                    quote = None;
142                }
143            }
144            Some(_) => {
145                if c == '\'' {
146                    if chars.peek().is_some_and(|(_, n)| *n == '\'') {
147                        chars.next();
148                    } else {
149                        quote = None;
150                    }
151                }
152            }
153            None => {
154                // Quotes only open a text at the start of the value (or the key); in the middle they are ordinary.
155                let opens = {
156                    let head = line[..i].trim_end();
157                    head.is_empty() || head.ends_with(':')
158                };
159                if (c == '"' || c == '\'') && opens {
160                    quote = Some(c);
161                } else if c == '#' && (prev == ' ' || i == 0) {
162                    return Ok(line[..i].trim_end().to_owned());
163                }
164            }
165        }
166        prev = c;
167    }
168    if quote.is_some() {
169        return fail(number, "unterminated quoted text");
170    }
171    Ok(line.trim_end().to_owned())
172}
173
174/// Splits `key: rest`. The key can come in quotes.
175fn split_key(body: &str, number: usize) -> Result<(String, &str), YamlError> {
176    if body.starts_with('"') || body.starts_with('\'') {
177        let q = body.chars().next().unwrap_or('"');
178        let mut end = None;
179        let mut chars = body.char_indices().skip(1);
180        while let Some((i, c)) = chars.next() {
181            if q == '"' && c == '\\' {
182                chars.next();
183            } else if c == q {
184                end = Some(i);
185                break;
186            }
187        }
188        let end = end.ok_or_else(|| YamlError {
189            line: number,
190            message: "unterminated quoted key".into(),
191        })?;
192        let key = scalar(&body[..=end], number)?;
193        let rest = body[end + 1..].trim_start();
194        let rest = rest.strip_prefix(':').ok_or_else(|| YamlError {
195            line: number,
196            message: "expected `:` after the key".into(),
197        })?;
198        return Ok((key, rest));
199    }
200    // Plain key: goes up to the first `:` followed by a space or the end.
201    let bytes = body.as_bytes();
202    for (i, b) in bytes.iter().enumerate() {
203        if *b == b':' && (i + 1 == bytes.len() || bytes[i + 1] == b' ') {
204            let key = body[..i].trim();
205            if key.is_empty() {
206                return fail(number, "empty key");
207            }
208            return Ok((key.to_owned(), &body[i + 1..]));
209        }
210    }
211    fail(number, "expected `key: value`")
212}
213
214fn scalar(text: &str, number: usize) -> Result<String, YamlError> {
215    let text = text.trim();
216    if let Some(inner) = text.strip_prefix('"') {
217        let inner = inner
218            .strip_suffix('"')
219            .filter(|_| text.len() >= 2)
220            .ok_or_else(|| YamlError {
221                line: number,
222                message: "unterminated double-quoted text".into(),
223            })?;
224        let mut out = String::new();
225        let mut chars = inner.chars();
226        while let Some(c) = chars.next() {
227            if c != '\\' {
228                out.push(c);
229                continue;
230            }
231            match chars.next() {
232                Some('n') => out.push('\n'),
233                Some('t') => out.push('\t'),
234                Some('r') => out.push('\r'),
235                Some('0') => out.push('\0'),
236                Some('"') => out.push('"'),
237                Some('\\') => out.push('\\'),
238                Some('/') => out.push('/'),
239                Some(' ') => out.push(' '),
240                Some('u') => {
241                    let hex: String = chars.by_ref().take(4).collect();
242                    let c = u32::from_str_radix(&hex, 16)
243                        .ok()
244                        .filter(|_| hex.len() == 4)
245                        .and_then(char::from_u32)
246                        .ok_or_else(|| YamlError {
247                            line: number,
248                            message: "invalid \\u escape".into(),
249                        })?;
250                    out.push(c);
251                }
252                _ => return fail(number, "invalid escape in double-quoted text"),
253            }
254        }
255        return Ok(out);
256    }
257    if let Some(inner) = text.strip_prefix('\'') {
258        let inner = inner
259            .strip_suffix('\'')
260            .filter(|_| text.len() >= 2)
261            .ok_or_else(|| YamlError {
262                line: number,
263                message: "unterminated single-quoted text".into(),
264            })?;
265        return Ok(inner.replace("''", "'"));
266    }
267    if text == "~" || text == "null" {
268        return Ok(String::new());
269    }
270    Ok(text.to_owned())
271}
272
273/// The text of a value, in quotes only when needed.
274pub fn render_scalar(value: &str) -> String {
275    let lower = value.to_ascii_lowercase();
276    let needs_quotes = value.is_empty()
277        || value != value.trim()
278        || value.contains(" #")
279        || value.contains(": ")
280        || value.ends_with(':')
281        || value.chars().any(|c| c.is_control())
282        || value.starts_with([
283            '-', '?', ':', ',', '[', ']', '{', '}', '#', '&', '*', '!', '|', '>', '\'', '"', '%',
284            '@', '`',
285        ])
286        || matches!(
287            lower.as_str(),
288            "null" | "~" | "true" | "false" | "yes" | "no" | "on" | "off"
289        );
290    if !needs_quotes {
291        return value.to_owned();
292    }
293    let mut out = String::from("\"");
294    for c in value.chars() {
295        match c {
296            '"' => out.push_str("\\\""),
297            '\\' => out.push_str("\\\\"),
298            '\n' => out.push_str("\\n"),
299            '\t' => out.push_str("\\t"),
300            '\r' => out.push_str("\\r"),
301            c if c.is_control() => out.push_str(&format!("\\u{:04x}", c as u32)),
302            c => out.push(c),
303        }
304    }
305    out.push('"');
306    out
307}
308
309/// Writes the map with two spaces per level.
310pub fn render(map: &[(String, Node)]) -> String {
311    let mut out = String::new();
312    render_into(map, 0, &mut out);
313    out
314}
315
316fn render_into(map: &[(String, Node)], indent: usize, out: &mut String) {
317    for (key, node) in map {
318        out.push_str(&" ".repeat(indent));
319        out.push_str(&render_scalar(key));
320        match node {
321            Node::Text(t) => {
322                out.push_str(": ");
323                out.push_str(&render_scalar(t));
324                out.push('\n');
325            }
326            Node::Map(children) if children.is_empty() => out.push_str(": {}\n"),
327            Node::Map(children) => {
328                out.push_str(":\n");
329                render_into(children, indent + 2, out);
330            }
331        }
332    }
333}
334
335#[cfg(test)]
336mod tests;