1use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
4use crate::source::{LineColumn, SourceId, SourceSpan, line_column};
5use std::collections::{BTreeMap, BTreeSet};
6use std::error::Error;
7use std::fmt;
8use std::sync::Arc;
9use yaml_edit::{
10 AnchorRegistry, AsYaml, Mapping, Parse, ParseErrorKind, Scalar, ScalarStyle, ScalarType, ScalarValue, YamlFile,
11 YamlNode,
12};
13
14pub const YAML_SYNTAX_ERROR: DiagnosticCode = DiagnosticCode::new("compose.yaml.syntax");
16
17pub const YAML_UNPARSED_INPUT: DiagnosticCode = DiagnosticCode::new("compose.yaml.unparsed-input");
19
20pub const YAML_UNCLOSED_FLOW_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.yaml.unclosed-flow-sequence");
22
23pub const YAML_UNCLOSED_FLOW_MAPPING: DiagnosticCode = DiagnosticCode::new("compose.yaml.unclosed-flow-mapping");
25
26pub const YAML_UNTERMINATED_STRING: DiagnosticCode = DiagnosticCode::new("compose.yaml.unterminated-string");
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31#[non_exhaustive]
32pub enum ScalarPresentation {
33 Plain,
35 SingleQuoted,
37 DoubleQuoted,
39 Literal,
41 Folded,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct SyntaxDocument {
52 source_id: SourceId,
53 source: Arc<str>,
54 parse: Parse<YamlFile>,
55}
56
57impl SyntaxDocument {
58 pub fn parse(source_id: SourceId, source: impl Into<Arc<str>>) -> Result<SyntaxParse, SyntaxParseError> {
69 let source = source.into();
70 if u32::try_from(source.len()).is_err() {
71 return Err(SyntaxParseError {
72 source_id,
73 byte_len: source.len(),
74 });
75 }
76
77 let (parser_source, block_scalars) = parser_compatible_source(&source);
78 let parse = YamlFile::parse(parser_source.as_deref().unwrap_or(&source));
79 let mut diagnostics: Vec<_> = parse
80 .positioned_errors()
81 .iter()
82 .map(|error| syntax_diagnostic(source_id, source.len(), error))
83 .collect();
84 for block in &block_scalars {
85 if !block.valid {
86 diagnostics.push(block_scalar_diagnostic(
87 source_id,
88 source.len(),
89 block.start,
90 YAML_SYNTAX_ERROR,
91 ));
92 } else if !block_scalar_is_complete(&parse, &source, block) {
93 diagnostics.push(block_scalar_diagnostic(
94 source_id,
95 source.len(),
96 block.start,
97 YAML_UNPARSED_INPUT,
98 ));
99 }
100 }
101 if diagnostics.is_empty()
102 && let Some(document_end) = unparsed_input_offset(&parse, &source)
103 {
104 diagnostics.push(unparsed_input_diagnostic(source_id, source.len(), document_end));
105 }
106
107 let document = Self {
108 source_id,
109 source,
110 parse,
111 };
112 for scalar in document.editable_value_scalars() {
113 if let Some(offset) = anchored_mapping_in_plain_scalar(&scalar.raw) {
114 diagnostics.push(unparsed_input_diagnostic(
115 source_id,
116 document.source.len(),
117 scalar.span.start() + offset,
118 ));
119 }
120 }
121 Ok(SyntaxParse { document, diagnostics })
122 }
123
124 #[must_use]
126 pub const fn source_id(&self) -> SourceId {
127 self.source_id
128 }
129
130 #[must_use]
132 pub fn source_text(&self) -> &str {
133 &self.source
134 }
135
136 #[must_use]
138 pub fn source_span(&self) -> SourceSpan {
139 SourceSpan::from_valid_offsets(self.source_id, 0, self.source.len())
140 }
141
142 #[must_use]
144 pub fn text(&self, span: SourceSpan) -> Option<&str> {
145 if span.source_id() != self.source_id || span.end() > self.source.len() {
146 return None;
147 }
148
149 self.source.get(span.range())
150 }
151
152 #[must_use]
154 pub fn line_column(&self, byte_offset: usize) -> Option<LineColumn> {
155 line_column(&self.source, byte_offset)
156 }
157
158 #[must_use]
160 pub fn document_count(&self) -> usize {
161 self.parse.tree().documents().count()
162 }
163
164 #[must_use]
166 pub fn comment_count(&self) -> usize {
167 self.parse.tree().comments().count()
168 }
169
170 #[must_use]
172 pub fn render_preserved(&self) -> String {
173 self.source.to_string()
174 }
175
176 pub(crate) fn yaml_file(&self) -> YamlFile {
177 self.parse.tree()
178 }
179
180 pub(crate) fn interpolatable_value_scalars(&self) -> Vec<ValueScalar> {
181 let mut values = Vec::new();
182 if let Some(document) = self.parse.tree().document() {
183 if let Some(mapping) = document.as_mapping() {
184 collect_value_scalars(self.source_id, &self.source, YamlNode::Mapping(mapping), &mut values);
185 } else if let Some(sequence) = document.as_sequence() {
186 collect_value_scalars(self.source_id, &self.source, YamlNode::Sequence(sequence), &mut values);
187 } else if let Some(scalar) = document.as_scalar() {
188 collect_value_scalars(self.source_id, &self.source, YamlNode::Scalar(scalar), &mut values);
189 }
190 }
191 values
192 }
193
194 pub(crate) fn editable_value_scalars(&self) -> Vec<EditableValueScalar> {
195 let mut values = Vec::new();
196 if let Some(document) = self.parse.tree().document() {
197 if let Some(mapping) = document.as_mapping() {
198 collect_editable_value_scalars(self.source_id, &self.source, YamlNode::Mapping(mapping), &mut values);
199 } else if let Some(sequence) = document.as_sequence() {
200 collect_editable_value_scalars(self.source_id, &self.source, YamlNode::Sequence(sequence), &mut values);
201 } else if let Some(scalar) = document.as_scalar() {
202 collect_editable_value_scalars(self.source_id, &self.source, YamlNode::Scalar(scalar), &mut values);
203 }
204 }
205 values
206 }
207
208 pub(crate) fn merge_root(&self) -> Option<MergeSyntaxValue> {
209 let document = self.parse.tree().document()?;
210 let root = if let Some(mapping) = document.as_mapping() {
211 YamlNode::Mapping(mapping)
212 } else if let Some(sequence) = document.as_sequence() {
213 YamlNode::Sequence(sequence)
214 } else {
215 YamlNode::Scalar(document.as_scalar()?)
216 };
217 let registry = AnchorRegistry::from_document(&document);
218 Some(extract_merge_value(
219 self.source_id,
220 &self.source,
221 root,
222 ®istry,
223 &mut Vec::new(),
224 ))
225 }
226}
227
228#[derive(Debug, Clone, PartialEq, Eq)]
229pub(crate) struct ValueScalar {
230 pub(crate) value: String,
231 pub(crate) span: SourceSpan,
232}
233
234#[derive(Debug, Clone, PartialEq, Eq)]
235pub(crate) struct EditableValueScalar {
236 pub(crate) raw: String,
237 pub(crate) span: SourceSpan,
238}
239
240#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241pub(crate) enum MergeScalarKind {
242 String,
243 Boolean,
244 Number,
245 Null,
246}
247
248#[derive(Debug, Clone, PartialEq, Eq)]
249pub(crate) struct MergeSyntaxScalar {
250 pub(crate) raw: String,
251 pub(crate) value: String,
252 pub(crate) kind: MergeScalarKind,
253 pub(crate) plain: bool,
254 pub(crate) presentation: ScalarPresentation,
255 pub(crate) strict_yaml_string: bool,
256 pub(crate) span: SourceSpan,
257}
258
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub(crate) struct MergeSyntaxEntry {
261 pub(crate) key: MergeSyntaxScalar,
262 pub(crate) value: MergeSyntaxValue,
263}
264
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub(crate) enum MergeSyntaxValue {
267 Empty(SourceSpan),
268 Scalar(MergeSyntaxScalar),
269 Mapping {
270 entries: Vec<MergeSyntaxEntry>,
271 span: SourceSpan,
272 },
273 Sequence {
274 values: Vec<MergeSyntaxValue>,
275 span: SourceSpan,
276 },
277 Alias {
278 name: String,
279 span: SourceSpan,
280 },
281 Tagged {
282 tag: String,
283 value: Box<MergeSyntaxValue>,
284 span: SourceSpan,
285 },
286}
287
288#[derive(Debug, Clone, PartialEq, Eq)]
290pub struct SyntaxParse {
291 document: SyntaxDocument,
292 diagnostics: Vec<Diagnostic>,
293}
294
295impl SyntaxParse {
296 #[must_use]
298 pub const fn document(&self) -> &SyntaxDocument {
299 &self.document
300 }
301
302 #[must_use]
304 pub fn diagnostics(&self) -> &[Diagnostic] {
305 &self.diagnostics
306 }
307
308 #[must_use]
310 pub fn is_valid(&self) -> bool {
311 !self
312 .diagnostics
313 .iter()
314 .any(|diagnostic| diagnostic.severity() == Severity::Error)
315 }
316
317 #[must_use]
319 pub fn into_parts(self) -> (SyntaxDocument, Vec<Diagnostic>) {
320 (self.document, self.diagnostics)
321 }
322}
323
324#[derive(Debug, Clone, Copy, PartialEq, Eq)]
326pub struct SyntaxParseError {
327 source_id: SourceId,
328 byte_len: usize,
329}
330
331impl SyntaxParseError {
332 #[must_use]
334 pub const fn source_id(self) -> SourceId {
335 self.source_id
336 }
337
338 #[must_use]
340 pub const fn byte_len(self) -> usize {
341 self.byte_len
342 }
343}
344
345impl fmt::Display for SyntaxParseError {
346 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
347 write!(
348 formatter,
349 "{} contains {} bytes, exceeding the YAML syntax tree limit",
350 self.source_id, self.byte_len
351 )
352 }
353}
354
355impl Error for SyntaxParseError {}
356
357fn syntax_diagnostic(source_id: SourceId, source_len: usize, error: &yaml_edit::PositionedParseError) -> Diagnostic {
358 let (code, message) = match error.kind {
359 ParseErrorKind::UnclosedFlowSequence => (YAML_UNCLOSED_FLOW_SEQUENCE, "flow sequence is missing a closing `]`"),
360 ParseErrorKind::UnclosedFlowMapping => (YAML_UNCLOSED_FLOW_MAPPING, "flow mapping is missing a closing `}`"),
361 ParseErrorKind::UnterminatedString => (YAML_UNTERMINATED_STRING, "quoted scalar is missing its closing quote"),
362 ParseErrorKind::Other => (YAML_SYNTAX_ERROR, "invalid YAML syntax"),
363 };
364 let start = (error.range.start as usize).min(source_len);
365 let end = (error.range.end as usize).clamp(start, source_len);
366 let span = SourceSpan::from_valid_offsets(source_id, start, end);
367
368 Diagnostic::new(code, Severity::Error, message).with_label(DiagnosticLabel::primary(span, "syntax error"))
369}
370
371fn unparsed_input_diagnostic(source_id: SourceId, source_len: usize, document_end: usize) -> Diagnostic {
372 let span = SourceSpan::from_valid_offsets(source_id, document_end.min(source_len), source_len);
373
374 Diagnostic::new(
375 YAML_UNPARSED_INPUT,
376 Severity::Error,
377 "YAML parser did not include the complete input in the document root",
378 )
379 .with_label(DiagnosticLabel::primary(span, "input omitted from YAML document"))
380 .with_note("the original source remains available, but typed processing must not continue silently")
381}
382
383fn unparsed_input_offset(parse: &Parse<YamlFile>, source: &str) -> Option<usize> {
384 let document_end = parse
385 .tree()
386 .document()
387 .and_then(|document| {
388 document
389 .as_node()
390 .map(|node| u32::from(node.text_range().end()) as usize)
391 })
392 .unwrap_or_default();
393 unparsed_input_offset_after_document_end(document_end, source)
394}
395
396fn unparsed_input_offset_after_document_end(document_end: usize, source: &str) -> Option<usize> {
397 source
398 .as_bytes()
399 .get(document_end..)
400 .is_some_and(|suffix| suffix.iter().any(|byte| !byte.is_ascii_whitespace()))
401 .then_some(document_end)
402}
403
404fn parser_compatible_source(source: &str) -> (Option<String>, Vec<BlockScalarBoundary>) {
409 let mut compatible = source.as_bytes().to_vec();
410 let mut changed = mask_blank_lines_after_mapping_keys(source, &mut compatible);
411 changed |= mask_non_colliding_anchor_hyphens(source, &mut compatible);
412 let mut offset = 0;
413 let mut blocks: Vec<BlockScalarBoundary> = Vec::new();
414 let mut state = ParserCompatibilityState::default();
415
416 for line in source.split_inclusive('\n') {
417 let content = line.trim_end_matches(['\r', '\n']);
418 let indent = content.bytes().take_while(|byte| *byte == b' ').count();
419 let blank = content[indent..].is_empty();
420
421 if let Some(block) = blocks.last_mut().filter(|block| block.end == offset)
422 && (blank || indent > block.parent_indent)
423 {
424 if blank && block.content_indent.is_none() {
425 block.leading_blank_indent = block.leading_blank_indent.max(indent);
426 } else if !blank {
427 let required = *block.content_indent.get_or_insert(indent);
428 block.valid &= indent >= required && block.leading_blank_indent <= required;
429 if block.explicit_indent && indent > required {
430 compatible[offset + required..offset + indent].fill(b'_');
433 changed = true;
434 }
435 }
436 for (index, byte) in content.bytes().enumerate() {
440 if matches!(byte, b'\'' | b'"') {
441 compatible[offset + index] = b'_';
442 changed = true;
443 }
444 }
445 block.end += line.len();
446 offset += line.len();
447 continue;
448 }
449
450 let (line_changed, starts_block_scalar) = mask_block_plain_commas(content, offset, &mut compatible, &mut state);
451 changed |= line_changed;
452 if let Some(index) = starts_block_scalar {
453 let parent_indent = block_parent_indent(&content[..index]);
454 let header = &content[index..];
455 let token = header.split_ascii_whitespace().next().unwrap_or_default();
456 let mut digit = None;
457 let mut chomp = None;
458 let mut valid = true;
459 for byte in token.bytes().skip(1) {
460 match byte {
461 b'1'..=b'9' if digit.is_none() => digit = Some(usize::from(byte - b'0')),
462 b'+' | b'-' if chomp.is_none() => chomp = Some(byte),
463 _ => valid = false,
464 }
465 }
466 valid &=
467 header[token.len()..].trim_start().is_empty() || header[token.len()..].trim_start().starts_with('#');
468 if digit.is_some() && valid {
469 compatible[offset + index + 1..offset + index + token.len()].fill(b' ');
472 if token.len() > 2 {
473 compatible[offset + index + 2] = b'#';
474 }
475 changed = true;
476 }
477 blocks.push(BlockScalarBoundary {
478 start: offset + index,
479 end: offset + line.len(),
480 parent_indent,
481 content_indent: digit.map(|digit| parent_indent + digit),
482 explicit_indent: digit.is_some(),
483 leading_blank_indent: 0,
484 valid,
485 });
486 }
487 offset += line.len();
488 }
489
490 if changed {
491 (String::from_utf8(compatible).ok(), blocks)
492 } else {
493 (None, blocks)
494 }
495}
496
497#[derive(Debug)]
498struct BlockScalarBoundary {
499 start: usize,
500 end: usize,
501 parent_indent: usize,
502 content_indent: Option<usize>,
503 explicit_indent: bool,
504 leading_blank_indent: usize,
505 valid: bool,
506}
507
508fn block_parent_indent(prefix: &str) -> usize {
509 let indent = prefix.bytes().take_while(|byte| *byte == b' ').count();
510 let bytes = prefix.as_bytes();
511 let mut cursor = indent;
512 let mut sequence_indent = None;
513 while bytes.get(cursor) == Some(&b'-') && bytes.get(cursor + 1).is_some_and(u8::is_ascii_whitespace) {
514 sequence_indent = Some(cursor);
515 cursor += 1;
516 cursor += bytes[cursor..]
517 .iter()
518 .take_while(|byte| byte.is_ascii_whitespace())
519 .count();
520 }
521 while matches!(bytes.get(cursor), Some(b'&' | b'!')) {
523 cursor += bytes[cursor..]
524 .iter()
525 .take_while(|byte| !byte.is_ascii_whitespace())
526 .count();
527 cursor += bytes[cursor..]
528 .iter()
529 .take_while(|byte| byte.is_ascii_whitespace())
530 .count();
531 }
532 sequence_indent.map_or(indent, |base| if cursor == prefix.len() { base } else { cursor })
533}
534
535fn block_scalar_is_complete(parse: &Parse<YamlFile>, source: &str, block: &BlockScalarBoundary) -> bool {
536 parse.tree().documents().any(|document| {
537 document.as_node().is_some_and(|root| {
538 root.descendants().any(|node| {
539 let Some(YamlNode::Scalar(scalar)) = YamlNode::from_syntax(node) else {
540 return false;
541 };
542 let range = scalar.byte_range();
543 range.start as usize == block.start
544 && range.end as usize <= block.end
545 && source[range.end as usize..block.end]
546 .bytes()
547 .all(|byte| byte.is_ascii_whitespace())
548 })
549 })
550 })
551}
552
553fn block_scalar_diagnostic(source_id: SourceId, source_len: usize, start: usize, code: DiagnosticCode) -> Diagnostic {
554 Diagnostic::new(
555 code,
556 Severity::Error,
557 "The YAML block scalar could not be interpreted safely.",
558 )
559 .with_label(DiagnosticLabel::primary(
560 SourceSpan::from_valid_offsets(source_id, start, source_len.min(start + 1)),
561 "Check the block scalar header and indentation.",
562 ))
563}
564
565fn mask_blank_lines_after_mapping_keys(source: &str, compatible: &mut [u8]) -> bool {
566 let bytes = source.as_bytes();
567 let mut line_starts = vec![0];
568 line_starts.extend(
569 bytes
570 .iter()
571 .enumerate()
572 .filter_map(|(index, byte)| (*byte == b'\n').then_some(index + 1)),
573 );
574 let mut changed = false;
575
576 for line_index in 0..line_starts.len().saturating_sub(2) {
577 let start = line_starts[line_index];
578 let end = line_starts.get(line_index + 1).copied().unwrap_or(bytes.len());
579 let content_end = line_content_end(bytes, start, end);
580 let content = &source[start..content_end];
581 let trimmed = content.trim();
582 if trimmed.starts_with('#') || !trimmed.ends_with(':') {
583 continue;
584 }
585
586 let mut next_line = line_index + 1;
587 while next_line < line_starts.len() {
588 let next_start = line_starts[next_line];
589 let next_end = line_starts.get(next_line + 1).copied().unwrap_or(bytes.len());
590 let next_content_end = line_content_end(bytes, next_start, next_end);
591 if !source[next_start..next_content_end].trim().is_empty() {
592 break;
593 }
594 next_line += 1;
595 }
596 if next_line == line_index + 1 || next_line >= line_starts.len() {
597 continue;
598 }
599
600 let key_indent = content.bytes().take_while(|byte| *byte == b' ').count();
601 let value_start = line_starts[next_line];
602 let value_indent = source[value_start..].bytes().take_while(|byte| *byte == b' ').count();
603 if value_indent <= key_indent {
604 continue;
605 }
606
607 let final_blank_start = line_starts[next_line - 1];
610 compatible[content_end..final_blank_start].fill(b' ');
611 changed = true;
612 }
613 changed
614}
615
616fn line_content_end(bytes: &[u8], start: usize, end: usize) -> usize {
617 let mut content_end = end;
618 if content_end > start && bytes[content_end - 1] == b'\n' {
619 content_end -= 1;
620 }
621 if content_end > start && bytes[content_end - 1] == b'\r' {
622 content_end -= 1;
623 }
624 content_end
625}
626
627fn mask_non_colliding_anchor_hyphens(source: &str, compatible: &mut [u8]) -> bool {
628 let bytes = source.as_bytes();
629 let mut occurrences = Vec::new();
630 let mut originals_by_normalized = BTreeMap::<Vec<u8>, BTreeSet<Vec<u8>>>::new();
631 let mut index = 0;
632
633 while index < bytes.len() {
634 if !matches!(bytes[index], b'&' | b'*') {
635 index += 1;
636 continue;
637 }
638 let line_start = bytes[..index]
639 .iter()
640 .rposition(|byte| *byte == b'\n')
641 .map_or(0, |position| position + 1);
642 let preceding = bytes[line_start..index]
643 .iter()
644 .rfind(|byte| !byte.is_ascii_whitespace())
645 .copied();
646 if preceding.is_some_and(|byte| !matches!(byte, b':' | b'-' | b'[' | b'{' | b',' | b'?')) {
647 index += 1;
648 continue;
649 }
650 let start = index + 1;
651 let mut end = start;
652 while bytes.get(end).is_some_and(|byte| !anchor_name_delimiter(*byte)) {
653 end += 1;
654 }
655 if start == end {
656 index += 1;
657 continue;
658 }
659
660 let original = bytes[start..end].to_vec();
661 let normalized = original
662 .iter()
663 .map(|byte| if *byte == b'-' { b'_' } else { *byte })
664 .collect::<Vec<_>>();
665 originals_by_normalized
666 .entry(normalized.clone())
667 .or_default()
668 .insert(original.clone());
669 occurrences.push((start, end, original, normalized));
670 index = end;
671 }
672
673 let mut changed = false;
674 for (start, end, original, normalized) in occurrences {
675 if !original.contains(&b'-')
676 || originals_by_normalized
677 .get(&normalized)
678 .is_none_or(|originals| originals.len() != 1)
679 {
680 continue;
681 }
682 for byte in &mut compatible[start..end] {
683 if *byte == b'-' {
684 *byte = b'_';
685 changed = true;
686 }
687 }
688 }
689 changed
690}
691
692const fn anchor_name_delimiter(byte: u8) -> bool {
693 byte.is_ascii_whitespace() || matches!(byte, b'[' | b']' | b'{' | b'}' | b',')
694}
695
696#[derive(Debug, Default)]
697struct ParserCompatibilityState {
698 flow_depth: u32,
699 quote: Option<u8>,
700 escaped: bool,
701}
702
703fn block_sequence_indicator(bytes: &[u8], index: usize) -> bool {
704 bytes[index] == b'-' && bytes.get(index + 1).is_none_or(u8::is_ascii_whitespace)
705}
706
707fn mask_block_plain_commas(
708 content: &str,
709 offset: usize,
710 compatible: &mut [u8],
711 state: &mut ParserCompatibilityState,
712) -> (bool, Option<usize>) {
713 let bytes = content.as_bytes();
714 let mut index = 0;
715 let mut first_token = true;
716 let mut plain_started = false;
717 let mut eligible_plain_value = false;
718 let mut interpolation_depth = 0u32;
719 let mut changed = false;
720
721 while index < bytes.len() {
722 let byte = bytes[index];
723
724 if let Some(delimiter) = state.quote {
725 if delimiter == b'"' && state.escaped {
726 state.escaped = false;
727 } else if delimiter == b'"' && byte == b'\\' {
728 state.escaped = true;
729 } else if byte == delimiter {
730 if delimiter == b'\'' && bytes.get(index + 1) == Some(&b'\'') {
731 index += 1;
732 } else {
733 state.quote = None;
734 }
735 }
736 index += 1;
737 continue;
738 }
739
740 if byte == b'#' && (index == 0 || bytes[index - 1].is_ascii_whitespace()) {
741 break;
742 }
743
744 if eligible_plain_value && !plain_started && matches!(byte, b'!' | b'&') {
745 index += 1;
746 while bytes.get(index).is_some_and(|byte| !byte.is_ascii_whitespace()) {
747 index += 1;
748 }
749 first_token = false;
750 continue;
751 }
752
753 if matches!(byte, b'\'' | b'"') && !plain_started {
754 state.quote = Some(byte);
755 first_token = false;
756 index += 1;
757 continue;
758 }
759
760 if state.flow_depth > 0 {
761 match byte {
762 b'[' | b'{' => state.flow_depth += 1,
763 b']' | b'}' => state.flow_depth -= 1,
764 _ => {}
765 }
766 index += 1;
767 continue;
768 }
769
770 let token_boundary = index == 0 || bytes[index - 1].is_ascii_whitespace();
771 if matches!(byte, b'[' | b'{') && (!plain_started || (eligible_plain_value && token_boundary)) {
772 state.flow_depth = 1;
773 first_token = false;
774 index += 1;
775 continue;
776 }
777
778 if byte.is_ascii_whitespace() {
779 index += 1;
780 continue;
781 }
782
783 if (first_token || (eligible_plain_value && !plain_started)) && block_sequence_indicator(bytes, index) {
784 eligible_plain_value = true;
785 plain_started = false;
786 first_token = false;
787 index += 1;
788 continue;
789 }
790
791 if byte == b':' && bytes.get(index + 1).is_none_or(u8::is_ascii_whitespace) {
792 eligible_plain_value = true;
793 plain_started = false;
794 first_token = false;
795 index += 1;
796 continue;
797 }
798
799 if eligible_plain_value && !plain_started && matches!(byte, b'|' | b'>') {
800 return (changed, Some(index));
801 }
802
803 if eligible_plain_value && !plain_started && byte == b'-' && bytes.get(index + 1) == Some(&b'-') {
807 compatible[offset + index] = b'_';
808 changed = true;
809 }
810
811 if eligible_plain_value && plain_started && byte == b',' {
812 compatible[offset + index] = b'_';
813 changed = true;
814 }
815
816 if eligible_plain_value && byte == b'$' && bytes.get(index + 1) == Some(&b'{') {
821 interpolation_depth = interpolation_depth.saturating_add(1);
822 } else if eligible_plain_value && plain_started && byte == b'}' && interpolation_depth > 0 {
823 interpolation_depth -= 1;
824 compatible[offset + index] = b'_';
825 changed = true;
826 }
827
828 plain_started = true;
829 first_token = false;
830 index += 1;
831 }
832
833 state.escaped = false;
834 (changed, None)
835}
836
837pub(crate) fn scalar_raw_from_source(source: &str, scalar: &Scalar) -> String {
838 let range = scalar.byte_range();
839 source
840 .get(range.start as usize..range.end as usize)
841 .map_or_else(|| scalar.value(), str::to_owned)
842}
843
844pub(crate) fn scalar_string_from_source(source: &str, scalar: &Scalar) -> String {
845 let authored = scalar_raw_from_source(source, scalar);
846 if authored.starts_with(['|', '>']) {
847 let line_start = source[..scalar.byte_range().start as usize]
848 .rfind('\n')
849 .map_or(0, |index| index + 1);
850 let indent = block_parent_indent(&source[line_start..scalar.byte_range().start as usize]);
851 decode_block_scalar(&authored, indent)
852 } else if authored == scalar.value() {
853 scalar.as_string()
854 } else {
855 authored
857 }
858}
859
860fn decode_block_scalar(authored: &str, parent_indent: usize) -> String {
864 let Some((header, body)) = authored.split_once('\n') else {
865 return String::new();
866 };
867 let token = header.split_ascii_whitespace().next().unwrap_or_default();
868 let explicit = token.bytes().skip(1).find(|byte| matches!(byte, b'1'..=b'9'));
869 let indent = explicit.map_or_else(
870 || {
871 body.split_inclusive('\n')
872 .find_map(|line| {
873 let content = line.trim_end_matches(['\r', '\n']);
874 let spaces = content.bytes().take_while(|byte| *byte == b' ').count();
875 (spaces < content.len()).then_some(spaces)
876 })
877 .unwrap_or_else(|| body.split_inclusive('\n').map(|line| {
880 line.bytes().take_while(|byte| *byte == b' ').count()
881 }).max().unwrap_or(parent_indent + 1))
882 },
883 |digit| parent_indent + usize::from(digit - b'0'),
884 );
885 let lines: Vec<_> = body
886 .split_inclusive('\n')
887 .map(|line| {
888 let has_break = line.ends_with('\n');
889 let content = line.strip_suffix('\n').unwrap_or(line);
890 let content = content.strip_suffix('\r').unwrap_or(content);
891 let spaces = content.bytes().take_while(|byte| *byte == b' ').count();
892 let text = &content[spaces.min(indent)..];
893 (text, has_break, spaces > indent || text.starts_with('\t'))
894 })
895 .collect();
896 let folded = token.starts_with('>');
897 let mut decoded = String::new();
898 for (index, &(text, has_break, more_indented)) in lines.iter().enumerate() {
899 decoded.push_str(text);
900 if !has_break {
901 continue;
902 }
903 if !folded || text.is_empty() || more_indented {
904 decoded.push('\n');
905 continue;
906 }
907 match lines.get(index + 1) {
908 Some((next, _, false)) if !next.is_empty() => decoded.push(' '),
909 Some((next, _, _))
910 if next.is_empty()
911 && lines[index + 1..]
912 .iter()
913 .find(|(text, _, _)| !text.is_empty())
914 .is_some_and(|(_, _, more)| !more) => {}
915 _ => decoded.push('\n'),
916 }
917 }
918 let has_final_break = decoded.ends_with('\n');
919 if !token.contains('+') {
920 decoded.truncate(decoded.trim_end_matches('\n').len());
921 if !token.contains('-') && has_final_break && !decoded.is_empty() {
922 decoded.push('\n');
923 }
924 }
925 decoded
926}
927
928fn collect_value_scalars(source_id: SourceId, source: &str, node: YamlNode, values: &mut Vec<ValueScalar>) {
929 match node {
930 YamlNode::Scalar(scalar) => collect_scalar(source_id, source, &scalar, values),
931 YamlNode::Mapping(mapping) => {
932 for value in mapping.entries().filter_map(|entry| entry.value_node()) {
933 collect_value_scalars(source_id, source, value, values);
934 }
935 }
936 YamlNode::Sequence(sequence) => {
937 for value in sequence.values() {
938 collect_value_scalars(source_id, source, value, values);
939 }
940 }
941 YamlNode::TaggedNode(tagged) => {
942 if let Some(node) = tagged
943 .as_node()
944 .and_then(|node| node.children().find_map(YamlNode::from_syntax))
945 {
946 collect_value_scalars(source_id, source, node, values);
947 }
948 }
949 YamlNode::Alias(_) => {}
950 }
951}
952
953fn collect_editable_value_scalars(
954 source_id: SourceId,
955 source: &str,
956 node: YamlNode,
957 values: &mut Vec<EditableValueScalar>,
958) {
959 match node {
960 YamlNode::Scalar(scalar) => {
961 values.push(EditableValueScalar {
962 raw: scalar_raw_from_source(source, &scalar),
963 span: position_span(source_id, scalar.byte_range()),
964 });
965 }
966 YamlNode::Mapping(mapping) => {
967 for value in mapping.entries().filter_map(|entry| entry.value_node()) {
968 collect_editable_value_scalars(source_id, source, value, values);
969 }
970 }
971 YamlNode::Sequence(sequence) => {
972 for value in sequence.values() {
973 collect_editable_value_scalars(source_id, source, value, values);
974 }
975 }
976 YamlNode::TaggedNode(tagged) => {
977 if let Some(node) = tagged
978 .as_node()
979 .and_then(|node| node.children().find_map(YamlNode::from_syntax))
980 {
981 collect_editable_value_scalars(source_id, source, node, values);
982 }
983 }
984 YamlNode::Alias(_) => {}
985 }
986}
987
988fn extract_merge_value(
989 source_id: SourceId,
990 source: &str,
991 node: YamlNode,
992 registry: &AnchorRegistry,
993 aliases: &mut Vec<String>,
994) -> MergeSyntaxValue {
995 match node {
996 YamlNode::Scalar(scalar) if scalar.byte_range().is_empty() => {
997 MergeSyntaxValue::Empty(position_span(source_id, scalar.byte_range()))
998 }
999 YamlNode::Scalar(scalar) => MergeSyntaxValue::Scalar(extract_merge_scalar(source_id, source, &scalar)),
1000 YamlNode::Mapping(mapping) => extract_merge_mapping(source_id, source, &mapping, registry, aliases),
1001 YamlNode::Sequence(sequence) => {
1002 let span = position_span(source_id, sequence.byte_range());
1003 let values = sequence
1004 .values()
1005 .map(|value| extract_merge_value(source_id, source, value, registry, aliases))
1006 .collect();
1007 MergeSyntaxValue::Sequence { values, span }
1008 }
1009 YamlNode::Alias(alias) => {
1010 let name = alias.name();
1011 let span = yaml_node_span(source_id, &YamlNode::Alias(alias.clone()));
1012 if aliases.contains(&name) || aliases.len() >= 64 {
1013 return MergeSyntaxValue::Alias {
1014 name: original_alias_name(source, span).unwrap_or(name),
1015 span,
1016 };
1017 }
1018 if let Some(target) = registry.resolve(&name).and_then(|node| {
1019 YamlNode::from_syntax(node.clone()).or_else(|| node.children().find_map(YamlNode::from_syntax))
1020 }) {
1021 aliases.push(name);
1022 let value = extract_merge_value(source_id, source, target, registry, aliases);
1023 let _ = aliases.pop();
1024 value
1025 } else {
1026 MergeSyntaxValue::Alias {
1027 name: original_alias_name(source, span).unwrap_or(name),
1028 span,
1029 }
1030 }
1031 }
1032 YamlNode::TaggedNode(tagged) => {
1033 let span = yaml_node_span(source_id, &YamlNode::TaggedNode(tagged.clone()));
1034 let tag = tagged.tag().unwrap_or_default();
1035 let mut value = tagged
1036 .as_node()
1037 .and_then(|node| node.children().find_map(YamlNode::from_syntax))
1038 .map_or_else(
1039 || MergeSyntaxValue::Empty(span),
1040 |value| extract_merge_value(source_id, source, value, registry, aliases),
1041 );
1042 if matches!(tag.as_str(), "!!timestamp" | "!!regex")
1043 && let MergeSyntaxValue::Scalar(scalar) = &mut value
1044 {
1045 scalar.strict_yaml_string = false;
1046 }
1047 MergeSyntaxValue::Tagged {
1048 tag,
1049 value: Box::new(value),
1050 span,
1051 }
1052 }
1053 }
1054}
1055
1056fn original_alias_name(source: &str, span: SourceSpan) -> Option<String> {
1057 source.get(span.range())?.strip_prefix('*').map(ToOwned::to_owned)
1058}
1059
1060fn extract_merge_mapping(
1061 source_id: SourceId,
1062 source: &str,
1063 mapping: &Mapping,
1064 registry: &AnchorRegistry,
1065 aliases: &mut Vec<String>,
1066) -> MergeSyntaxValue {
1067 let span = position_span(source_id, mapping.byte_range());
1068 let fields = raw_merge_fields(source_id, source, mapping);
1069 let direct = if is_flow_mapping(source, mapping) {
1070 fields
1071 } else {
1072 flatten_merge_fields(source_id, source, fields)
1073 };
1074 let mut entries = Vec::new();
1075 let mut inherited = Vec::new();
1076 for mut field in direct {
1077 let value = field.value.map_or_else(
1078 || {
1079 MergeSyntaxValue::Empty(SourceSpan::from_valid_offsets(
1080 source_id,
1081 field.key.span.end(),
1082 field.key.span.end(),
1083 ))
1084 },
1085 |value| extract_merge_value(source_id, source, value, registry, aliases),
1086 );
1087 if field.key.plain && field.key.value == "<<" {
1088 match merge_source_groups(value) {
1089 Ok(groups) => inherited.extend(groups),
1090 Err(value) => {
1091 field.key.strict_yaml_string = false;
1093 entries.push(MergeSyntaxEntry { key: field.key, value });
1094 }
1095 }
1096 } else {
1097 entries.push(MergeSyntaxEntry { key: field.key, value });
1098 }
1099 }
1100 let mut previous: BTreeSet<_> = entries
1103 .iter()
1104 .map(|entry| (entry.key.value.clone(), entry.key.strict_yaml_string))
1105 .collect();
1106 for group in inherited {
1107 let mut added = BTreeSet::new();
1108 entries.extend(group.into_iter().filter(|entry| {
1109 let key = (entry.key.value.clone(), entry.key.strict_yaml_string);
1110 if previous.contains(&key) {
1111 false
1112 } else {
1113 added.insert(key);
1114 true
1115 }
1116 }));
1117 previous.extend(added);
1118 }
1119 MergeSyntaxValue::Mapping { entries, span }
1120}
1121
1122fn merge_source_groups(value: MergeSyntaxValue) -> Result<Vec<Vec<MergeSyntaxEntry>>, MergeSyntaxValue> {
1123 match value {
1124 MergeSyntaxValue::Mapping { entries, .. } => Ok(vec![entries]),
1125 MergeSyntaxValue::Sequence { values, span } => {
1126 if values
1127 .iter()
1128 .any(|value| !matches!(value, MergeSyntaxValue::Mapping { .. }))
1129 {
1130 return Err(MergeSyntaxValue::Sequence { values, span });
1131 }
1132 Ok(values
1133 .into_iter()
1134 .filter_map(|value| match value {
1135 MergeSyntaxValue::Mapping { entries, .. } => Some(entries),
1136 _ => None,
1137 })
1138 .collect())
1139 }
1140 value => Err(value),
1141 }
1142}
1143
1144#[derive(Debug)]
1145struct RawMergeField {
1146 key: MergeSyntaxScalar,
1147 value: Option<YamlNode>,
1148}
1149
1150fn raw_merge_fields(source_id: SourceId, source: &str, mapping: &Mapping) -> Vec<RawMergeField> {
1151 let node = YamlNode::Mapping(mapping.clone());
1152 let Some(syntax) = node.as_node() else {
1153 return Vec::new();
1154 };
1155 mapping
1158 .entries()
1159 .zip(syntax.children())
1160 .filter_map(|(entry, authored)| {
1161 let wrapper = authored.children().next()?;
1162 let range = wrapper.text_range();
1163 let span = SourceSpan::from_valid_offsets(
1164 source_id,
1165 u32::from(authored.text_range().start()) as usize,
1166 u32::from(range.end()) as usize,
1167 );
1168 let key = if source.get(span.range()).is_some_and(|raw| raw.starts_with(['!', '&'])) {
1169 unsupported_merge_key(source, span)
1171 } else {
1172 entry.key_node().map_or_else(
1173 || unsupported_merge_key(source, span),
1174 |node| extract_merge_key(source_id, source, &node),
1175 )
1176 };
1177 Some(RawMergeField {
1178 key,
1179 value: entry.value_node(),
1180 })
1181 })
1182 .collect()
1183}
1184
1185fn anchored_mapping_in_plain_scalar(raw: &str) -> Option<usize> {
1188 if raw.starts_with(['\'', '"', '|', '>']) {
1189 return None;
1190 }
1191 let mut offset = 0;
1192 for line in raw.split_inclusive('\n') {
1193 let content = line.trim_start_matches([' ', '\t']);
1194 if content.starts_with('&')
1195 && (content.contains(": ") || content.contains(":\t") || content.trim_end().ends_with(':'))
1196 {
1197 return Some(offset + line.len() - content.len());
1198 }
1199 offset += line.len();
1200 }
1201 None
1202}
1203
1204fn extract_merge_key(source_id: SourceId, source: &str, node: &YamlNode) -> MergeSyntaxScalar {
1205 node.as_scalar().map_or_else(
1206 || unsupported_merge_key(source, yaml_node_span(source_id, node)),
1207 |scalar| extract_merge_scalar(source_id, source, scalar),
1208 )
1209}
1210
1211fn unsupported_merge_key(source: &str, span: SourceSpan) -> MergeSyntaxScalar {
1212 let raw = source.get(span.range()).unwrap_or_default().to_owned();
1213 MergeSyntaxScalar {
1214 value: raw.clone(),
1215 raw,
1216 kind: MergeScalarKind::String,
1217 plain: false,
1218 presentation: ScalarPresentation::Plain,
1219 strict_yaml_string: false,
1220 span,
1221 }
1222}
1223
1224fn flatten_merge_fields(source_id: SourceId, source: &str, fields: Vec<RawMergeField>) -> Vec<RawMergeField> {
1225 let Some(target_column) = fields
1226 .first()
1227 .map(|field| source_column(source, field.key.span.start()))
1228 else {
1229 return fields;
1230 };
1231 recover_merge_fields(source_id, source, fields, target_column)
1232}
1233
1234fn recover_merge_fields(
1235 source_id: SourceId,
1236 source: &str,
1237 fields: Vec<RawMergeField>,
1238 target_column: usize,
1239) -> Vec<RawMergeField> {
1240 let mut flattened = Vec::new();
1241 for mut field in fields {
1242 let field_column = source_column(source, field.key.span.start());
1243 let nested_mapping = field.value.as_ref().and_then(YamlNode::as_mapping).cloned();
1244 let continuation = nested_mapping.as_ref().is_some_and(|mapping| {
1245 !is_flow_mapping(source, mapping)
1246 && mapping
1247 .entries()
1248 .find_map(|entry| entry.key_node()?.as_scalar().map(Scalar::byte_range))
1249 .is_some_and(|position| source_column(source, position.start as usize) <= field_column)
1250 });
1251 if continuation {
1252 field.value = None;
1253 }
1254 if field_column == target_column {
1255 flattened.push(field);
1256 }
1257 if let Some(mapping) = nested_mapping.filter(|mapping| !is_flow_mapping(source, mapping)) {
1258 let nested = raw_merge_fields(source_id, source, &mapping);
1259 flattened.extend(recover_merge_fields(source_id, source, nested, target_column));
1260 }
1261 }
1262 flattened
1263}
1264
1265fn is_flow_mapping(source: &str, mapping: &Mapping) -> bool {
1266 let position = mapping.byte_range();
1267 source
1268 .get(position.start as usize..position.end as usize)
1269 .is_some_and(|text| text.trim_start().starts_with('{'))
1270}
1271
1272fn source_column(source: &str, offset: usize) -> usize {
1273 let prefix = &source[..offset.min(source.len())];
1274 let line_start = prefix.rfind('\n').map_or(0, |index| index + 1);
1275 source[line_start..offset.min(source.len())].chars().count()
1276}
1277
1278fn extract_merge_scalar(source_id: SourceId, source: &str, scalar: &Scalar) -> MergeSyntaxScalar {
1279 let scalar_type = ScalarValue::from_scalar(scalar).scalar_type();
1280 let kind = match scalar_type {
1281 ScalarType::Boolean => MergeScalarKind::Boolean,
1282 ScalarType::Integer | ScalarType::Float => MergeScalarKind::Number,
1283 ScalarType::Null => MergeScalarKind::Null,
1284 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => MergeScalarKind::String,
1285 };
1286 MergeSyntaxScalar {
1287 presentation: match scalar_raw_from_source(source, scalar).chars().next() {
1288 Some('|') => ScalarPresentation::Literal,
1289 Some('>') => ScalarPresentation::Folded,
1290 Some('\'') => ScalarPresentation::SingleQuoted,
1291 Some('"') => ScalarPresentation::DoubleQuoted,
1292 _ => ScalarPresentation::Plain,
1293 },
1294 raw: scalar_raw_from_source(source, scalar),
1295 value: scalar_string_from_source(source, scalar),
1296 kind,
1297 plain: ScalarValue::from_scalar(scalar).style() == ScalarStyle::Plain
1298 && !scalar_uses_block_style(source, scalar),
1299 strict_yaml_string: scalar_type == ScalarType::String,
1300 span: position_span(source_id, scalar.byte_range()),
1301 }
1302}
1303
1304fn scalar_uses_block_style(source: &str, scalar: &Scalar) -> bool {
1305 let start = scalar.byte_range().start as usize;
1306 source[start..].trim_start().starts_with(['|', '>'])
1307 || source[..start]
1308 .lines()
1309 .rev()
1310 .find(|line| !line.trim().is_empty())
1311 .is_some_and(|header| header.contains(": |") || header.contains(": >"))
1312}
1313
1314fn yaml_node_span(source_id: SourceId, node: &YamlNode) -> SourceSpan {
1315 let Some(syntax) = node.as_node() else {
1316 return SourceSpan::from_valid_offsets(source_id, 0, 0);
1317 };
1318 let range = syntax.text_range();
1319 SourceSpan::from_valid_offsets(
1320 source_id,
1321 u32::from(range.start()) as usize,
1322 u32::from(range.end()) as usize,
1323 )
1324}
1325
1326fn position_span(source_id: SourceId, position: yaml_edit::TextPosition) -> SourceSpan {
1327 SourceSpan::from_valid_offsets(source_id, position.start as usize, position.end as usize)
1328}
1329
1330fn collect_scalar(source_id: SourceId, source: &str, scalar: &Scalar, values: &mut Vec<ValueScalar>) {
1331 let raw = scalar_raw_from_source(source, scalar);
1332 let eligible_style = !raw.starts_with('\'') && !raw.starts_with('|') && !raw.starts_with('>');
1333 if !eligible_style || !raw.contains('$') {
1334 return;
1335 }
1336 let position = scalar.byte_range();
1337 values.push(ValueScalar {
1338 value: scalar_string_from_source(source, scalar),
1339 span: SourceSpan::from_valid_offsets(source_id, position.start as usize, position.end as usize),
1340 });
1341}
1342
1343#[cfg(test)]
1344mod tests {
1345 use super::{
1346 MergeSyntaxScalar, MergeSyntaxValue, SyntaxDocument, block_scalar_is_complete, parser_compatible_source,
1347 unparsed_input_offset_after_document_end,
1348 };
1349 use crate::source::SourceId;
1350
1351 fn assert_send_and_sync<T: Send + Sync>() {}
1352
1353 #[test]
1354 fn syntax_documents_are_send_and_sync() {
1355 assert_send_and_sync::<SyntaxDocument>();
1356 }
1357
1358 #[test]
1359 fn parsing_never_reads_the_process_environment() -> Result<(), Box<dyn std::error::Error>> {
1360 let source = "services:\n app:\n image: ${COMPOSE_LENS_SECRET}\n";
1361 let parsed = SyntaxDocument::parse(SourceId::new(1), source)?;
1362
1363 assert_eq!(parsed.document().render_preserved(), source);
1364 assert!(parsed.is_valid());
1365 Ok(())
1366 }
1367
1368 #[test]
1369 fn complete_root_guard_rejects_non_whitespace_after_the_backend_root() {
1370 let source = "services:\n app:\n image: example.invalid/app\n";
1371 let document_end = "services:".len();
1372
1373 assert_eq!(
1374 unparsed_input_offset_after_document_end(document_end, source),
1375 Some(document_end)
1376 );
1377 assert_eq!(unparsed_input_offset_after_document_end(source.len(), source), None);
1378 }
1379
1380 #[test]
1381 fn block_boundary_guard_rejects_backend_scalars_crossing_siblings() {
1382 let source = "services:\n app:\n command: |2\n body\n sibling:\n image: later\n";
1383 let (_, blocks) = parser_compatible_source(source);
1384 let raw = yaml_edit::YamlFile::parse(source);
1385 assert_eq!(blocks.len(), 1);
1386 assert!(!block_scalar_is_complete(&raw, source, &blocks[0]));
1387 }
1388
1389 #[test]
1390 fn comma_bearing_short_volumes_are_complete_and_preserved() -> Result<(), Box<dyn std::error::Error>> {
1391 let source = "services:\n app:\n volumes:\n - ./data:/data:Z,ro\n later:\n image: later\n";
1392
1393 let parsed = SyntaxDocument::parse(SourceId::new(2), source)?;
1394
1395 assert!(parsed.is_valid());
1396 assert_eq!(parsed.document().render_preserved(), source);
1397 Ok(())
1398 }
1399
1400 #[test]
1401 fn anchor_compatibility_does_not_merge_colliding_names() {
1402 let source = "first: &shared-name one\nsecond: &shared_name two\n";
1403
1404 assert_eq!(parser_compatible_source(source).0, None);
1405 }
1406
1407 #[test]
1408 fn anchor_compatibility_ignores_scalar_and_comment_content() {
1409 let source = "quoted: \"*not-an-alias\"\nplain: echo ¬-an-anchor\n# *also-not-an-alias\n";
1410
1411 assert_eq!(parser_compatible_source(source).0, None);
1412 }
1413
1414 #[test]
1415 fn merge_scalars_retain_strict_yaml_string_identity() -> Result<(), Box<dyn std::error::Error>> {
1416 let parsed = SyntaxDocument::parse(
1417 SourceId::new(2),
1418 "plain: gpu\ntimestamp: !!timestamp 2023-12-25\nregex: !!regex 'gpu.*'\nquoted-timestamp: \"2023-12-25\"\nquoted-regex: \"gpu.*\"\n",
1419 )?;
1420 let root = parsed.document().merge_root().ok_or("merge root")?;
1421 let scalar = |name| -> Option<&MergeSyntaxScalar> {
1422 let MergeSyntaxValue::Mapping { entries, .. } = &root else {
1423 return None;
1424 };
1425 let value = &entries.iter().find(|entry| entry.key.value == name)?.value;
1426 match value {
1427 MergeSyntaxValue::Scalar(scalar) => Some(scalar),
1428 MergeSyntaxValue::Tagged { value, .. } => match value.as_ref() {
1429 MergeSyntaxValue::Scalar(scalar) => Some(scalar),
1430 _ => None,
1431 },
1432 _ => None,
1433 }
1434 };
1435 assert!(scalar("plain").is_some_and(|value| value.strict_yaml_string));
1436 for name in ["timestamp", "regex"] {
1437 assert!(
1438 scalar(name).is_some_and(|value| !value.strict_yaml_string),
1439 "{name}: {:?}",
1440 scalar(name)
1441 );
1442 }
1443 for name in ["quoted-timestamp", "quoted-regex"] {
1444 assert!(scalar(name).is_some_and(|value| value.strict_yaml_string));
1445 }
1446 Ok(())
1447 }
1448}