1use crate::Document;
24use crate::error::{IncludeError, Result};
25use crate::node::{Node, NodeMap};
26use crate::options::{Disabled, EntryOptions};
27use crate::patch::PatchOptions;
28
29const JS_TAG: &str = "tag:yaml.org,2002:js";
31
32fn core_tag(tag: &str) -> Option<&'static str> {
34 const CORE: [&str; 5] = ["bool", "int", "float", "null", "str"];
35 let local = tag.strip_prefix("tag:yaml.org,2002:")?;
36 CORE.into_iter().find(|candidate| *candidate == local)
37}
38
39#[derive(Debug)]
41struct DialectError {
42 message: String,
43}
44
45impl std::fmt::Display for DialectError {
46 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47 f.write_str(&self.message)
48 }
49}
50
51impl std::error::Error for DialectError {}
52
53fn yaml_error(message: impl Into<String>) -> IncludeError {
54 IncludeError::Parse {
55 format: "yaml",
56 source: Box::new(DialectError {
57 message: message.into(),
58 }),
59 }
60}
61
62mod sys {
67 #![allow(unsafe_code)]
68 #[allow(clippy::unsafe_removed_from_name)]
75 use unsafe_libyaml as sys;
76
77 pub(super) struct Event {
79 pub kind: EventKind,
80 pub anchor: Option<String>,
81 pub tag: Option<String>,
82 pub value: String,
83 pub style: ScalarStyle,
84 pub line: u64,
86 pub column: u64,
87 }
88
89 pub(super) enum EventKind {
90 StreamStart,
91 StreamEnd,
92 DocumentStart,
93 DocumentEnd,
94 Alias,
95 Scalar,
96 SequenceStart,
97 SequenceEnd,
98 MappingStart,
99 MappingEnd,
100 }
101
102 pub(super) enum ScalarStyle {
103 Plain,
104 Quoted,
105 }
106
107 pub(super) struct Error {
109 pub problem: String,
110 pub line: u64,
111 pub column: u64,
112 }
113
114 pub(super) struct Parser {
115 raw: std::boxed::Box<std::mem::MaybeUninit<sys::yaml_parser_t>>,
116 }
117
118 impl Parser {
119 pub(super) fn new(input: &[u8]) -> Parser {
120 let mut raw = std::boxed::Box::new(std::mem::MaybeUninit::uninit());
121 unsafe {
125 let parser = raw.as_mut_ptr();
126 if sys::yaml_parser_initialize(parser).fail {
127 panic!("libyaml parser allocation failed");
128 }
129 sys::yaml_parser_set_encoding(parser, sys::YAML_UTF8_ENCODING);
130 sys::yaml_parser_set_input_string(parser, input.as_ptr(), input.len() as u64);
131 }
132 Parser { raw }
133 }
134
135 pub(super) fn next(&mut self) -> std::result::Result<Event, Error> {
136 let mut event = std::mem::MaybeUninit::<sys::yaml_event_t>::uninit();
137 unsafe {
142 let parser = self.raw.as_mut_ptr();
143 if sys::yaml_parser_parse(parser, event.as_mut_ptr()).fail {
144 return Err(Self::error(parser));
145 }
146 let converted = convert_event(&*event.as_ptr());
147 sys::yaml_event_delete(event.as_mut_ptr());
148 Ok(converted)
149 }
150 }
151
152 unsafe fn error(parser: *mut sys::yaml_parser_t) -> Error {
158 unsafe {
159 let parser: &sys::yaml_parser_t = &*parser;
160 let problem = cstring(std::ptr::addr_of!(parser.problem).cast())
161 .unwrap_or_else(|| "libyaml parser failed".to_owned());
162 let mark = std::ptr::addr_of!(parser.problem_mark).read();
163 Error {
164 problem,
165 line: mark.line,
166 column: mark.column,
167 }
168 }
169 }
170 }
171
172 impl Drop for Parser {
173 fn drop(&mut self) {
174 unsafe {
179 sys::yaml_parser_delete(self.raw.as_mut_ptr());
180 }
181 }
182 }
183
184 unsafe fn cstring(ptr: *const u8) -> Option<String> {
186 unsafe {
187 if ptr.is_null() {
188 return None;
189 }
190 let mut length = 0usize;
191 while *ptr.add(length) != 0 {
192 length += 1;
193 }
194 let bytes = std::slice::from_raw_parts(ptr, length);
195 Some(String::from_utf8_lossy(bytes).into_owned())
196 }
197 }
198
199 unsafe fn convert_event(event: &sys::yaml_event_t) -> Event {
202 let mark = event.start_mark;
203 let base = |kind| Event {
204 kind,
205 anchor: None,
206 tag: None,
207 value: String::new(),
208 style: ScalarStyle::Plain,
209 line: mark.line,
210 column: mark.column,
211 };
212 unsafe {
215 match event.type_ {
216 sys::YAML_STREAM_START_EVENT => base(EventKind::StreamStart),
217 sys::YAML_STREAM_END_EVENT => base(EventKind::StreamEnd),
218 sys::YAML_DOCUMENT_START_EVENT => base(EventKind::DocumentStart),
219 sys::YAML_DOCUMENT_END_EVENT => base(EventKind::DocumentEnd),
220 sys::YAML_ALIAS_EVENT => Event {
221 anchor: cstring(event.data.alias.anchor),
222 ..base(EventKind::Alias)
223 },
224 sys::YAML_SCALAR_EVENT => {
225 let length = event.data.scalar.length as usize;
226 let bytes = if event.data.scalar.value.is_null() {
227 &[][..]
228 } else {
229 std::slice::from_raw_parts(event.data.scalar.value, length)
230 };
231 Event {
232 kind: EventKind::Scalar,
233 anchor: cstring(event.data.scalar.anchor),
234 tag: cstring(event.data.scalar.tag),
235 value: String::from_utf8_lossy(bytes).into_owned(),
236 style: match event.data.scalar.style {
237 sys::YAML_PLAIN_SCALAR_STYLE => ScalarStyle::Plain,
238 _ => ScalarStyle::Quoted,
239 },
240 line: mark.line,
241 column: mark.column,
242 }
243 }
244 sys::YAML_SEQUENCE_START_EVENT => Event {
245 anchor: cstring(event.data.sequence_start.anchor),
246 tag: cstring(event.data.sequence_start.tag),
247 ..base(EventKind::SequenceStart)
248 },
249 sys::YAML_SEQUENCE_END_EVENT => base(EventKind::SequenceEnd),
250 sys::YAML_MAPPING_START_EVENT => Event {
251 anchor: cstring(event.data.mapping_start.anchor),
252 tag: cstring(event.data.mapping_start.tag),
253 ..base(EventKind::MappingStart)
254 },
255 sys::YAML_MAPPING_END_EVENT => base(EventKind::MappingEnd),
256 _ => unreachable!("libyaml produced an event outside the parser state machine"),
257 }
258 }
259 }
260}
261
262pub fn parse_node(source: &str) -> Result<Node> {
272 let mut parser = sys::Parser::new(source.as_bytes());
273 let mut anchors: Vec<(String, Node)> = Vec::new();
274 let mut stack: Vec<Frame> = Vec::new();
275 let mut root: Option<Node> = None;
276 let mut documents = 0usize;
277
278 loop {
279 let event = parser.next().map_err(|error| {
280 yaml_error(format!(
281 "{} at line {} column {}",
282 error.problem,
283 error.line + 1,
284 error.column + 1
285 ))
286 })?;
287 match event.kind {
288 sys::EventKind::StreamStart | sys::EventKind::DocumentEnd => {}
289 sys::EventKind::DocumentStart => {
290 documents += 1;
291 if documents > 1 {
292 return Err(yaml_error(
293 "deserializing from YAML containing more than one document is not supported",
294 ));
295 }
296 }
297 sys::EventKind::StreamEnd => break,
298 sys::EventKind::Scalar => {
299 if let Some(Frame::Map {
302 key: slot @ None, ..
303 }) = stack.last_mut()
304 {
305 *slot = Some(event.value.clone());
306 } else {
307 let node = resolve_scalar(&event)?;
308 register_anchor(&mut anchors, &event.anchor, &node);
309 feed(&mut stack, &mut root, node)?;
310 }
311 }
312 sys::EventKind::Alias => {
313 let Some(anchor) = &event.anchor else {
314 return Err(yaml_error("alias without an anchor"));
315 };
316 let Some(node) = anchors
317 .iter()
318 .rev()
319 .find(|(name, _)| name == anchor)
320 .map(|(_, node)| node.clone())
321 else {
322 return Err(yaml_error(format!(
323 "unknown anchor {anchor:?} at line {} column {}",
324 event.line + 1,
325 event.column + 1
326 )));
327 };
328 feed(&mut stack, &mut root, node)?;
329 }
330 sys::EventKind::SequenceStart => {
331 reject_local_tag(&event)?;
332 stack.push(Frame::Seq {
333 items: Vec::new(),
334 anchor: event.anchor,
335 });
336 }
337 sys::EventKind::SequenceEnd => {
338 let Some(Frame::Seq { items, anchor }) = stack.pop() else {
339 return Err(yaml_error("unbalanced sequence end"));
340 };
341 let node = Node::Array(items);
342 register_anchor(&mut anchors, &anchor, &node);
343 feed(&mut stack, &mut root, node)?;
344 }
345 sys::EventKind::MappingStart => {
346 reject_local_tag(&event)?;
347 stack.push(Frame::Map {
348 map: NodeMap::new(),
349 key: None,
350 anchor: event.anchor,
351 });
352 }
353 sys::EventKind::MappingEnd => {
354 let Some(Frame::Map {
355 map,
356 key: None,
357 anchor,
358 }) = stack.pop()
359 else {
360 return Err(yaml_error("unbalanced mapping end"));
361 };
362 let node = Node::Object(map);
363 register_anchor(&mut anchors, &anchor, &node);
364 feed(&mut stack, &mut root, node)?;
365 }
366 }
367 }
368 Ok(root.unwrap_or(Node::Null))
369}
370
371enum Frame {
373 Seq {
374 items: Vec<Node>,
375 anchor: Option<String>,
376 },
377 Map {
378 map: NodeMap,
379 key: Option<String>,
380 anchor: Option<String>,
381 },
382}
383
384fn register_anchor(anchors: &mut Vec<(String, Node)>, anchor: &Option<String>, node: &Node) {
385 if let Some(anchor) = anchor {
386 anchors.push((anchor.clone(), node.clone()));
387 }
388}
389
390fn feed(stack: &mut [Frame], root: &mut Option<Node>, node: Node) -> Result<()> {
392 match stack.last_mut() {
393 None => {
394 if root.is_some() {
395 return Err(yaml_error("multiple root values in one document"));
396 }
397 *root = Some(node);
398 }
399 Some(Frame::Seq { items, .. }) => items.push(node),
400 Some(Frame::Map { map, key, .. }) => match key.take() {
401 None => {
402 return Err(yaml_error(format!(
403 "mapping keys must be scalars, found {}",
404 node_kind(&node)
405 )));
406 }
407 Some(name) => {
408 map.insert(name, node);
409 }
410 },
411 }
412 Ok(())
413}
414
415fn reject_local_tag(event: &sys::Event) -> Result<()> {
417 if event.tag.as_deref().is_some_and(|tag| tag.starts_with('!')) {
418 return Err(yaml_error(format!(
419 "local tags are not supported: {}",
420 event.tag.as_deref().unwrap_or_default()
421 )));
422 }
423 Ok(())
424}
425
426fn resolve_scalar(event: &sys::Event) -> Result<Node> {
429 let value = &event.value;
430 if let Some(tag) = &event.tag {
431 if tag == JS_TAG {
432 return Ok(Node::Expr(value.clone()));
433 }
434 if let Some(core) = core_tag(tag) {
435 return match core {
436 "bool" => parse_bool(value)
437 .map(Node::Bool)
438 .ok_or_else(|| yaml_error(format!("invalid boolean {value:?}"))),
439 "int" => {
440 try_int(value)?.ok_or_else(|| yaml_error(format!("invalid integer {value:?}")))
441 }
442 "float" => parse_f64(value)
443 .map(Node::Float)
444 .ok_or_else(|| yaml_error(format!("invalid float {value:?}"))),
445 "null" => parse_null(value)
446 .map(|()| Node::Null)
447 .ok_or_else(|| yaml_error(format!("invalid null {value:?}"))),
448 "str" => Ok(Node::String(value.clone())),
449 _ => unreachable!("core_tag filters its output"),
450 };
451 }
452 if tag.starts_with('!') {
453 return Err(yaml_error(format!("local tags are not supported: {tag}")));
456 }
457 return Ok(Node::String(value.clone()));
460 }
461 if matches!(event.style, sys::ScalarStyle::Plain) {
462 resolve_plain(value)
463 } else {
464 Ok(Node::String(value.clone()))
465 }
466}
467
468fn resolve_plain(value: &str) -> Result<Node> {
472 if value.is_empty() || parse_null(value).is_some() {
473 return Ok(Node::Null);
474 }
475 if let Some(boolean) = parse_bool(value) {
476 return Ok(Node::Bool(boolean));
477 }
478 if let Some(node) = try_int(value)? {
479 return Ok(node);
480 }
481 if !digits_but_not_number(value) {
482 if let Some(float) = parse_f64(value) {
483 return Ok(Node::Float(float));
484 }
485 }
486 Ok(Node::String(value.to_owned()))
487}
488
489fn small_uint(value: u64) -> Node {
492 if value <= i64::MAX as u64 {
493 Node::Int(value as i64)
494 } else {
495 Node::UInt(value)
496 }
497}
498
499fn digits_but_not_number(scalar: &str) -> bool {
501 let scalar = scalar.strip_prefix(['-', '+']).unwrap_or(scalar);
502 scalar.len() > 1 && scalar.starts_with('0') && scalar[1..].bytes().all(|b| b.is_ascii_digit())
503}
504
505fn parse_unsigned(scalar: &str, radix_skip: fn(&str, u32) -> Option<u64>) -> Option<u64> {
508 let unpositive = scalar.strip_prefix('+').unwrap_or(scalar);
509 if let Some(rest) = unpositive.strip_prefix("0x") {
510 if !rest.starts_with(['+', '-']) {
511 if let Some(int) = radix_skip(rest, 16) {
512 return Some(int);
513 }
514 }
515 }
516 if let Some(rest) = unpositive.strip_prefix("0o") {
517 if !rest.starts_with(['+', '-']) {
518 if let Some(int) = radix_skip(rest, 8) {
519 return Some(int);
520 }
521 }
522 }
523 if let Some(rest) = unpositive.strip_prefix("0b") {
524 if !rest.starts_with(['+', '-']) {
525 if let Some(int) = radix_skip(rest, 2) {
526 return Some(int);
527 }
528 }
529 }
530 if unpositive.starts_with(['+', '-']) {
531 return None;
532 }
533 if digits_but_not_number(scalar) {
534 return None;
535 }
536 radix_skip(unpositive, 10)
537}
538
539fn parse_negative(scalar: &str, radix_skip: fn(&str, u32) -> Option<i64>) -> Option<i64> {
543 for prefix in ["-0x", "-0o", "-0b"] {
544 if let Some(rest) = scalar.strip_prefix(prefix) {
545 let radix = match prefix {
546 "-0x" => 16,
547 "-0o" => 8,
548 _ => 2,
549 };
550 if let Some(int) = radix_skip(rest, radix) {
551 return Some(-int);
552 }
553 }
554 }
555 if digits_but_not_number(scalar) {
556 return None;
557 }
558 radix_skip(scalar, 10)
559}
560
561fn u64_radix(text: &str, radix: u32) -> Option<u64> {
562 u64::from_str_radix(text, radix).ok()
563}
564
565fn i64_radix(text: &str, radix: u32) -> Option<i64> {
566 i64::from_str_radix(text, radix).ok()
567}
568
569fn try_int(scalar: &str) -> Result<Option<Node>> {
573 if let Some(unsigned) = parse_unsigned(scalar, u64_radix) {
574 return Ok(Some(small_uint(unsigned)));
575 }
576 if let Some(signed) = parse_negative(scalar, i64_radix) {
577 return Ok(Some(Node::Int(signed)));
578 }
579 if parse_unsigned(scalar, |text, radix| {
581 u128::from_str_radix(text, radix).ok().map(|_| 0)
582 })
583 .is_some()
584 || parse_negative(scalar, |text, radix| {
585 i128::from_str_radix(text, radix).ok().map(|_| 0)
586 })
587 .is_some()
588 {
589 return Err(yaml_error(format!("integer out of range: {scalar:?}")));
590 }
591 Ok(None)
592}
593
594fn parse_null(scalar: &str) -> Option<()> {
595 match scalar {
596 "null" | "Null" | "NULL" | "~" => Some(()),
597 _ => None,
598 }
599}
600
601fn parse_bool(scalar: &str) -> Option<bool> {
602 match scalar {
603 "true" | "True" | "TRUE" => Some(true),
604 "false" | "False" | "FALSE" => Some(false),
605 _ => None,
606 }
607}
608
609fn parse_f64(scalar: &str) -> Option<f64> {
610 let unpositive = if let Some(unpositive) = scalar.strip_prefix('+') {
611 if unpositive.starts_with(['+', '-']) {
612 return None;
613 }
614 unpositive
615 } else {
616 scalar
617 };
618 if let ".inf" | ".Inf" | ".INF" = unpositive {
619 return Some(f64::INFINITY);
620 }
621 if let "-.inf" | "-.Inf" | "-.INF" = scalar {
622 return Some(f64::NEG_INFINITY);
623 }
624 if let ".nan" | ".NaN" | ".NAN" = scalar {
625 return Some(f64::NAN.copysign(1.0));
626 }
627 if let Ok(float) = unpositive.parse::<f64>() {
628 if float.is_finite() {
629 return Some(float);
630 }
631 }
632 None
633}
634
635pub(crate) fn node_kind(node: &Node) -> &'static str {
637 match node {
638 Node::Null => "null",
639 Node::Bool(_) => "a boolean",
640 Node::Int(_) | Node::UInt(_) => "an integer",
641 Node::Float(_) => "a float",
642 Node::String(_) => "a string",
643 Node::Expr(_) => "a !!js expression",
644 Node::Array(_) => "a sequence",
645 Node::Object(_) => "a mapping",
646 }
647}
648
649pub fn parse_document(source: &str) -> Result<Document> {
654 document_from_node(parse_node(source)?)
655}
656
657pub fn parse_entry_list(source: &str) -> Result<Vec<EntryOptions>> {
660 entry_list_from_node(parse_node(source)?)
661}
662
663pub fn document_from_node(node: Node) -> Result<Document> {
665 let Node::Object(map) = node else {
666 return Err(yaml_error(format!(
667 "expected a mapping with an `entries` list, found {}",
668 node_kind(&node)
669 )));
670 };
671 let mut document = Document::default();
672 for (key, value) in map {
673 if key == "entries" {
674 document.entries = match value {
677 Node::Null => Vec::new(),
678 other => {
679 entry_list_from_node(other).map_err(|error| prepend(error, "entries: "))?
680 }
681 };
682 } else {
683 document.extra.insert(key, value);
684 }
685 }
686 Ok(document)
687}
688
689pub fn entry_list_from_node(node: Node) -> Result<Vec<EntryOptions>> {
691 let Node::Array(items) = node else {
692 return Err(yaml_error(format!(
693 "expected a sequence of entries, found {}",
694 node_kind(&node)
695 )));
696 };
697 items
698 .into_iter()
699 .enumerate()
700 .map(|(index, node)| {
701 entry_from_node(node).map_err(|error| prepend(error, &format!("entry {}: ", index + 1)))
702 })
703 .collect()
704}
705
706pub fn patch_list_from_node(node: Node) -> Result<Vec<PatchOptions>> {
708 let Node::Array(items) = node else {
709 return Err(yaml_error(format!(
710 "expected a sequence of patch entries, found {}",
711 node_kind(&node)
712 )));
713 };
714 items
715 .into_iter()
716 .enumerate()
717 .map(|(index, node)| {
718 patch_from_node(node).map_err(|error| prepend(error, &format!("patch {}: ", index + 1)))
719 })
720 .collect()
721}
722
723fn prepend(error: IncludeError, context: &str) -> IncludeError {
724 let message = match &error {
725 IncludeError::Parse { source, .. } => source.to_string(),
726 other => other.to_string(),
727 };
728 yaml_error(format!("{context}{message}"))
729}
730
731fn entry_from_node(node: Node) -> Result<EntryOptions> {
733 let Node::Object(map) = node else {
734 return Err(yaml_error(format!(
735 "expected a mapping (an entry), found {}",
736 node_kind(&node)
737 )));
738 };
739 let mut entry = EntryOptions::default();
740 for (key, value) in map {
741 match key.as_str() {
742 "id" => entry.id = optional_string(value, "id")?,
743 "name" => {
744 entry.name = match value {
745 Node::Null => String::new(),
747 other => string_value(other, "name")?,
748 };
749 }
750 "disabled" => {
751 entry.disabled = match value {
752 Node::Bool(flag) => Disabled::Flag(flag),
753 Node::Expr(raw) => Disabled::Expr(raw),
756 other => {
757 return Err(yaml_error(format!(
758 "disabled: expected a boolean or a !!js expression, found {}",
759 node_kind(&other)
760 )));
761 }
762 };
763 }
764 "inject" => {
766 entry.inject = match value {
767 Node::Null => Vec::new(),
768 other => string_list(other, "inject")?,
769 };
770 }
771 "group" => {
772 entry.group = match value {
773 Node::Null => Vec::new(),
774 other => {
775 entry_list_from_node(other).map_err(|error| prepend(error, "group: "))?
776 }
777 };
778 }
779 "config" => entry.config = optional_config(value),
780 _ => {}
782 }
783 }
784 Ok(entry)
785}
786
787pub(crate) fn patch_from_node(node: Node) -> Result<PatchOptions> {
790 let Node::Object(map) = node else {
791 return Err(yaml_error(format!(
792 "expected a mapping (a loader patch entry), found {}",
793 node_kind(&node)
794 )));
795 };
796 let mut patch = PatchOptions::default();
797 for (key, value) in map {
798 match key.as_str() {
799 "id" => patch.id = optional_string(value, "id")?,
800 "insert" => {
801 patch.insert = match value {
802 Node::Null => None,
803 Node::Array(_) => Some(
804 entry_list_from_node(value).map_err(|error| prepend(error, "insert: "))?,
805 ),
806 other => {
807 return Err(yaml_error(format!(
808 "insert: expected a sequence of entries, found {}",
809 node_kind(&other)
810 )));
811 }
812 };
813 }
814 "name" => patch.name = optional_string(value, "name")?,
815 "config" => patch.config = optional_config(value),
816 "disabled" => {
817 patch.disabled = match value {
822 Node::Null => None,
823 Node::Bool(flag) => Some(flag),
824 Node::Expr(_) => {
825 return Err(yaml_error(
826 "disabled: !!js expressions are not supported in patches; put the \
827 expression on the entry itself",
828 ));
829 }
830 other => {
831 return Err(yaml_error(format!(
832 "disabled: expected a boolean, found {}",
833 node_kind(&other)
834 )));
835 }
836 };
837 }
838 "inject" => {
839 patch.inject = match value {
840 Node::Null => None,
841 Node::Array(_) => Some(string_list(value, "inject")?),
842 other => {
843 return Err(yaml_error(format!(
844 "inject: expected a list of strings, found {}",
845 node_kind(&other)
846 )));
847 }
848 };
849 }
850 other => {
851 patch.extra.insert(other.to_owned(), value);
852 }
853 }
854 }
855 Ok(patch)
856}
857
858fn optional_string(node: Node, field: &str) -> Result<Option<String>> {
859 match node {
860 Node::Null => Ok(None),
861 Node::String(value) => Ok(Some(value)),
862 other => Err(yaml_error(format!(
863 "{field}: expected a string, found {}",
864 node_kind(&other)
865 ))),
866 }
867}
868
869fn string_value(node: Node, field: &str) -> Result<String> {
870 match node {
871 Node::String(value) => Ok(value),
872 other => Err(yaml_error(format!(
873 "{field}: expected a string, found {}",
874 node_kind(&other)
875 ))),
876 }
877}
878
879fn string_list(node: Node, field: &str) -> Result<Vec<String>> {
880 let Node::Array(items) = node else {
881 return Err(yaml_error(format!(
882 "{field}: expected a list of strings, found {}",
883 node_kind(&node)
884 )));
885 };
886 items
887 .into_iter()
888 .enumerate()
889 .map(|(index, item)| {
890 string_value(item, &format!("{field}[{}]", index))
891 .map_err(|error| prepend(error, &format!("{field}: entry {}: ", index + 1)))
892 })
893 .collect()
894}
895
896fn optional_config(node: Node) -> Option<Node> {
898 match node {
899 Node::Null => None,
900 other => Some(other),
901 }
902}
903
904pub fn emit_document(document: &Document) -> String {
909 let mut map = NodeMap::new();
910 if !document.entries.is_empty() {
911 map.insert("entries".to_owned(), entries_to_node(&document.entries));
912 }
913 for (key, value) in &document.extra {
914 map.insert(key.clone(), value.clone());
915 }
916 let mut out = String::new();
917 if map.is_empty() {
918 out.push_str("{}\n");
919 } else {
920 emit_map(&map, 0, "", &mut out);
921 }
922 out
923}
924
925pub fn emit_entry_list(entries: &[EntryOptions]) -> String {
927 let mut out = String::new();
928 if entries.is_empty() {
929 out.push_str("[]\n");
930 } else {
931 emit_node(&entries_to_node(entries), 0, "", &mut out);
932 }
933 out
934}
935
936fn entries_to_node(entries: &[EntryOptions]) -> Node {
940 Node::Array(entries.iter().map(entry_to_node).collect())
941}
942
943fn entry_to_node(entry: &EntryOptions) -> Node {
944 let mut map = NodeMap::new();
945 if let Some(id) = &entry.id {
946 map.insert("id".to_owned(), Node::String(id.clone()));
947 }
948 map.insert("name".to_owned(), Node::String(entry.name.clone()));
949 match &entry.disabled {
950 Disabled::Flag(true) => {
953 map.insert("disabled".to_owned(), Node::Bool(true));
954 }
955 Disabled::Expr(raw) => {
956 map.insert("disabled".to_owned(), Node::Expr(raw.clone()));
957 }
958 Disabled::Flag(false) => {}
959 }
960 if !entry.inject.is_empty() {
961 map.insert(
962 "inject".to_owned(),
963 Node::Array(
964 entry
965 .inject
966 .iter()
967 .map(|name| Node::String(name.clone()))
968 .collect(),
969 ),
970 );
971 }
972 if !entry.group.is_empty() {
973 map.insert("group".to_owned(), entries_to_node(&entry.group));
974 }
975 if let Some(config) = &entry.config {
976 map.insert("config".to_owned(), config.clone());
977 }
978 Node::Object(map)
979}
980
981fn spaces(indent: usize) -> String {
982 " ".repeat(indent)
983}
984
985fn emit_node(node: &Node, indent: usize, first_lead: &str, out: &mut String) {
988 match node {
989 Node::Array(items) => emit_seq(items, indent, first_lead, out),
990 Node::Object(map) => emit_map(map, indent, first_lead, out),
991 scalar => {
992 out.push_str(first_lead);
993 out.push_str(&scalar_text(scalar));
994 out.push('\n');
995 }
996 }
997}
998
999fn emit_map(map: &NodeMap, indent: usize, first_lead: &str, out: &mut String) {
1000 let indented = spaces(indent);
1001 for (position, (key, value)) in map.iter().enumerate() {
1002 let lead: &str = if position == 0 { first_lead } else { &indented };
1003 let key_text = scalar_text(&Node::String(key.clone()));
1004 match value {
1005 Node::Array(items) if items.is_empty() => {
1006 out.push_str(&format!("{lead}{key_text}: []\n"));
1007 }
1008 Node::Object(inner) if inner.is_empty() => {
1009 out.push_str(&format!("{lead}{key_text}: {{}}\n"));
1010 }
1011 Node::Object(inner) => {
1012 out.push_str(&format!("{lead}{key_text}:\n"));
1013 emit_map(inner, indent + 2, &spaces(indent + 2), out);
1014 }
1015 Node::Array(items) => {
1018 out.push_str(&format!("{lead}{key_text}:\n"));
1019 emit_seq(items, indent, &spaces(indent), out);
1020 }
1021 scalar => {
1022 out.push_str(&format!("{lead}{key_text}: {}\n", scalar_text(scalar)));
1023 }
1024 }
1025 }
1026}
1027
1028fn emit_seq(items: &[Node], indent: usize, first_lead: &str, out: &mut String) {
1029 let indented = spaces(indent);
1030 for (position, item) in items.iter().enumerate() {
1031 let lead: &str = if position == 0 { first_lead } else { &indented };
1032 match item {
1033 Node::Object(map) if map.is_empty() => {
1034 out.push_str(&format!("{lead}- {{}}\n"));
1035 }
1036 Node::Object(map) => emit_map(map, indent + 2, &format!("{lead}- "), out),
1037 Node::Array(inner) if inner.is_empty() => {
1038 out.push_str(&format!("{lead}- []\n"));
1039 }
1040 Node::Array(inner) => {
1041 out.push_str(&format!("{lead}-\n"));
1042 emit_seq(inner, indent + 2, &spaces(indent + 2), out);
1043 }
1044 scalar => {
1045 out.push_str(&format!("{lead}- {}\n", scalar_text(scalar)));
1046 }
1047 }
1048 }
1049}
1050
1051fn scalar_text(node: &Node) -> String {
1055 match node {
1056 Node::Null => "null".to_owned(),
1057 Node::Bool(value) => value.to_string(),
1058 Node::Int(value) => value.to_string(),
1059 Node::UInt(value) => value.to_string(),
1060 Node::Float(value) => float_text(*value),
1061 Node::String(value) => quote_scalar(value),
1062 Node::Expr(value) => format!("!!js {}", quote_scalar(value)),
1063 Node::Array(_) | Node::Object(_) => unreachable!("scalars only"),
1064 }
1065}
1066
1067fn float_text(value: f64) -> String {
1070 if value.is_nan() {
1071 ".nan".to_owned()
1072 } else if value == f64::INFINITY {
1073 ".inf".to_owned()
1074 } else if value == f64::NEG_INFINITY {
1075 "-.inf".to_owned()
1076 } else {
1077 let mut buffer = ryu::Buffer::new();
1078 buffer.format_finite(value).to_owned()
1079 }
1080}
1081
1082enum Quote {
1084 Plain,
1085 Single,
1086 Double,
1087}
1088
1089fn classify(text: &str) -> Quote {
1090 if text.is_empty() {
1091 return Quote::Single;
1092 }
1093 if text.chars().any(|c| (c as u32) < 0x20 || c as u32 == 0x7f) {
1094 return Quote::Double;
1095 }
1096 let first = text.chars().next().expect("non-empty");
1097 if "#%,[]{}&*!|>'\"`@".contains(first) {
1098 return Quote::Single;
1099 }
1100 if (first == '-' || first == '?') && text.chars().nth(1).is_none_or(|next| next == ' ') {
1101 return Quote::Single;
1102 }
1103 if text == "---" || text == "..." {
1104 return Quote::Single;
1105 }
1106 if text.ends_with(':')
1107 || text.contains(": ")
1108 || text.contains(" #")
1109 || text.starts_with(' ')
1110 || text.ends_with(' ')
1111 {
1112 return Quote::Single;
1113 }
1114 if parse_null(text).is_some()
1117 || parse_bool(text).is_some()
1118 || parse_f64(text).is_some()
1119 || matches!(try_int(text), Ok(Some(_)))
1120 {
1121 return Quote::Single;
1122 }
1123 Quote::Plain
1124}
1125
1126fn quote_scalar(text: &str) -> String {
1127 match classify(text) {
1128 Quote::Plain => text.to_owned(),
1129 Quote::Single => format!("'{}'", text.replace('\'', "''")),
1130 Quote::Double => double_quote(text),
1131 }
1132}
1133
1134fn double_quote(text: &str) -> String {
1135 let mut out = String::with_capacity(text.len() + 2);
1136 out.push('"');
1137 for character in text.chars() {
1138 match character {
1139 '"' => out.push_str("\\\""),
1140 '\\' => out.push_str("\\\\"),
1141 '\n' => out.push_str("\\n"),
1142 '\r' => out.push_str("\\r"),
1143 '\t' => out.push_str("\\t"),
1144 '\0' => out.push_str("\\0"),
1145 other if (other as u32) < 0x20 || other as u32 == 0x7f => {
1146 out.push_str(&format!("\\x{:02x}", other as u32));
1147 }
1148 other => out.push(other),
1149 }
1150 }
1151 out.push('"');
1152 out
1153}
1154
1155#[cfg(test)]
1156mod tests {
1157 use super::*;
1158
1159 fn parity(source: &str) {
1162 let mine = parse_node(source).expect("dialect parse");
1163 let oracle: Node = serde_yaml_ng::from_str(source).expect("oracle parse");
1164 assert_eq!(
1165 format!("{mine:?}"),
1166 format!("{oracle:?}"),
1167 "dialect and serde disagree on {source:?}"
1168 );
1169 }
1170
1171 #[test]
1172 fn scalar_resolution_matches_the_serde_oracle() {
1173 for value in [
1174 "null",
1175 "Null",
1176 "NULL",
1177 "~",
1178 "true",
1179 "True",
1180 "TRUE",
1181 "false",
1182 "False",
1183 "FALSE",
1184 "0",
1185 "-0",
1186 "42",
1187 "+5",
1188 "-17",
1189 "9223372036854775807",
1190 "9223372036854775808",
1191 "18446744073709551615",
1192 "0x1A",
1193 "0o17",
1194 "0b101",
1195 "-0x10",
1196 "007",
1197 "1_000",
1198 "1.5",
1199 "-0.0",
1200 "1e300",
1201 ".inf",
1202 "-.inf",
1203 ".nan",
1204 "yes",
1205 "No",
1206 "text",
1207 "http://x",
1208 "a b",
1209 "${{ env.X }}",
1210 ] {
1211 parity(&format!("key: {value}\n"));
1212 parity(&format!("- {value}\n"));
1213 }
1214 for value in ["", "0", "true", "42", " x "] {
1215 parity(&format!("key: '{value}'\n"));
1216 parity(&format!("key: \"{value}\"\n"));
1217 }
1218 parity("key:\n");
1219 parity("key: |\n line1\n line2\n");
1220 parity("key: >\n folded text\n");
1221 parity("");
1222 parity("---\n");
1223 }
1224
1225 #[test]
1226 fn structural_shapes_match_the_serde_oracle() {
1227 parity("[]\n");
1228 parity("{}\n");
1229 parity("- 1\n- a\n- [1, 2]\n- {x: 1}\n");
1230 parity("a:\n b:\n c: 1\n");
1231 parity("a: &anchor 1\nb: *anchor\n");
1232 parity("base: &b\n x: 1\nover: *b\n");
1233 parity("list:\n- 1\n- 2\n");
1234 parity("- id: a\n name: n\n group:\n - id: c\n");
1235 parity("m:\n 1: value\n");
1237 }
1238
1239 #[test]
1240 fn core_tags_match_the_serde_oracle() {
1241 parity("a: !!str 5\n");
1242 parity("a: !!bool 'true'\n");
1243 parity("a: !!int 0x1f\n");
1244 parity("a: !!float 5\n");
1245 parity("a: !!null ~\n");
1246 parity("a: !!python/object 'x'\n");
1247 }
1248
1249 #[test]
1252 fn local_tags_fail_like_the_serde_path() {
1253 for source in ["a: !local 5\n", "a: !local 'x'\n"] {
1254 assert!(serde_yaml_ng::from_str::<Node>(source).is_err(), "{source}");
1255 let error = parse_node(source).unwrap_err().to_string();
1256 assert!(error.contains("local tags are not supported"), "{error}");
1257 }
1258 }
1259
1260 #[test]
1262 fn complex_mapping_keys_fail_like_the_serde_path() {
1263 let source = "? [a]\n: v\n";
1264 assert!(serde_yaml_ng::from_str::<Node>(source).is_err());
1265 let error = parse_node(source).unwrap_err().to_string();
1266 assert!(error.contains("mapping keys must be scalars"), "{error}");
1267 }
1268
1269 #[test]
1270 fn js_tag_becomes_an_expression_node() {
1271 let node = parse_node("- id: a\n config:\n model: !!js process.env.MODEL\n").unwrap();
1272 let entry = &node.as_array().unwrap()[0];
1273 let config = entry.as_object().unwrap()["config"].as_object().unwrap();
1274 assert_eq!(config["model"], Node::Expr("process.env.MODEL".to_owned()));
1275 let node = parse_node("k: !!js 'quoted expression'\n").unwrap();
1278 assert_eq!(
1279 node.as_object().unwrap()["k"],
1280 Node::Expr("quoted expression".to_owned())
1281 );
1282 }
1283
1284 #[test]
1285 fn integer_overflow_fails_like_the_serde_path() {
1286 let error = parse_node("a: 18446744073709551616\n")
1287 .unwrap_err()
1288 .to_string();
1289 assert!(error.contains("integer out of range"), "{error}");
1290 assert!(serde_yaml_ng::from_str::<Node>("a: 18446744073709551616\n").is_err());
1292 }
1293
1294 #[test]
1295 fn syntax_errors_carry_line_and_column() {
1296 let error = parse_node("a: [unclosed\n").unwrap_err().to_string();
1299 assert!(error.contains("line 2"), "{error}");
1300 let error = parse_node("valid: 1\nbroken: [x\n")
1301 .unwrap_err()
1302 .to_string();
1303 assert!(error.contains("line 3"), "{error}");
1304 }
1305
1306 #[test]
1307 fn multiple_documents_and_unknown_anchors_fail() {
1308 let error = parse_node("---\na: 1\n---\nb: 2\n")
1309 .unwrap_err()
1310 .to_string();
1311 assert!(error.contains("more than one document"), "{error}");
1312 let error = parse_node("a: *missing\n").unwrap_err().to_string();
1313 assert!(error.contains("unknown anchor"), "{error}");
1314 }
1315
1316 #[test]
1317 fn converters_reject_wrong_shapes_with_field_context() {
1318 let error = parse_document("- id: a\n").unwrap_err().to_string();
1319 assert!(
1320 error.contains("expected a mapping with an `entries` list"),
1321 "{error}"
1322 );
1323 let error = parse_document("entries: 5\n").unwrap_err().to_string();
1324 assert!(error.contains("entries:"), "{error}");
1325 let error = parse_entry_list("entries:\n - id: a\n")
1326 .unwrap_err()
1327 .to_string();
1328 assert!(error.contains("expected a sequence of entries"), "{error}");
1329 let error = parse_entry_list("- name: 5\n").unwrap_err().to_string();
1330 assert!(
1331 error.contains("entry 1: name: expected a string"),
1332 "{error}"
1333 );
1334 let error = parse_entry_list("- inject: [1]\n").unwrap_err().to_string();
1335 assert!(error.contains("inject"), "{error}");
1336 let error = parse_entry_list("- disabled: maybe\n")
1337 .unwrap_err()
1338 .to_string();
1339 assert!(
1340 error.contains("disabled: expected a boolean or a !!js expression"),
1341 "{error}"
1342 );
1343 let error = crate::yaml::patch_list_from_node(
1345 parse_node("- id: x\n disabled: !!js process.platform\n").unwrap(),
1346 )
1347 .unwrap_err()
1348 .to_string();
1349 assert!(
1350 error.contains("disabled: !!js expressions are not supported in patches"),
1351 "{error}"
1352 );
1353 }
1354
1355 #[test]
1359 fn disabled_expressions_round_trip() {
1360 let source = "- id: x\n name: n\n disabled: !!js process.platform === 'win32'\n";
1361 let entries = parse_entry_list(source).unwrap();
1362 assert_eq!(
1363 entries[0].disabled,
1364 Disabled::Expr("process.platform === 'win32'".to_owned())
1365 );
1366 let text = emit_entry_list(&entries);
1367 assert!(
1368 text.contains("disabled: !!js process.platform === 'win32'\n"),
1369 "{text}"
1370 );
1371 assert_eq!(parse_entry_list(&text).unwrap(), entries);
1372
1373 let entries = parse_entry_list("- name: n\n disabled: true\n").unwrap();
1374 assert_eq!(entries[0].disabled, Disabled::Flag(true));
1375 let text = emit_entry_list(&entries);
1376 assert!(text.contains("disabled: true\n"), "{text}");
1377 assert_eq!(parse_entry_list(&text).unwrap(), entries);
1378
1379 let entries = parse_entry_list("- name: n\n").unwrap();
1381 assert_eq!(entries[0].disabled, Disabled::Flag(false));
1382 let text = emit_entry_list(&entries);
1383 assert!(!text.contains("disabled"), "{text}");
1384 }
1385
1386 #[test]
1387 fn converters_keep_unknown_entry_keys_dropped_and_patch_extras() {
1388 let entries = parse_entry_list("- id: a\n name: n\n mystery: value\n").unwrap();
1389 assert_eq!(entries.len(), 1);
1390 assert_eq!(entries[0].name, "n");
1391
1392 let patches = crate::yaml::patch_list_from_node(
1393 parse_node("- id: a\n intercept: db\n group: []\n").unwrap(),
1394 )
1395 .unwrap();
1396 assert_eq!(patches[0].extra.len(), 2);
1397 assert!(patches[0].extra.contains_key("intercept"));
1398 assert!(patches[0].extra.contains_key("group"));
1399
1400 let patches = crate::yaml::patch_list_from_node(
1401 parse_node("- insert: [{id: x, name: n}]\n").unwrap(),
1402 )
1403 .unwrap();
1404 assert_eq!(
1405 patches[0].insert.as_ref().unwrap()[0].id.as_deref(),
1406 Some("x")
1407 );
1408 }
1409
1410 #[test]
1411 fn document_round_trips_with_extras_in_order() {
1412 let document = parse_document(
1413 "entries:\n - id: a\n name: n\n config:\n x: 1\nmeta: kept\n",
1414 )
1415 .unwrap();
1416 assert_eq!(document.entries.len(), 1);
1417 assert_eq!(document.extra.len(), 1);
1418 let text = emit_document(&document);
1419 assert_eq!(parse_document(&text).unwrap(), document, "{text}");
1420 assert!(text.contains("meta: kept"), "{text}");
1421 }
1422
1423 #[test]
1424 fn emitter_matches_the_previous_writer_layout() {
1425 let entries = parse_entry_list(
1426 "- id: w1\n name: worker\n config: 8080\n- id: g\n name: group\n group:\n - id: c1\n name: adapter-http\n config:\n host: localhost\n empty: ''\n list:\n - 1\n - 2.5\n",
1427 )
1428 .unwrap();
1429 let text = emit_entry_list(&entries);
1430 let expected = "\
1431- id: w1
1432 name: worker
1433 config: 8080
1434- id: g
1435 name: group
1436 group:
1437 - id: c1
1438 name: adapter-http
1439 config:
1440 host: localhost
1441 empty: ''
1442 list:
1443 - 1
1444 - 2.5
1445";
1446 assert_eq!(text, expected);
1447 assert_eq!(parse_entry_list(&text).unwrap(), entries);
1448 }
1449
1450 #[test]
1451 fn emitter_quotes_and_numbers_match_the_previous_writer() {
1452 for (text, expected) in [
1453 ("x: -foo\n", "x: -foo\n"),
1454 ("x: '#hash'\n", "x: '#hash'\n"),
1455 ("x: 'trailing:'\n", "x: 'trailing:'\n"),
1456 ("x: 'true'\n", "x: 'true'\n"),
1457 ("x: '0x1A'\n", "x: '0x1A'\n"),
1458 ("x: it's\n", "x: it's\n"),
1459 ("x: qu\"ote\n", "x: qu\"ote\n"),
1460 ("x: http://x\n", "x: http://x\n"),
1461 ("x: ${{ env.X }}\n", "x: ${{ env.X }}\n"),
1462 ("x: 0.5\n", "x: 0.5\n"),
1463 ("x: -0.0\n", "x: -0.0\n"),
1464 ("x: 1e300\n", "x: 1e300\n"),
1465 ("x: 8080\n", "x: 8080\n"),
1466 ] {
1467 let node = parse_node(text).unwrap();
1468 let emitted = emit_node_test(&node);
1469 assert_eq!(emitted, expected, "input {text:?}");
1470 assert_eq!(parse_node(&emitted).unwrap(), node, "round trip {text:?}");
1471 }
1472 assert_eq!(emit_document(&Document::default()), "{}\n");
1474 let node = parse_node("a: []\nb: {}\nc: \"tab\\there\"\n").unwrap();
1477 let emitted = emit_node_test(&node);
1478 assert_eq!(emitted, "a: []\nb: {}\nc: \"tab\\there\"\n");
1479 assert_eq!(parse_node(&emitted).unwrap(), node);
1480 }
1481
1482 fn emit_node_test(node: &Node) -> String {
1483 let mut out = String::new();
1484 emit_node(node, 0, "", &mut out);
1485 out
1486 }
1487
1488 #[test]
1489 fn expressions_emit_and_round_trip() {
1490 let entries = parse_entry_list(
1491 "- id: a\n name: n\n config:\n model: !!js process.env.MODEL || 'default'\n",
1492 )
1493 .unwrap();
1494 let text = emit_entry_list(&entries);
1495 assert!(
1496 text.contains("model: !!js process.env.MODEL || 'default'\n"),
1497 "{text}"
1498 );
1499 assert_eq!(parse_entry_list(&text).unwrap(), entries);
1500 }
1501
1502 #[test]
1506 fn bundle_shaped_fixture_round_trips() {
1507 let entries = parse_entry_list(
1508 "\
1509- id: base-sandbox
1510 name: '@dsh/base'
1511 inject: [database]
1512 disabled: !!js process.platform === 'darwin'
1513 config:
1514 level: !!js process.env.DSH_LOG_LEVEL || 'info'
1515 retries: 3
1516 nested:
1517 keep: true
1518",
1519 )
1520 .unwrap();
1521 assert_eq!(entries.len(), 1);
1522 assert_eq!(
1523 entries[0].disabled,
1524 Disabled::Expr("process.platform === 'darwin'".to_owned())
1525 );
1526 let text = emit_entry_list(&entries);
1527 assert_eq!(parse_entry_list(&text).unwrap(), entries, "{text}");
1528 assert!(
1529 text.contains("disabled: !!js process.platform === 'darwin'"),
1530 "{text}"
1531 );
1532
1533 let patches = crate::yaml::patch_list_from_node(
1534 parse_node(
1535 "\
1536- id: base-sandbox
1537 config:
1538 level: !!js process.env.DSH_LOG_LEVEL || 'info'
1539- insert:
1540 - id: web-app
1541 name: '@dsh/web-app'
1542 config:
1543 theme: dark
1544 gate: !!js process.platform === 'darwin'
1545",
1546 )
1547 .unwrap(),
1548 )
1549 .unwrap();
1550 assert_eq!(patches.len(), 2);
1551 assert_eq!(
1552 patches[1].insert.as_ref().unwrap()[0].id.as_deref(),
1553 Some("web-app")
1554 );
1555 }
1556
1557 #[test]
1558 fn anchors_resolve_into_shared_clones() {
1559 let node = parse_node("a: &x {v: 1}\nb: *x\n").unwrap();
1560 let map = node.as_object().unwrap();
1561 assert_eq!(map["a"], map["b"]);
1562 }
1563}