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, MappingMergedExt, Parse, ParseErrorKind, Scalar, ScalarStyle, ScalarType,
11 ScalarValue, YamlFile, 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, PartialEq, Eq)]
35pub struct SyntaxDocument {
36 source_id: SourceId,
37 source: Arc<str>,
38 parse: Parse<YamlFile>,
39}
40
41impl SyntaxDocument {
42 pub fn parse(source_id: SourceId, source: impl Into<Arc<str>>) -> Result<SyntaxParse, SyntaxParseError> {
53 let source = source.into();
54 if u32::try_from(source.len()).is_err() {
55 return Err(SyntaxParseError {
56 source_id,
57 byte_len: source.len(),
58 });
59 }
60
61 let parser_source = parser_compatible_source(&source);
62 let parse = YamlFile::parse(parser_source.as_deref().unwrap_or(&source));
63 let mut diagnostics: Vec<_> = parse
64 .positioned_errors()
65 .iter()
66 .map(|error| syntax_diagnostic(source_id, source.len(), error))
67 .collect();
68 if diagnostics.is_empty() {
69 if let Some(document_end) = unparsed_input_offset(&parse, &source) {
70 diagnostics.push(unparsed_input_diagnostic(source_id, source.len(), document_end));
71 }
72 }
73
74 Ok(SyntaxParse {
75 document: Self {
76 source_id,
77 source,
78 parse,
79 },
80 diagnostics,
81 })
82 }
83
84 #[must_use]
86 pub const fn source_id(&self) -> SourceId {
87 self.source_id
88 }
89
90 #[must_use]
92 pub fn source_text(&self) -> &str {
93 &self.source
94 }
95
96 #[must_use]
98 pub fn source_span(&self) -> SourceSpan {
99 SourceSpan::from_valid_offsets(self.source_id, 0, self.source.len())
100 }
101
102 #[must_use]
104 pub fn text(&self, span: SourceSpan) -> Option<&str> {
105 if span.source_id() != self.source_id || span.end() > self.source.len() {
106 return None;
107 }
108
109 self.source.get(span.range())
110 }
111
112 #[must_use]
114 pub fn line_column(&self, byte_offset: usize) -> Option<LineColumn> {
115 line_column(&self.source, byte_offset)
116 }
117
118 #[must_use]
120 pub fn document_count(&self) -> usize {
121 self.parse.tree().documents().count()
122 }
123
124 #[must_use]
126 pub fn comment_count(&self) -> usize {
127 self.parse.tree().comments().count()
128 }
129
130 #[must_use]
132 pub fn render_preserved(&self) -> String {
133 self.source.to_string()
134 }
135
136 pub(crate) fn yaml_file(&self) -> YamlFile {
137 self.parse.tree()
138 }
139
140 pub(crate) fn interpolatable_value_scalars(&self) -> Vec<ValueScalar> {
141 let mut values = Vec::new();
142 if let Some(document) = self.parse.tree().document() {
143 if let Some(mapping) = document.as_mapping() {
144 collect_value_scalars(self.source_id, &self.source, YamlNode::Mapping(mapping), &mut values);
145 } else if let Some(sequence) = document.as_sequence() {
146 collect_value_scalars(self.source_id, &self.source, YamlNode::Sequence(sequence), &mut values);
147 } else if let Some(scalar) = document.as_scalar() {
148 collect_value_scalars(self.source_id, &self.source, YamlNode::Scalar(scalar), &mut values);
149 }
150 }
151 values
152 }
153
154 pub(crate) fn editable_value_scalars(&self) -> Vec<EditableValueScalar> {
155 let mut values = Vec::new();
156 if let Some(document) = self.parse.tree().document() {
157 if let Some(mapping) = document.as_mapping() {
158 collect_editable_value_scalars(self.source_id, &self.source, YamlNode::Mapping(mapping), &mut values);
159 } else if let Some(sequence) = document.as_sequence() {
160 collect_editable_value_scalars(self.source_id, &self.source, YamlNode::Sequence(sequence), &mut values);
161 } else if let Some(scalar) = document.as_scalar() {
162 collect_editable_value_scalars(self.source_id, &self.source, YamlNode::Scalar(scalar), &mut values);
163 }
164 }
165 values
166 }
167
168 pub(crate) fn merge_root(&self) -> Option<MergeSyntaxValue> {
169 let document = self.parse.tree().document()?;
170 let root = if let Some(mapping) = document.as_mapping() {
171 YamlNode::Mapping(mapping)
172 } else if let Some(sequence) = document.as_sequence() {
173 YamlNode::Sequence(sequence)
174 } else {
175 YamlNode::Scalar(document.as_scalar()?)
176 };
177 let registry = AnchorRegistry::from_document(&document);
178 Some(extract_merge_value(
179 self.source_id,
180 &self.source,
181 root,
182 ®istry,
183 &mut Vec::new(),
184 ))
185 }
186}
187
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub(crate) struct ValueScalar {
190 pub(crate) value: String,
191 pub(crate) span: SourceSpan,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub(crate) struct EditableValueScalar {
196 pub(crate) raw: String,
197 pub(crate) span: SourceSpan,
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201pub(crate) enum MergeScalarKind {
202 String,
203 Boolean,
204 Number,
205 Null,
206}
207
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub(crate) struct MergeSyntaxScalar {
210 pub(crate) raw: String,
211 pub(crate) value: String,
212 pub(crate) kind: MergeScalarKind,
213 pub(crate) plain: bool,
214 pub(crate) strict_yaml_string: bool,
215 pub(crate) span: SourceSpan,
216}
217
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub(crate) struct MergeSyntaxEntry {
220 pub(crate) key: MergeSyntaxScalar,
221 pub(crate) value: MergeSyntaxValue,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub(crate) enum MergeSyntaxValue {
226 Empty(SourceSpan),
227 Scalar(MergeSyntaxScalar),
228 Mapping {
229 entries: Vec<MergeSyntaxEntry>,
230 span: SourceSpan,
231 },
232 Sequence {
233 values: Vec<MergeSyntaxValue>,
234 span: SourceSpan,
235 },
236 Alias {
237 name: String,
238 span: SourceSpan,
239 },
240 Tagged {
241 tag: String,
242 value: Box<MergeSyntaxValue>,
243 span: SourceSpan,
244 },
245}
246
247#[derive(Debug, Clone, PartialEq, Eq)]
249pub struct SyntaxParse {
250 document: SyntaxDocument,
251 diagnostics: Vec<Diagnostic>,
252}
253
254impl SyntaxParse {
255 #[must_use]
257 pub const fn document(&self) -> &SyntaxDocument {
258 &self.document
259 }
260
261 #[must_use]
263 pub fn diagnostics(&self) -> &[Diagnostic] {
264 &self.diagnostics
265 }
266
267 #[must_use]
269 pub fn is_valid(&self) -> bool {
270 !self
271 .diagnostics
272 .iter()
273 .any(|diagnostic| diagnostic.severity() == Severity::Error)
274 }
275
276 #[must_use]
278 pub fn into_parts(self) -> (SyntaxDocument, Vec<Diagnostic>) {
279 (self.document, self.diagnostics)
280 }
281}
282
283#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285pub struct SyntaxParseError {
286 source_id: SourceId,
287 byte_len: usize,
288}
289
290impl SyntaxParseError {
291 #[must_use]
293 pub const fn source_id(self) -> SourceId {
294 self.source_id
295 }
296
297 #[must_use]
299 pub const fn byte_len(self) -> usize {
300 self.byte_len
301 }
302}
303
304impl fmt::Display for SyntaxParseError {
305 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
306 write!(
307 formatter,
308 "{} contains {} bytes, exceeding the YAML syntax tree limit",
309 self.source_id, self.byte_len
310 )
311 }
312}
313
314impl Error for SyntaxParseError {}
315
316fn syntax_diagnostic(source_id: SourceId, source_len: usize, error: &yaml_edit::PositionedParseError) -> Diagnostic {
317 let (code, message) = match error.kind {
318 ParseErrorKind::UnclosedFlowSequence => (YAML_UNCLOSED_FLOW_SEQUENCE, "flow sequence is missing a closing `]`"),
319 ParseErrorKind::UnclosedFlowMapping => (YAML_UNCLOSED_FLOW_MAPPING, "flow mapping is missing a closing `}`"),
320 ParseErrorKind::UnterminatedString => (YAML_UNTERMINATED_STRING, "quoted scalar is missing its closing quote"),
321 ParseErrorKind::Other => (YAML_SYNTAX_ERROR, "invalid YAML syntax"),
322 };
323 let start = (error.range.start as usize).min(source_len);
324 let end = (error.range.end as usize).clamp(start, source_len);
325 let span = SourceSpan::from_valid_offsets(source_id, start, end);
326
327 Diagnostic::new(code, Severity::Error, message).with_label(DiagnosticLabel::primary(span, "syntax error"))
328}
329
330fn unparsed_input_diagnostic(source_id: SourceId, source_len: usize, document_end: usize) -> Diagnostic {
331 let span = SourceSpan::from_valid_offsets(source_id, document_end.min(source_len), source_len);
332
333 Diagnostic::new(
334 YAML_UNPARSED_INPUT,
335 Severity::Error,
336 "YAML parser did not include the complete input in the document root",
337 )
338 .with_label(DiagnosticLabel::primary(span, "input omitted from YAML document"))
339 .with_note("the original source remains available, but typed processing must not continue silently")
340}
341
342fn unparsed_input_offset(parse: &Parse<YamlFile>, source: &str) -> Option<usize> {
343 let document_end = parse
344 .tree()
345 .document()
346 .and_then(|document| {
347 document
348 .as_node()
349 .map(|node| u32::from(node.text_range().end()) as usize)
350 })
351 .unwrap_or_default();
352 unparsed_input_offset_after_document_end(document_end, source)
353}
354
355fn unparsed_input_offset_after_document_end(document_end: usize, source: &str) -> Option<usize> {
356 source
357 .as_bytes()
358 .get(document_end..)
359 .is_some_and(|suffix| suffix.iter().any(|byte| !byte.is_ascii_whitespace()))
360 .then_some(document_end)
361}
362
363fn parser_compatible_source(source: &str) -> Option<String> {
368 let mut compatible = source.as_bytes().to_vec();
369 let mut changed = mask_blank_lines_after_mapping_keys(source, &mut compatible);
370 changed |= mask_non_colliding_anchor_hyphens(source, &mut compatible);
371 let mut offset = 0;
372 let mut block_scalar_indent = None;
373 let mut state = ParserCompatibilityState::default();
374
375 for line in source.split_inclusive('\n') {
376 let content = line.trim_end_matches(['\r', '\n']);
377 let indent = content.bytes().take_while(|byte| *byte == b' ').count();
378 let blank = content[indent..].trim().is_empty();
379
380 if let Some(header_indent) = block_scalar_indent {
381 if blank || indent > header_indent {
382 offset += line.len();
383 continue;
384 }
385 block_scalar_indent = None;
386 }
387
388 let (line_changed, starts_block_scalar) = mask_block_plain_commas(content, offset, &mut compatible, &mut state);
389 changed |= line_changed;
390 if starts_block_scalar {
391 block_scalar_indent = Some(indent);
392 }
393 offset += line.len();
394 }
395
396 if changed {
397 String::from_utf8(compatible).ok()
398 } else {
399 None
400 }
401}
402
403fn mask_blank_lines_after_mapping_keys(source: &str, compatible: &mut [u8]) -> bool {
404 let bytes = source.as_bytes();
405 let mut line_starts = vec![0];
406 line_starts.extend(
407 bytes
408 .iter()
409 .enumerate()
410 .filter_map(|(index, byte)| (*byte == b'\n').then_some(index + 1)),
411 );
412 let mut changed = false;
413
414 for line_index in 0..line_starts.len().saturating_sub(2) {
415 let start = line_starts[line_index];
416 let end = line_starts.get(line_index + 1).copied().unwrap_or(bytes.len());
417 let content_end = line_content_end(bytes, start, end);
418 let content = &source[start..content_end];
419 let trimmed = content.trim();
420 if trimmed.starts_with('#') || !trimmed.ends_with(':') {
421 continue;
422 }
423
424 let mut next_line = line_index + 1;
425 while next_line < line_starts.len() {
426 let next_start = line_starts[next_line];
427 let next_end = line_starts.get(next_line + 1).copied().unwrap_or(bytes.len());
428 let next_content_end = line_content_end(bytes, next_start, next_end);
429 if !source[next_start..next_content_end].trim().is_empty() {
430 break;
431 }
432 next_line += 1;
433 }
434 if next_line == line_index + 1 || next_line >= line_starts.len() {
435 continue;
436 }
437
438 let key_indent = content.bytes().take_while(|byte| *byte == b' ').count();
439 let value_start = line_starts[next_line];
440 let value_indent = source[value_start..].bytes().take_while(|byte| *byte == b' ').count();
441 if value_indent <= key_indent {
442 continue;
443 }
444
445 let final_blank_start = line_starts[next_line - 1];
448 compatible[content_end..final_blank_start].fill(b' ');
449 changed = true;
450 }
451 changed
452}
453
454fn line_content_end(bytes: &[u8], start: usize, end: usize) -> usize {
455 let mut content_end = end;
456 if content_end > start && bytes[content_end - 1] == b'\n' {
457 content_end -= 1;
458 }
459 if content_end > start && bytes[content_end - 1] == b'\r' {
460 content_end -= 1;
461 }
462 content_end
463}
464
465fn mask_non_colliding_anchor_hyphens(source: &str, compatible: &mut [u8]) -> bool {
466 let bytes = source.as_bytes();
467 let mut occurrences = Vec::new();
468 let mut originals_by_normalized = BTreeMap::<Vec<u8>, BTreeSet<Vec<u8>>>::new();
469 let mut index = 0;
470
471 while index < bytes.len() {
472 if !matches!(bytes[index], b'&' | b'*') {
473 index += 1;
474 continue;
475 }
476 let line_start = bytes[..index]
477 .iter()
478 .rposition(|byte| *byte == b'\n')
479 .map_or(0, |position| position + 1);
480 let preceding = bytes[line_start..index]
481 .iter()
482 .rfind(|byte| !byte.is_ascii_whitespace())
483 .copied();
484 if preceding.is_some_and(|byte| !matches!(byte, b':' | b'-' | b'[' | b'{' | b',' | b'?')) {
485 index += 1;
486 continue;
487 }
488 let start = index + 1;
489 let mut end = start;
490 while bytes.get(end).is_some_and(|byte| !anchor_name_delimiter(*byte)) {
491 end += 1;
492 }
493 if start == end {
494 index += 1;
495 continue;
496 }
497
498 let original = bytes[start..end].to_vec();
499 let normalized = original
500 .iter()
501 .map(|byte| if *byte == b'-' { b'_' } else { *byte })
502 .collect::<Vec<_>>();
503 originals_by_normalized
504 .entry(normalized.clone())
505 .or_default()
506 .insert(original.clone());
507 occurrences.push((start, end, original, normalized));
508 index = end;
509 }
510
511 let mut changed = false;
512 for (start, end, original, normalized) in occurrences {
513 if !original.contains(&b'-')
514 || originals_by_normalized
515 .get(&normalized)
516 .is_none_or(|originals| originals.len() != 1)
517 {
518 continue;
519 }
520 for byte in &mut compatible[start..end] {
521 if *byte == b'-' {
522 *byte = b'_';
523 changed = true;
524 }
525 }
526 }
527 changed
528}
529
530const fn anchor_name_delimiter(byte: u8) -> bool {
531 byte.is_ascii_whitespace() || matches!(byte, b'[' | b']' | b'{' | b'}' | b',')
532}
533
534#[derive(Debug, Default)]
535struct ParserCompatibilityState {
536 flow_depth: u32,
537 quote: Option<u8>,
538 escaped: bool,
539}
540
541fn mask_block_plain_commas(
542 content: &str,
543 offset: usize,
544 compatible: &mut [u8],
545 state: &mut ParserCompatibilityState,
546) -> (bool, bool) {
547 let bytes = content.as_bytes();
548 let mut index = 0;
549 let mut first_token = true;
550 let mut plain_started = false;
551 let mut eligible_plain_value = false;
552 let mut interpolation_depth = 0u32;
553 let mut changed = false;
554
555 while index < bytes.len() {
556 let byte = bytes[index];
557
558 if let Some(delimiter) = state.quote {
559 if delimiter == b'"' && state.escaped {
560 state.escaped = false;
561 } else if delimiter == b'"' && byte == b'\\' {
562 state.escaped = true;
563 } else if byte == delimiter {
564 if delimiter == b'\'' && bytes.get(index + 1) == Some(&b'\'') {
565 index += 1;
566 } else {
567 state.quote = None;
568 }
569 }
570 index += 1;
571 continue;
572 }
573
574 if byte == b'#' && (index == 0 || bytes[index - 1].is_ascii_whitespace()) {
575 break;
576 }
577
578 if eligible_plain_value && !plain_started && matches!(byte, b'!' | b'&') {
579 index += 1;
580 while bytes.get(index).is_some_and(|byte| !byte.is_ascii_whitespace()) {
581 index += 1;
582 }
583 first_token = false;
584 continue;
585 }
586
587 if matches!(byte, b'\'' | b'"') && !plain_started {
588 state.quote = Some(byte);
589 first_token = false;
590 index += 1;
591 continue;
592 }
593
594 if state.flow_depth > 0 {
595 match byte {
596 b'[' | b'{' => state.flow_depth += 1,
597 b']' | b'}' => state.flow_depth -= 1,
598 _ => {}
599 }
600 index += 1;
601 continue;
602 }
603
604 let token_boundary = index == 0 || bytes[index - 1].is_ascii_whitespace();
605 if matches!(byte, b'[' | b'{') && (!plain_started || (eligible_plain_value && token_boundary)) {
606 state.flow_depth = 1;
607 first_token = false;
608 index += 1;
609 continue;
610 }
611
612 if byte.is_ascii_whitespace() {
613 index += 1;
614 continue;
615 }
616
617 if first_token && byte == b'-' && bytes.get(index + 1).is_none_or(u8::is_ascii_whitespace) {
618 eligible_plain_value = true;
619 plain_started = false;
620 first_token = false;
621 index += 1;
622 continue;
623 }
624
625 if byte == b':' && bytes.get(index + 1).is_none_or(u8::is_ascii_whitespace) {
626 eligible_plain_value = true;
627 plain_started = false;
628 first_token = false;
629 index += 1;
630 continue;
631 }
632
633 if eligible_plain_value && !plain_started && matches!(byte, b'|' | b'>') {
634 return (changed, true);
635 }
636
637 if eligible_plain_value && !plain_started && byte == b'-' && bytes.get(index + 1) == Some(&b'-') {
641 compatible[offset + index] = b'_';
642 changed = true;
643 }
644
645 if eligible_plain_value && plain_started && byte == b',' {
646 compatible[offset + index] = b'_';
647 changed = true;
648 }
649
650 if eligible_plain_value && byte == b'$' && bytes.get(index + 1) == Some(&b'{') {
655 interpolation_depth = interpolation_depth.saturating_add(1);
656 } else if eligible_plain_value && plain_started && byte == b'}' && interpolation_depth > 0 {
657 interpolation_depth -= 1;
658 compatible[offset + index] = b'_';
659 changed = true;
660 }
661
662 plain_started = true;
663 first_token = false;
664 index += 1;
665 }
666
667 state.escaped = false;
668 (changed, false)
669}
670
671pub(crate) fn scalar_raw_from_source(source: &str, scalar: &Scalar) -> String {
672 let range = scalar.byte_range();
673 source
674 .get(range.start as usize..range.end as usize)
675 .map_or_else(|| scalar.value(), str::to_owned)
676}
677
678pub(crate) fn scalar_string_from_source(source: &str, scalar: &Scalar) -> String {
679 let authored = scalar_raw_from_source(source, scalar);
680 if authored == scalar.value() {
681 scalar.as_string()
682 } else {
683 authored
685 }
686}
687
688fn collect_value_scalars(source_id: SourceId, source: &str, node: YamlNode, values: &mut Vec<ValueScalar>) {
689 match node {
690 YamlNode::Scalar(scalar) => collect_scalar(source_id, source, &scalar, values),
691 YamlNode::Mapping(mapping) => {
692 for value in mapping.entries().filter_map(|entry| entry.value_node()) {
693 collect_value_scalars(source_id, source, value, values);
694 }
695 }
696 YamlNode::Sequence(sequence) => {
697 for value in sequence.values() {
698 collect_value_scalars(source_id, source, value, values);
699 }
700 }
701 YamlNode::TaggedNode(tagged) => {
702 if let Some(node) = tagged
703 .as_node()
704 .and_then(|node| node.children().find_map(YamlNode::from_syntax))
705 {
706 collect_value_scalars(source_id, source, node, values);
707 }
708 }
709 YamlNode::Alias(_) => {}
710 }
711}
712
713fn collect_editable_value_scalars(
714 source_id: SourceId,
715 source: &str,
716 node: YamlNode,
717 values: &mut Vec<EditableValueScalar>,
718) {
719 match node {
720 YamlNode::Scalar(scalar) => {
721 values.push(EditableValueScalar {
722 raw: scalar_raw_from_source(source, &scalar),
723 span: position_span(source_id, scalar.byte_range()),
724 });
725 }
726 YamlNode::Mapping(mapping) => {
727 for value in mapping.entries().filter_map(|entry| entry.value_node()) {
728 collect_editable_value_scalars(source_id, source, value, values);
729 }
730 }
731 YamlNode::Sequence(sequence) => {
732 for value in sequence.values() {
733 collect_editable_value_scalars(source_id, source, value, values);
734 }
735 }
736 YamlNode::TaggedNode(tagged) => {
737 if let Some(node) = tagged
738 .as_node()
739 .and_then(|node| node.children().find_map(YamlNode::from_syntax))
740 {
741 collect_editable_value_scalars(source_id, source, node, values);
742 }
743 }
744 YamlNode::Alias(_) => {}
745 }
746}
747
748fn extract_merge_value(
749 source_id: SourceId,
750 source: &str,
751 node: YamlNode,
752 registry: &AnchorRegistry,
753 aliases: &mut Vec<String>,
754) -> MergeSyntaxValue {
755 match node {
756 YamlNode::Scalar(scalar) if scalar.byte_range().is_empty() => {
757 MergeSyntaxValue::Empty(position_span(source_id, scalar.byte_range()))
758 }
759 YamlNode::Scalar(scalar) => MergeSyntaxValue::Scalar(extract_merge_scalar(source_id, source, &scalar)),
760 YamlNode::Mapping(mapping) => extract_merge_mapping(source_id, source, &mapping, registry, aliases),
761 YamlNode::Sequence(sequence) => {
762 let span = position_span(source_id, sequence.byte_range());
763 let values = sequence
764 .values()
765 .map(|value| extract_merge_value(source_id, source, value, registry, aliases))
766 .collect();
767 MergeSyntaxValue::Sequence { values, span }
768 }
769 YamlNode::Alias(alias) => {
770 let name = alias.name();
771 let span = yaml_node_span(source_id, &YamlNode::Alias(alias.clone()));
772 if aliases.contains(&name) || aliases.len() >= 64 {
773 return MergeSyntaxValue::Alias {
774 name: original_alias_name(source, span).unwrap_or(name),
775 span,
776 };
777 }
778 if let Some(target) = registry.resolve(&name).and_then(|node| {
779 YamlNode::from_syntax(node.clone()).or_else(|| node.children().find_map(YamlNode::from_syntax))
780 }) {
781 aliases.push(name);
782 let value = extract_merge_value(source_id, source, target, registry, aliases);
783 let _ = aliases.pop();
784 value
785 } else {
786 MergeSyntaxValue::Alias {
787 name: original_alias_name(source, span).unwrap_or(name),
788 span,
789 }
790 }
791 }
792 YamlNode::TaggedNode(tagged) => {
793 let span = yaml_node_span(source_id, &YamlNode::TaggedNode(tagged.clone()));
794 let tag = tagged.tag().unwrap_or_default();
795 let mut value = tagged
796 .as_node()
797 .and_then(|node| node.children().find_map(YamlNode::from_syntax))
798 .map_or_else(
799 || MergeSyntaxValue::Empty(span),
800 |value| extract_merge_value(source_id, source, value, registry, aliases),
801 );
802 if matches!(tag.as_str(), "!!timestamp" | "!!regex") {
803 if let MergeSyntaxValue::Scalar(scalar) = &mut value {
804 scalar.strict_yaml_string = false;
805 }
806 }
807 MergeSyntaxValue::Tagged {
808 tag,
809 value: Box::new(value),
810 span,
811 }
812 }
813 }
814}
815
816fn original_alias_name(source: &str, span: SourceSpan) -> Option<String> {
817 source.get(span.range())?.strip_prefix('*').map(ToOwned::to_owned)
818}
819
820fn extract_merge_mapping(
821 source_id: SourceId,
822 source: &str,
823 mapping: &Mapping,
824 registry: &AnchorRegistry,
825 aliases: &mut Vec<String>,
826) -> MergeSyntaxValue {
827 let span = position_span(source_id, mapping.byte_range());
828 let direct = flatten_merge_fields(source_id, source, raw_merge_fields(source_id, source, mapping));
829 let mut entries = Vec::new();
830 let mut direct_keys = Vec::new();
831
832 for field in direct {
833 if field.key.value == "<<" {
834 continue;
835 }
836 direct_keys.push(field.key.value.clone());
837 let value = field.value.map_or_else(
838 || {
839 MergeSyntaxValue::Empty(SourceSpan::from_valid_offsets(
840 source_id,
841 field.key.span.end(),
842 field.key.span.end(),
843 ))
844 },
845 |value| extract_merge_value(source_id, source, resolve_alias(value, registry), registry, aliases),
846 );
847 entries.push(MergeSyntaxEntry { key: field.key, value });
848 }
849
850 for (key, value) in mapping.merged(registry).iter() {
851 let Some(key) = key
852 .as_scalar()
853 .map(|scalar| extract_merge_scalar(source_id, source, scalar))
854 else {
855 continue;
856 };
857 if direct_keys.contains(&key.value) {
858 continue;
859 }
860 entries.push(MergeSyntaxEntry {
861 key,
862 value: extract_merge_value(source_id, source, value, registry, aliases),
863 });
864 }
865
866 MergeSyntaxValue::Mapping { entries, span }
867}
868
869#[derive(Debug)]
870struct RawMergeField {
871 key: MergeSyntaxScalar,
872 value: Option<YamlNode>,
873}
874
875fn raw_merge_fields(source_id: SourceId, source: &str, mapping: &Mapping) -> Vec<RawMergeField> {
876 mapping
877 .entries()
878 .filter_map(|entry| {
879 let key = entry.key_node()?.as_scalar().cloned()?;
880 Some(RawMergeField {
881 key: extract_merge_scalar(source_id, source, &key),
882 value: entry.value_node(),
883 })
884 })
885 .collect()
886}
887
888fn flatten_merge_fields(source_id: SourceId, source: &str, fields: Vec<RawMergeField>) -> Vec<RawMergeField> {
889 let Some(target_column) = fields
890 .first()
891 .map(|field| source_column(source, field.key.span.start()))
892 else {
893 return fields;
894 };
895 recover_merge_fields(source_id, source, fields, target_column)
896}
897
898fn recover_merge_fields(
899 source_id: SourceId,
900 source: &str,
901 fields: Vec<RawMergeField>,
902 target_column: usize,
903) -> Vec<RawMergeField> {
904 let mut flattened = Vec::new();
905 for mut field in fields {
906 let field_column = source_column(source, field.key.span.start());
907 let nested_mapping = field.value.as_ref().and_then(YamlNode::as_mapping).cloned();
908 let continuation = nested_mapping.as_ref().is_some_and(|mapping| {
909 !is_flow_mapping(source, mapping)
910 && mapping
911 .entries()
912 .find_map(|entry| entry.key_node()?.as_scalar().map(Scalar::byte_range))
913 .is_some_and(|position| source_column(source, position.start as usize) <= field_column)
914 });
915 if continuation {
916 field.value = None;
917 }
918 if field_column == target_column {
919 flattened.push(field);
920 }
921 if let Some(mapping) = nested_mapping.filter(|mapping| !is_flow_mapping(source, mapping)) {
922 let nested = raw_merge_fields(source_id, source, &mapping);
923 flattened.extend(recover_merge_fields(source_id, source, nested, target_column));
924 }
925 }
926 flattened
927}
928
929fn is_flow_mapping(source: &str, mapping: &Mapping) -> bool {
930 let position = mapping.byte_range();
931 source
932 .get(position.start as usize..position.end as usize)
933 .is_some_and(|text| text.trim_start().starts_with('{'))
934}
935
936fn source_column(source: &str, offset: usize) -> usize {
937 let prefix = &source[..offset.min(source.len())];
938 let line_start = prefix.rfind('\n').map_or(0, |index| index + 1);
939 source[line_start..offset.min(source.len())].chars().count()
940}
941
942fn resolve_alias(node: YamlNode, registry: &AnchorRegistry) -> YamlNode {
943 let YamlNode::Alias(alias) = &node else {
944 return node;
945 };
946 registry
947 .resolve(&alias.name())
948 .and_then(|target| {
949 YamlNode::from_syntax(target.clone()).or_else(|| target.children().find_map(YamlNode::from_syntax))
950 })
951 .unwrap_or(node)
952}
953
954fn extract_merge_scalar(source_id: SourceId, source: &str, scalar: &Scalar) -> MergeSyntaxScalar {
955 let scalar_type = ScalarValue::from_scalar(scalar).scalar_type();
956 let kind = match scalar_type {
957 ScalarType::Boolean => MergeScalarKind::Boolean,
958 ScalarType::Integer | ScalarType::Float => MergeScalarKind::Number,
959 ScalarType::Null => MergeScalarKind::Null,
960 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => MergeScalarKind::String,
961 };
962 MergeSyntaxScalar {
963 raw: scalar_raw_from_source(source, scalar),
964 value: scalar_string_from_source(source, scalar),
965 kind,
966 plain: ScalarValue::from_scalar(scalar).style() == ScalarStyle::Plain
967 && !scalar_uses_block_style(source, scalar),
968 strict_yaml_string: scalar_type == ScalarType::String,
969 span: position_span(source_id, scalar.byte_range()),
970 }
971}
972
973fn scalar_uses_block_style(source: &str, scalar: &Scalar) -> bool {
974 let start = scalar.byte_range().start as usize;
975 source[start..].trim_start().starts_with(['|', '>'])
976 || source[..start]
977 .lines()
978 .rev()
979 .find(|line| !line.trim().is_empty())
980 .is_some_and(|header| header.contains(": |") || header.contains(": >"))
981}
982
983fn yaml_node_span(source_id: SourceId, node: &YamlNode) -> SourceSpan {
984 let Some(syntax) = node.as_node() else {
985 return SourceSpan::from_valid_offsets(source_id, 0, 0);
986 };
987 let range = syntax.text_range();
988 SourceSpan::from_valid_offsets(
989 source_id,
990 u32::from(range.start()) as usize,
991 u32::from(range.end()) as usize,
992 )
993}
994
995fn position_span(source_id: SourceId, position: yaml_edit::TextPosition) -> SourceSpan {
996 SourceSpan::from_valid_offsets(source_id, position.start as usize, position.end as usize)
997}
998
999fn collect_scalar(source_id: SourceId, source: &str, scalar: &Scalar, values: &mut Vec<ValueScalar>) {
1000 let raw = scalar_raw_from_source(source, scalar);
1001 let eligible_style = !raw.starts_with('\'') && !raw.starts_with('|') && !raw.starts_with('>');
1002 if !eligible_style || !raw.contains('$') {
1003 return;
1004 }
1005 let position = scalar.byte_range();
1006 values.push(ValueScalar {
1007 value: scalar_string_from_source(source, scalar),
1008 span: SourceSpan::from_valid_offsets(source_id, position.start as usize, position.end as usize),
1009 });
1010}
1011
1012#[cfg(test)]
1013mod tests {
1014 use super::{
1015 MergeSyntaxScalar, MergeSyntaxValue, SyntaxDocument, parser_compatible_source,
1016 unparsed_input_offset_after_document_end,
1017 };
1018 use crate::source::SourceId;
1019
1020 fn assert_send_and_sync<T: Send + Sync>() {}
1021
1022 #[test]
1023 fn syntax_documents_are_send_and_sync() {
1024 assert_send_and_sync::<SyntaxDocument>();
1025 }
1026
1027 #[test]
1028 fn parsing_never_reads_the_process_environment() -> Result<(), Box<dyn std::error::Error>> {
1029 let source = "services:\n app:\n image: ${COMPOSE_LENS_SECRET}\n";
1030 let parsed = SyntaxDocument::parse(SourceId::new(1), source)?;
1031
1032 assert_eq!(parsed.document().render_preserved(), source);
1033 assert!(parsed.is_valid());
1034 Ok(())
1035 }
1036
1037 #[test]
1038 fn complete_root_guard_rejects_non_whitespace_after_the_backend_root() {
1039 let source = "services:\n app:\n image: example.invalid/app\n";
1040 let document_end = "services:".len();
1041
1042 assert_eq!(
1043 unparsed_input_offset_after_document_end(document_end, source),
1044 Some(document_end)
1045 );
1046 assert_eq!(unparsed_input_offset_after_document_end(source.len(), source), None);
1047 }
1048
1049 #[test]
1050 fn comma_bearing_short_volumes_are_complete_and_preserved() -> Result<(), Box<dyn std::error::Error>> {
1051 let source = "services:\n app:\n volumes:\n - ./data:/data:Z,ro\n later:\n image: later\n";
1052
1053 let parsed = SyntaxDocument::parse(SourceId::new(2), source)?;
1054
1055 assert!(parsed.is_valid());
1056 assert_eq!(parsed.document().render_preserved(), source);
1057 Ok(())
1058 }
1059
1060 #[test]
1061 fn anchor_compatibility_does_not_merge_colliding_names() {
1062 let source = "first: &shared-name one\nsecond: &shared_name two\n";
1063
1064 assert_eq!(parser_compatible_source(source), None);
1065 }
1066
1067 #[test]
1068 fn anchor_compatibility_ignores_scalar_and_comment_content() {
1069 let source = "quoted: \"*not-an-alias\"\nplain: echo ¬-an-anchor\n# *also-not-an-alias\n";
1070
1071 assert_eq!(parser_compatible_source(source), None);
1072 }
1073
1074 #[test]
1075 fn merge_scalars_retain_strict_yaml_string_identity() -> Result<(), Box<dyn std::error::Error>> {
1076 let parsed = SyntaxDocument::parse(
1077 SourceId::new(2),
1078 "plain: gpu\ntimestamp: !!timestamp 2023-12-25\nregex: !!regex 'gpu.*'\nquoted-timestamp: \"2023-12-25\"\nquoted-regex: \"gpu.*\"\n",
1079 )?;
1080 let root = parsed.document().merge_root().ok_or("merge root")?;
1081 let scalar = |name| -> Option<&MergeSyntaxScalar> {
1082 let MergeSyntaxValue::Mapping { entries, .. } = &root else {
1083 return None;
1084 };
1085 let value = &entries.iter().find(|entry| entry.key.value == name)?.value;
1086 match value {
1087 MergeSyntaxValue::Scalar(scalar) => Some(scalar),
1088 MergeSyntaxValue::Tagged { value, .. } => match value.as_ref() {
1089 MergeSyntaxValue::Scalar(scalar) => Some(scalar),
1090 _ => None,
1091 },
1092 _ => None,
1093 }
1094 };
1095 assert!(scalar("plain").is_some_and(|value| value.strict_yaml_string));
1096 for name in ["timestamp", "regex"] {
1097 assert!(
1098 scalar(name).is_some_and(|value| !value.strict_yaml_string),
1099 "{name}: {:?}",
1100 scalar(name)
1101 );
1102 }
1103 for name in ["quoted-timestamp", "quoted-regex"] {
1104 assert!(scalar(name).is_some_and(|value| value.strict_yaml_string));
1105 }
1106 Ok(())
1107 }
1108}