metrale_config/
recipe_yaml.rs1use anyhow::{Result, bail};
20use std::collections::BTreeMap;
21
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub enum Yaml {
24 Scalar(String),
25 List(Vec<Yaml>),
26 Map(BTreeMap<String, Yaml>),
27}
28
29impl Yaml {
30 pub fn as_str(&self) -> Option<&str> {
31 match self {
32 Yaml::Scalar(s) => Some(s),
33 _ => None,
34 }
35 }
36
37 pub fn as_map(&self) -> Option<&BTreeMap<String, Yaml>> {
38 match self {
39 Yaml::Map(m) => Some(m),
40 _ => None,
41 }
42 }
43}
44
45struct Line {
47 no: usize,
48 indent: usize,
49 text: String,
50}
51
52pub fn parse(input: &str) -> Result<Yaml> {
54 let lines = significant(input)?;
55 let (value, end) = parse_block(&lines, 0, 0)?;
56 if end != lines.len() {
57 let l = &lines[end];
58 bail!("line {}: unexpected indentation at {:?}", l.no, l.text);
59 }
60 match value {
61 Yaml::Map(_) => Ok(value),
62 _ => bail!("the document must be a mapping"),
63 }
64}
65
66fn significant(input: &str) -> Result<Vec<Line>> {
69 let mut out = Vec::new();
70 let mut raw = input.lines().enumerate().peekable();
71 while let Some((i, line)) = raw.next() {
72 let no = i + 1;
73 let indent = line.len() - line.trim_start().len();
74 let trimmed = line.trim_start();
75 if trimmed.is_empty() || trimmed.starts_with('#') {
76 continue;
77 }
78 let text = strip_comment(trimmed);
79 if text.is_empty() {
80 continue;
81 }
82 let is_literal = text.ends_with(": |") || text.ends_with(": |-");
83 if !is_literal {
84 out.push(Line { no, indent, text });
85 continue;
86 }
87 let mut body: Vec<String> = Vec::new();
90 let mut block_indent = None;
91 while let Some((_, peek)) = raw.peek() {
92 let peek_indent = peek.len() - peek.trim_start().len();
93 if !peek.trim().is_empty() && peek_indent <= indent {
94 break;
95 }
96 let (_, l) = raw.next().expect("peeked");
97 if l.trim().is_empty() {
98 body.push(String::new());
99 continue;
100 }
101 let base = *block_indent.get_or_insert(peek_indent);
102 body.push(l.chars().skip(base).collect());
103 }
104 while body.last().is_some_and(|l| l.is_empty()) {
105 body.pop();
106 }
107 let key = text.trim_end_matches(['|', '-', ' ']).trim_end_matches(':');
110 out.push(Line {
111 no,
112 indent,
113 text: format!("{key}:\u{0}{}", body.join("\n")),
114 });
115 }
116 Ok(out)
117}
118
119fn strip_comment(s: &str) -> String {
122 let mut in_quotes = false;
123 for (i, c) in s.char_indices() {
124 match c {
125 '"' => in_quotes = !in_quotes,
126 '#' if !in_quotes && (i == 0 || s.as_bytes()[i - 1] == b' ') => {
127 return s[..i].trim_end().to_string();
128 }
129 _ => {}
130 }
131 }
132 s.trim_end().to_string()
133}
134
135fn parse_block(lines: &[Line], start: usize, indent: usize) -> Result<(Yaml, usize)> {
137 if lines.get(start).is_some_and(|l| l.text.starts_with("- ")) {
138 return parse_list(lines, start, indent);
139 }
140 let mut map = BTreeMap::new();
141 let mut i = start;
142 while i < lines.len() {
143 let line = &lines[i];
144 if line.indent < indent {
145 break;
146 }
147 if line.indent > indent {
148 bail!(
149 "line {}: unexpected indentation at {:?}",
150 line.no,
151 line.text
152 );
153 }
154 let Some((key, rest)) = line.text.split_once(':') else {
155 bail!(
156 "line {}: expected `key: value`, found {:?}",
157 line.no,
158 line.text
159 );
160 };
161 let key = key.trim().to_string();
162 if key.is_empty() {
163 bail!("line {}: empty key", line.no);
164 }
165 if let Some(body) = rest.strip_prefix('\u{0}') {
167 map.insert(key, Yaml::Scalar(body.to_string()));
168 i += 1;
169 continue;
170 }
171 let rest = rest.trim();
172 if rest == "{}" {
173 map.insert(key, Yaml::Map(BTreeMap::new()));
174 i += 1;
175 continue;
176 }
177 if !rest.is_empty() {
178 map.insert(key, Yaml::Scalar(unquote(rest)));
179 i += 1;
180 continue;
181 }
182 let next_indent = lines.get(i + 1).map(|l| l.indent).unwrap_or(0);
184 if next_indent <= indent {
185 bail!(
186 "line {}: {key:?} has no value and no indented block",
187 line.no
188 );
189 }
190 let (value, next) = parse_block(lines, i + 1, next_indent)?;
191 map.insert(key, value);
192 i = next;
193 }
194 Ok((Yaml::Map(map), i))
195}
196
197fn parse_list(lines: &[Line], start: usize, indent: usize) -> Result<(Yaml, usize)> {
198 let mut items = Vec::new();
199 let mut i = start;
200 while i < lines.len() {
201 let line = &lines[i];
202 if line.indent < indent {
203 break;
204 }
205 let Some(item) = line.text.strip_prefix("- ") else {
206 bail!(
207 "line {}: expected a `- ` item, found {:?}",
208 line.no,
209 line.text
210 );
211 };
212 items.push(Yaml::Scalar(unquote(item.trim())));
213 i += 1;
214 }
215 Ok((Yaml::List(items), i))
216}
217
218fn unquote(s: &str) -> String {
221 let b = s.as_bytes();
222 if b.len() >= 2
223 && (b[0] == b'"' && b[b.len() - 1] == b'"' || b[0] == b'\'' && b[b.len() - 1] == b'\'')
224 {
225 return s[1..s.len() - 1].to_string();
226 }
227 s.to_string()
228}
229
230#[cfg(test)]
231#[path = "recipe_yaml_tests.rs"]
232mod tests;