1#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct YamlLiteError {
22 pub message: String,
24}
25
26impl std::fmt::Display for YamlLiteError {
27 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 f.write_str(&self.message)
29 }
30}
31
32impl std::error::Error for YamlLiteError {}
33
34fn err(line: usize, message: impl std::fmt::Display) -> YamlLiteError {
35 YamlLiteError {
36 message: format!("line {line}: {message}"),
37 }
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42#[non_exhaustive]
43pub enum YamlValue {
44 Null,
46 Scalar(String),
48 Bool(bool),
50 Sequence(Vec<String>),
52}
53
54impl YamlValue {
55 fn kind(&self) -> &'static str {
56 match self {
57 Self::Null => "null",
58 Self::Scalar(_) => "a string",
59 Self::Bool(_) => "a boolean",
60 Self::Sequence(_) => "a sequence",
61 }
62 }
63}
64
65#[derive(Debug, Clone, Default, PartialEq, Eq)]
67pub struct YamlMap {
68 entries: Vec<(String, YamlValue)>,
69}
70
71impl YamlMap {
72 pub fn get(&self, key: &str) -> Option<&YamlValue> {
74 self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v)
75 }
76
77 pub fn optional_str(&self, key: &str) -> Result<Option<String>, YamlLiteError> {
79 match self.get(key) {
80 None | Some(YamlValue::Null) => Ok(None),
81 Some(YamlValue::Scalar(s)) => Ok(Some(s.clone())),
82 Some(other) => Err(type_error(key, "a string", other)),
83 }
84 }
85
86 pub fn require_str(&self, key: &str) -> Result<String, YamlLiteError> {
88 self.optional_str(key)?.ok_or_else(|| YamlLiteError {
89 message: format!("missing field `{key}`"),
90 })
91 }
92
93 pub fn optional_bool(&self, key: &str) -> Result<bool, YamlLiteError> {
95 match self.get(key) {
96 None | Some(YamlValue::Null) => Ok(false),
97 Some(YamlValue::Bool(b)) => Ok(*b),
98 Some(other) => Err(type_error(key, "a boolean", other)),
99 }
100 }
101
102 pub fn optional_str_seq(&self, key: &str) -> Result<Vec<String>, YamlLiteError> {
104 match self.get(key) {
105 None | Some(YamlValue::Null) => Ok(Vec::new()),
106 Some(YamlValue::Sequence(items)) => Ok(items.clone()),
107 Some(other) => Err(type_error(key, "a sequence", other)),
108 }
109 }
110}
111
112fn type_error(key: &str, expected: &str, found: &YamlValue) -> YamlLiteError {
113 YamlLiteError {
114 message: format!("field `{key}`: expected {expected}, found {}", found.kind()),
115 }
116}
117
118pub fn parse_yaml_lite(input: &str) -> Result<YamlMap, YamlLiteError> {
122 let mut map = YamlMap::default();
123 let mut pending: Option<usize> = None;
125
126 for (idx, raw_line) in input.lines().enumerate() {
127 let line = idx + 1;
128 let trimmed = raw_line.trim();
129 if trimmed.is_empty() || trimmed.starts_with('#') {
130 continue;
131 }
132
133 if trimmed == "-" {
134 return Err(err(line, "sequence items must be inline scalars"));
135 }
136 if let Some(item) = trimmed.strip_prefix("- ") {
137 let Some(entry) = pending else {
138 return Err(err(line, "sequence item outside a `key:` block"));
139 };
140 let value = parse_scalar(item.trim(), line)?;
141 let slot = &mut map.entries[entry].1;
142 match slot {
143 YamlValue::Null => *slot = YamlValue::Sequence(vec![value]),
144 YamlValue::Sequence(items) => items.push(value),
145 _ => return Err(err(line, "sequence item cannot follow a scalar value")),
146 }
147 continue;
148 }
149
150 if raw_line.starts_with([' ', '\t']) {
151 return Err(err(
152 line,
153 "nested mappings and multi-line values are not supported",
154 ));
155 }
156
157 let Some((key, rest)) = trimmed.split_once(':') else {
158 return Err(err(line, "expected `key: value`"));
159 };
160 if !rest.is_empty() && !rest.starts_with([' ', '\t']) {
161 return Err(err(line, "expected a space after `key:`"));
162 }
163 let key = key.trim();
164 if key.is_empty() {
165 return Err(err(line, "empty key"));
166 }
167 if key.starts_with(['"', '\'']) {
168 return Err(err(line, "quoted keys are not supported"));
169 }
170 if map.get(key).is_some() {
171 return Err(err(line, format!("duplicate key `{key}`")));
172 }
173
174 let value = parse_value(rest.trim(), line)?;
175 pending = matches!(value, YamlValue::Null).then_some(map.entries.len());
176 map.entries.push((key.to_string(), value));
177 }
178
179 Ok(map)
180}
181
182fn parse_value(raw: &str, line: usize) -> Result<YamlValue, YamlLiteError> {
184 let Some(first) = raw.chars().next() else {
185 return Ok(YamlValue::Null);
186 };
187 match first {
188 '#' => Ok(YamlValue::Null),
189 '[' => parse_flow_sequence(raw, line).map(YamlValue::Sequence),
190 '{' => Err(err(line, "flow mappings are not supported")),
191 '|' | '>' => Err(err(line, "block scalars (`|`, `>`) are not supported")),
192 '&' | '*' | '!' => Err(err(
193 line,
194 "anchors, aliases and tags (`&`, `*`, `!`) are not supported",
195 )),
196 _ => {
197 let quoted = first == '"' || first == '\'';
198 let scalar = parse_scalar(raw, line)?;
199 if !quoted {
200 match scalar.as_str() {
201 "true" | "True" | "TRUE" => return Ok(YamlValue::Bool(true)),
202 "false" | "False" | "FALSE" => return Ok(YamlValue::Bool(false)),
203 "" => return Ok(YamlValue::Null),
204 _ => {}
205 }
206 }
207 Ok(YamlValue::Scalar(scalar))
208 }
209 }
210}
211
212fn parse_scalar(raw: &str, line: usize) -> Result<String, YamlLiteError> {
214 let raw = raw.trim();
215 if raw.starts_with(['"', '\'']) {
216 return parse_quoted(raw, line);
217 }
218 let value = match raw.find(" #") {
220 Some(i) => &raw[..i],
221 None => raw,
222 };
223 Ok(value.trim_end().to_string())
224}
225
226fn parse_quoted(raw: &str, line: usize) -> Result<String, YamlLiteError> {
227 let quote = if raw.starts_with('"') { '"' } else { '\'' };
228 let body = &raw[1..];
229 let mut out = String::new();
230 let mut chars = body.char_indices();
231
232 while let Some((i, ch)) = chars.next() {
233 if quote == '"' && ch == '\\' {
234 let Some((_, esc)) = chars.next() else {
235 break;
236 };
237 out.push(match esc {
238 '"' => '"',
239 '\\' => '\\',
240 'n' => '\n',
241 't' => '\t',
242 other => {
243 return Err(err(
244 line,
245 format!("unsupported escape `\\{other}` in a double-quoted string"),
246 ));
247 }
248 });
249 continue;
250 }
251 if ch == quote {
252 if quote == '\'' && body[i + 1..].starts_with('\'') {
254 out.push('\'');
255 chars.next();
256 continue;
257 }
258 let tail = body[i + 1..].trim();
259 if !tail.is_empty() && !tail.starts_with('#') {
260 return Err(err(line, "trailing content after a quoted string"));
261 }
262 return Ok(out);
263 }
264 out.push(ch);
265 }
266
267 Err(err(line, "unterminated quoted string"))
268}
269
270fn parse_flow_sequence(raw: &str, line: usize) -> Result<Vec<String>, YamlLiteError> {
271 let close = raw
272 .rfind(']')
273 .ok_or_else(|| err(line, "unterminated flow sequence (expected a closing `]`)"))?;
274 let tail = raw[close + 1..].trim();
275 if !tail.is_empty() && !tail.starts_with('#') {
276 return Err(err(line, "trailing content after a flow sequence"));
277 }
278
279 let inner = raw[1..close].trim();
280 if inner.is_empty() {
281 return Ok(Vec::new());
282 }
283
284 let mut pieces: Vec<&str> = Vec::new();
286 let mut start = 0usize;
287 let mut quote: Option<char> = None;
288 let mut escaped = false;
289 for (i, ch) in inner.char_indices() {
290 if escaped {
291 escaped = false;
292 continue;
293 }
294 match quote {
295 Some(q) => {
296 if q == '"' && ch == '\\' {
297 escaped = true;
298 } else if ch == q {
299 quote = None;
300 }
301 }
302 None => match ch {
303 '"' | '\'' => quote = Some(ch),
304 '[' | '{' => {
305 return Err(err(line, "nested flow collections are not supported"));
306 }
307 ',' => {
308 pieces.push(&inner[start..i]);
309 start = i + 1;
310 }
311 _ => {}
312 },
313 }
314 }
315 if quote.is_some() {
316 return Err(err(line, "unterminated quoted string in a flow sequence"));
317 }
318 pieces.push(&inner[start..]);
319
320 pieces
321 .into_iter()
322 .map(|piece| {
323 let piece = piece.trim();
324 if piece.is_empty() {
325 return Err(err(line, "empty item in a flow sequence"));
326 }
327 parse_scalar(piece, line)
328 })
329 .collect()
330}
331
332#[cfg(test)]
333mod tests {
334 use super::*;
335
336 fn parse(input: &str) -> YamlMap {
337 parse_yaml_lite(input).expect("expected the input to parse")
338 }
339
340 #[test]
341 fn parses_plain_quoted_and_escaped_scalars() {
342 let map = parse(
343 "title: Plain Value\nsingle: 'it''s here'\ndouble: \"a \\\"quoted\\\" \\\\ path\"",
344 );
345 assert_eq!(
346 map.optional_str("title").unwrap().as_deref(),
347 Some("Plain Value")
348 );
349 assert_eq!(
350 map.optional_str("single").unwrap().as_deref(),
351 Some("it's here")
352 );
353 assert_eq!(
354 map.optional_str("double").unwrap().as_deref(),
355 Some("a \"quoted\" \\ path")
356 );
357 }
358
359 #[test]
360 fn skips_comment_lines_and_trailing_comments() {
361 let map = parse("# leading comment\n\ntitle: Hello # trailing\nicon: \"book\" # also\n");
362 assert_eq!(map.optional_str("title").unwrap().as_deref(), Some("Hello"));
363 assert_eq!(map.optional_str("icon").unwrap().as_deref(), Some("book"));
364 }
365
366 #[test]
367 fn hash_without_leading_space_stays_in_a_plain_scalar() {
368 let map = parse("color: \"#ff0000\"\nanchor: intro#section");
369 assert_eq!(
370 map.optional_str("color").unwrap().as_deref(),
371 Some("#ff0000")
372 );
373 assert_eq!(
374 map.optional_str("anchor").unwrap().as_deref(),
375 Some("intro#section")
376 );
377 }
378
379 #[test]
380 fn parses_block_and_flow_sequences() {
381 let block = parse("tags:\n - rust\n - \"dioxus\"\ntitle: T");
382 assert_eq!(block.optional_str_seq("tags").unwrap(), ["rust", "dioxus"]);
383 assert_eq!(block.optional_str("title").unwrap().as_deref(), Some("T"));
384
385 let flow = parse("tags: [\"announcement\", dioxus, 'x, y']");
386 assert_eq!(
387 flow.optional_str_seq("tags").unwrap(),
388 ["announcement", "dioxus", "x, y"]
389 );
390
391 assert_eq!(
392 parse("tags: []").optional_str_seq("tags").unwrap(),
393 Vec::<String>::new()
394 );
395 }
396
397 #[test]
398 fn unindented_block_sequences_are_accepted() {
399 let map = parse("tags:\n- rust\n- wasm");
400 assert_eq!(map.optional_str_seq("tags").unwrap(), ["rust", "wasm"]);
401 }
402
403 #[test]
404 fn parses_booleans_only_when_unquoted() {
405 let map = parse("draft: true\nfeatured: False\nlabel: \"true\"");
406 assert!(map.optional_bool("draft").unwrap());
407 assert!(!map.optional_bool("featured").unwrap());
408 assert_eq!(map.optional_str("label").unwrap().as_deref(), Some("true"));
409 assert!(map.optional_bool("label").is_err());
411 assert!(!map.optional_bool("missing").unwrap());
413 }
414
415 #[test]
416 fn required_and_typed_field_errors() {
417 let map = parse("title: Hi\ntags: nope");
418 let err = map.require_str("date").unwrap_err();
419 assert!(err.to_string().contains("date"), "got: {err}");
420 let err = map.optional_str_seq("tags").unwrap_err();
421 assert!(
422 err.to_string().contains("expected a sequence"),
423 "got: {err}"
424 );
425 }
426
427 #[test]
428 fn empty_input_and_null_values() {
429 let map = parse("");
430 assert_eq!(map.optional_str("title").unwrap(), None);
431
432 let map = parse("description:\ntitle: Hi");
433 assert_eq!(map.get("description"), Some(&YamlValue::Null));
434 assert_eq!(map.optional_str("description").unwrap(), None);
435 assert_eq!(
436 map.optional_str_seq("description").unwrap(),
437 Vec::<String>::new()
438 );
439 }
440
441 #[test]
442 fn unsupported_shapes_are_errors() {
443 for input in [
444 "author:\n name: Jane", "body: |\n line one\n line two", "body: >\n folded", "base: &anchor value", "copy: *anchor", "meta: { a: 1 }", "- a\n- b", "Just a fenced paragraph.", "title: A\ntitle: B", "title: \"unterminated", "tags: [a, [b]]", "tags: [a", "title:Hello", ] {
458 assert!(
459 parse_yaml_lite(input).is_err(),
460 "expected an error for: {input:?}"
461 );
462 }
463 }
464}