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 source
353 .as_bytes()
354 .get(document_end..)
355 .is_some_and(|suffix| suffix.iter().any(|byte| !byte.is_ascii_whitespace()))
356 .then_some(document_end)
357}
358
359fn parser_compatible_source(source: &str) -> Option<String> {
364 let mut compatible = source.as_bytes().to_vec();
365 let mut changed = mask_blank_lines_after_mapping_keys(source, &mut compatible);
366 changed |= mask_non_colliding_anchor_hyphens(source, &mut compatible);
367 let mut offset = 0;
368 let mut block_scalar_indent = None;
369 let mut state = ParserCompatibilityState::default();
370
371 for line in source.split_inclusive('\n') {
372 let content = line.trim_end_matches(['\r', '\n']);
373 let indent = content.bytes().take_while(|byte| *byte == b' ').count();
374 let blank = content[indent..].trim().is_empty();
375
376 if let Some(header_indent) = block_scalar_indent {
377 if blank || indent > header_indent {
378 offset += line.len();
379 continue;
380 }
381 block_scalar_indent = None;
382 }
383
384 let (line_changed, starts_block_scalar) = mask_block_plain_commas(content, offset, &mut compatible, &mut state);
385 changed |= line_changed;
386 if starts_block_scalar {
387 block_scalar_indent = Some(indent);
388 }
389 offset += line.len();
390 }
391
392 if changed {
393 String::from_utf8(compatible).ok()
394 } else {
395 None
396 }
397}
398
399fn mask_blank_lines_after_mapping_keys(source: &str, compatible: &mut [u8]) -> bool {
400 let bytes = source.as_bytes();
401 let mut line_starts = vec![0];
402 line_starts.extend(
403 bytes
404 .iter()
405 .enumerate()
406 .filter_map(|(index, byte)| (*byte == b'\n').then_some(index + 1)),
407 );
408 let mut changed = false;
409
410 for line_index in 0..line_starts.len().saturating_sub(2) {
411 let start = line_starts[line_index];
412 let end = line_starts.get(line_index + 1).copied().unwrap_or(bytes.len());
413 let content_end = line_content_end(bytes, start, end);
414 let content = &source[start..content_end];
415 let trimmed = content.trim();
416 if trimmed.starts_with('#') || !trimmed.ends_with(':') {
417 continue;
418 }
419
420 let mut next_line = line_index + 1;
421 while next_line < line_starts.len() {
422 let next_start = line_starts[next_line];
423 let next_end = line_starts.get(next_line + 1).copied().unwrap_or(bytes.len());
424 let next_content_end = line_content_end(bytes, next_start, next_end);
425 if !source[next_start..next_content_end].trim().is_empty() {
426 break;
427 }
428 next_line += 1;
429 }
430 if next_line == line_index + 1 || next_line >= line_starts.len() {
431 continue;
432 }
433
434 let key_indent = content.bytes().take_while(|byte| *byte == b' ').count();
435 let value_start = line_starts[next_line];
436 let value_indent = source[value_start..].bytes().take_while(|byte| *byte == b' ').count();
437 if value_indent <= key_indent {
438 continue;
439 }
440
441 let final_blank_start = line_starts[next_line - 1];
444 compatible[content_end..final_blank_start].fill(b' ');
445 changed = true;
446 }
447 changed
448}
449
450fn line_content_end(bytes: &[u8], start: usize, end: usize) -> usize {
451 let mut content_end = end;
452 if content_end > start && bytes[content_end - 1] == b'\n' {
453 content_end -= 1;
454 }
455 if content_end > start && bytes[content_end - 1] == b'\r' {
456 content_end -= 1;
457 }
458 content_end
459}
460
461fn mask_non_colliding_anchor_hyphens(source: &str, compatible: &mut [u8]) -> bool {
462 let bytes = source.as_bytes();
463 let mut occurrences = Vec::new();
464 let mut originals_by_normalized = BTreeMap::<Vec<u8>, BTreeSet<Vec<u8>>>::new();
465 let mut index = 0;
466
467 while index < bytes.len() {
468 if !matches!(bytes[index], b'&' | b'*') {
469 index += 1;
470 continue;
471 }
472 let line_start = bytes[..index]
473 .iter()
474 .rposition(|byte| *byte == b'\n')
475 .map_or(0, |position| position + 1);
476 let preceding = bytes[line_start..index]
477 .iter()
478 .rfind(|byte| !byte.is_ascii_whitespace())
479 .copied();
480 if preceding.is_some_and(|byte| !matches!(byte, b':' | b'-' | b'[' | b'{' | b',' | b'?')) {
481 index += 1;
482 continue;
483 }
484 let start = index + 1;
485 let mut end = start;
486 while bytes.get(end).is_some_and(|byte| !anchor_name_delimiter(*byte)) {
487 end += 1;
488 }
489 if start == end {
490 index += 1;
491 continue;
492 }
493
494 let original = bytes[start..end].to_vec();
495 let normalized = original
496 .iter()
497 .map(|byte| if *byte == b'-' { b'_' } else { *byte })
498 .collect::<Vec<_>>();
499 originals_by_normalized
500 .entry(normalized.clone())
501 .or_default()
502 .insert(original.clone());
503 occurrences.push((start, end, original, normalized));
504 index = end;
505 }
506
507 let mut changed = false;
508 for (start, end, original, normalized) in occurrences {
509 if !original.contains(&b'-')
510 || originals_by_normalized
511 .get(&normalized)
512 .is_none_or(|originals| originals.len() != 1)
513 {
514 continue;
515 }
516 for byte in &mut compatible[start..end] {
517 if *byte == b'-' {
518 *byte = b'_';
519 changed = true;
520 }
521 }
522 }
523 changed
524}
525
526const fn anchor_name_delimiter(byte: u8) -> bool {
527 byte.is_ascii_whitespace() || matches!(byte, b'[' | b']' | b'{' | b'}' | b',')
528}
529
530#[derive(Debug, Default)]
531struct ParserCompatibilityState {
532 flow_depth: u32,
533 quote: Option<u8>,
534 escaped: bool,
535}
536
537fn mask_block_plain_commas(
538 content: &str,
539 offset: usize,
540 compatible: &mut [u8],
541 state: &mut ParserCompatibilityState,
542) -> (bool, bool) {
543 let bytes = content.as_bytes();
544 let mut index = 0;
545 let mut first_token = true;
546 let mut plain_started = false;
547 let mut eligible_plain_value = false;
548 let mut interpolation_depth = 0u32;
549 let mut changed = false;
550
551 while index < bytes.len() {
552 let byte = bytes[index];
553
554 if let Some(delimiter) = state.quote {
555 if delimiter == b'"' && state.escaped {
556 state.escaped = false;
557 } else if delimiter == b'"' && byte == b'\\' {
558 state.escaped = true;
559 } else if byte == delimiter {
560 if delimiter == b'\'' && bytes.get(index + 1) == Some(&b'\'') {
561 index += 1;
562 } else {
563 state.quote = None;
564 }
565 }
566 index += 1;
567 continue;
568 }
569
570 if byte == b'#' && (index == 0 || bytes[index - 1].is_ascii_whitespace()) {
571 break;
572 }
573
574 if eligible_plain_value && !plain_started && matches!(byte, b'!' | b'&') {
575 index += 1;
576 while bytes.get(index).is_some_and(|byte| !byte.is_ascii_whitespace()) {
577 index += 1;
578 }
579 first_token = false;
580 continue;
581 }
582
583 if matches!(byte, b'\'' | b'"') && !plain_started {
584 state.quote = Some(byte);
585 first_token = false;
586 index += 1;
587 continue;
588 }
589
590 if state.flow_depth > 0 {
591 match byte {
592 b'[' | b'{' => state.flow_depth += 1,
593 b']' | b'}' => state.flow_depth -= 1,
594 _ => {}
595 }
596 index += 1;
597 continue;
598 }
599
600 let token_boundary = index == 0 || bytes[index - 1].is_ascii_whitespace();
601 if matches!(byte, b'[' | b'{') && (!plain_started || (eligible_plain_value && token_boundary)) {
602 state.flow_depth = 1;
603 first_token = false;
604 index += 1;
605 continue;
606 }
607
608 if byte.is_ascii_whitespace() {
609 index += 1;
610 continue;
611 }
612
613 if first_token && byte == b'-' && bytes.get(index + 1).is_none_or(u8::is_ascii_whitespace) {
614 eligible_plain_value = true;
615 plain_started = false;
616 first_token = false;
617 index += 1;
618 continue;
619 }
620
621 if byte == b':' && bytes.get(index + 1).is_none_or(u8::is_ascii_whitespace) {
622 eligible_plain_value = true;
623 plain_started = false;
624 first_token = false;
625 index += 1;
626 continue;
627 }
628
629 if eligible_plain_value && !plain_started && matches!(byte, b'|' | b'>') {
630 return (changed, true);
631 }
632
633 if eligible_plain_value && !plain_started && byte == b'-' && bytes.get(index + 1) == Some(&b'-') {
637 compatible[offset + index] = b'_';
638 changed = true;
639 }
640
641 if eligible_plain_value && plain_started && byte == b',' {
642 compatible[offset + index] = b'_';
643 changed = true;
644 }
645
646 if eligible_plain_value && byte == b'$' && bytes.get(index + 1) == Some(&b'{') {
651 interpolation_depth = interpolation_depth.saturating_add(1);
652 } else if eligible_plain_value && plain_started && byte == b'}' && interpolation_depth > 0 {
653 interpolation_depth -= 1;
654 compatible[offset + index] = b'_';
655 changed = true;
656 }
657
658 plain_started = true;
659 first_token = false;
660 index += 1;
661 }
662
663 state.escaped = false;
664 (changed, false)
665}
666
667pub(crate) fn scalar_raw_from_source(source: &str, scalar: &Scalar) -> String {
668 let range = scalar.byte_range();
669 source
670 .get(range.start as usize..range.end as usize)
671 .map_or_else(|| scalar.value(), str::to_owned)
672}
673
674pub(crate) fn scalar_string_from_source(source: &str, scalar: &Scalar) -> String {
675 let authored = scalar_raw_from_source(source, scalar);
676 if authored == scalar.value() {
677 scalar.as_string()
678 } else {
679 authored
681 }
682}
683
684fn collect_value_scalars(source_id: SourceId, source: &str, node: YamlNode, values: &mut Vec<ValueScalar>) {
685 match node {
686 YamlNode::Scalar(scalar) => collect_scalar(source_id, source, &scalar, values),
687 YamlNode::Mapping(mapping) => {
688 for value in mapping.entries().filter_map(|entry| entry.value_node()) {
689 collect_value_scalars(source_id, source, value, values);
690 }
691 }
692 YamlNode::Sequence(sequence) => {
693 for value in sequence.values() {
694 collect_value_scalars(source_id, source, value, values);
695 }
696 }
697 YamlNode::TaggedNode(tagged) => {
698 if let Some(node) = tagged
699 .as_node()
700 .and_then(|node| node.children().find_map(YamlNode::from_syntax))
701 {
702 collect_value_scalars(source_id, source, node, values);
703 }
704 }
705 YamlNode::Alias(_) => {}
706 }
707}
708
709fn collect_editable_value_scalars(
710 source_id: SourceId,
711 source: &str,
712 node: YamlNode,
713 values: &mut Vec<EditableValueScalar>,
714) {
715 match node {
716 YamlNode::Scalar(scalar) => {
717 values.push(EditableValueScalar {
718 raw: scalar_raw_from_source(source, &scalar),
719 span: position_span(source_id, scalar.byte_range()),
720 });
721 }
722 YamlNode::Mapping(mapping) => {
723 for value in mapping.entries().filter_map(|entry| entry.value_node()) {
724 collect_editable_value_scalars(source_id, source, value, values);
725 }
726 }
727 YamlNode::Sequence(sequence) => {
728 for value in sequence.values() {
729 collect_editable_value_scalars(source_id, source, value, values);
730 }
731 }
732 YamlNode::TaggedNode(tagged) => {
733 if let Some(node) = tagged
734 .as_node()
735 .and_then(|node| node.children().find_map(YamlNode::from_syntax))
736 {
737 collect_editable_value_scalars(source_id, source, node, values);
738 }
739 }
740 YamlNode::Alias(_) => {}
741 }
742}
743
744fn extract_merge_value(
745 source_id: SourceId,
746 source: &str,
747 node: YamlNode,
748 registry: &AnchorRegistry,
749 aliases: &mut Vec<String>,
750) -> MergeSyntaxValue {
751 match node {
752 YamlNode::Scalar(scalar) => MergeSyntaxValue::Scalar(extract_merge_scalar(source_id, source, &scalar)),
753 YamlNode::Mapping(mapping) => extract_merge_mapping(source_id, source, &mapping, registry, aliases),
754 YamlNode::Sequence(sequence) => {
755 let span = position_span(source_id, sequence.byte_range());
756 let values = sequence
757 .values()
758 .map(|value| extract_merge_value(source_id, source, value, registry, aliases))
759 .collect();
760 MergeSyntaxValue::Sequence { values, span }
761 }
762 YamlNode::Alias(alias) => {
763 let name = alias.name();
764 let span = yaml_node_span(source_id, &YamlNode::Alias(alias.clone()));
765 if aliases.contains(&name) || aliases.len() >= 64 {
766 return MergeSyntaxValue::Alias {
767 name: original_alias_name(source, span).unwrap_or(name),
768 span,
769 };
770 }
771 if let Some(target) = registry.resolve(&name).and_then(|node| {
772 YamlNode::from_syntax(node.clone()).or_else(|| node.children().find_map(YamlNode::from_syntax))
773 }) {
774 aliases.push(name);
775 let value = extract_merge_value(source_id, source, target, registry, aliases);
776 let _ = aliases.pop();
777 value
778 } else {
779 MergeSyntaxValue::Alias {
780 name: original_alias_name(source, span).unwrap_or(name),
781 span,
782 }
783 }
784 }
785 YamlNode::TaggedNode(tagged) => {
786 let span = yaml_node_span(source_id, &YamlNode::TaggedNode(tagged.clone()));
787 let tag = tagged.tag().unwrap_or_default();
788 let mut value = tagged
789 .as_node()
790 .and_then(|node| node.children().find_map(YamlNode::from_syntax))
791 .map_or_else(
792 || MergeSyntaxValue::Empty(span),
793 |value| extract_merge_value(source_id, source, value, registry, aliases),
794 );
795 if matches!(tag.as_str(), "!!timestamp" | "!!regex") {
796 if let MergeSyntaxValue::Scalar(scalar) = &mut value {
797 scalar.strict_yaml_string = false;
798 }
799 }
800 MergeSyntaxValue::Tagged {
801 tag,
802 value: Box::new(value),
803 span,
804 }
805 }
806 }
807}
808
809fn original_alias_name(source: &str, span: SourceSpan) -> Option<String> {
810 source.get(span.range())?.strip_prefix('*').map(ToOwned::to_owned)
811}
812
813fn extract_merge_mapping(
814 source_id: SourceId,
815 source: &str,
816 mapping: &Mapping,
817 registry: &AnchorRegistry,
818 aliases: &mut Vec<String>,
819) -> MergeSyntaxValue {
820 let span = position_span(source_id, mapping.byte_range());
821 let direct = flatten_merge_fields(source_id, source, raw_merge_fields(source_id, source, mapping));
822 let mut entries = Vec::new();
823 let mut direct_keys = Vec::new();
824
825 for field in direct {
826 if field.key.value == "<<" {
827 continue;
828 }
829 direct_keys.push(field.key.value.clone());
830 let value = field.value.map_or_else(
831 || {
832 MergeSyntaxValue::Empty(SourceSpan::from_valid_offsets(
833 source_id,
834 field.key.span.end(),
835 field.key.span.end(),
836 ))
837 },
838 |value| extract_merge_value(source_id, source, resolve_alias(value, registry), registry, aliases),
839 );
840 entries.push(MergeSyntaxEntry { key: field.key, value });
841 }
842
843 for (key, value) in mapping.merged(registry).iter() {
844 let Some(key) = key
845 .as_scalar()
846 .map(|scalar| extract_merge_scalar(source_id, source, scalar))
847 else {
848 continue;
849 };
850 if direct_keys.contains(&key.value) {
851 continue;
852 }
853 entries.push(MergeSyntaxEntry {
854 key,
855 value: extract_merge_value(source_id, source, value, registry, aliases),
856 });
857 }
858
859 MergeSyntaxValue::Mapping { entries, span }
860}
861
862#[derive(Debug)]
863struct RawMergeField {
864 key: MergeSyntaxScalar,
865 value: Option<YamlNode>,
866}
867
868fn raw_merge_fields(source_id: SourceId, source: &str, mapping: &Mapping) -> Vec<RawMergeField> {
869 mapping
870 .entries()
871 .filter_map(|entry| {
872 let key = entry.key_node()?.as_scalar().cloned()?;
873 Some(RawMergeField {
874 key: extract_merge_scalar(source_id, source, &key),
875 value: entry.value_node(),
876 })
877 })
878 .collect()
879}
880
881fn flatten_merge_fields(source_id: SourceId, source: &str, fields: Vec<RawMergeField>) -> Vec<RawMergeField> {
882 let Some(target_column) = fields
883 .first()
884 .map(|field| source_column(source, field.key.span.start()))
885 else {
886 return fields;
887 };
888 recover_merge_fields(source_id, source, fields, target_column)
889}
890
891fn recover_merge_fields(
892 source_id: SourceId,
893 source: &str,
894 fields: Vec<RawMergeField>,
895 target_column: usize,
896) -> Vec<RawMergeField> {
897 let mut flattened = Vec::new();
898 for mut field in fields {
899 let field_column = source_column(source, field.key.span.start());
900 let nested_mapping = field.value.as_ref().and_then(YamlNode::as_mapping).cloned();
901 let continuation = nested_mapping.as_ref().is_some_and(|mapping| {
902 !is_flow_mapping(source, mapping)
903 && mapping
904 .entries()
905 .find_map(|entry| entry.key_node()?.as_scalar().map(Scalar::byte_range))
906 .is_some_and(|position| source_column(source, position.start as usize) <= field_column)
907 });
908 if continuation {
909 field.value = None;
910 }
911 if field_column == target_column {
912 flattened.push(field);
913 }
914 if let Some(mapping) = nested_mapping.filter(|mapping| !is_flow_mapping(source, mapping)) {
915 let nested = raw_merge_fields(source_id, source, &mapping);
916 flattened.extend(recover_merge_fields(source_id, source, nested, target_column));
917 }
918 }
919 flattened
920}
921
922fn is_flow_mapping(source: &str, mapping: &Mapping) -> bool {
923 let position = mapping.byte_range();
924 source
925 .get(position.start as usize..position.end as usize)
926 .is_some_and(|text| text.trim_start().starts_with('{'))
927}
928
929fn source_column(source: &str, offset: usize) -> usize {
930 let prefix = &source[..offset.min(source.len())];
931 let line_start = prefix.rfind('\n').map_or(0, |index| index + 1);
932 source[line_start..offset.min(source.len())].chars().count()
933}
934
935fn resolve_alias(node: YamlNode, registry: &AnchorRegistry) -> YamlNode {
936 let YamlNode::Alias(alias) = &node else {
937 return node;
938 };
939 registry
940 .resolve(&alias.name())
941 .and_then(|target| {
942 YamlNode::from_syntax(target.clone()).or_else(|| target.children().find_map(YamlNode::from_syntax))
943 })
944 .unwrap_or(node)
945}
946
947fn extract_merge_scalar(source_id: SourceId, source: &str, scalar: &Scalar) -> MergeSyntaxScalar {
948 let scalar_type = ScalarValue::from_scalar(scalar).scalar_type();
949 let kind = match scalar_type {
950 ScalarType::Boolean => MergeScalarKind::Boolean,
951 ScalarType::Integer | ScalarType::Float => MergeScalarKind::Number,
952 ScalarType::Null => MergeScalarKind::Null,
953 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => MergeScalarKind::String,
954 };
955 MergeSyntaxScalar {
956 raw: scalar_raw_from_source(source, scalar),
957 value: scalar_string_from_source(source, scalar),
958 kind,
959 plain: ScalarValue::from_scalar(scalar).style() == ScalarStyle::Plain
960 && !scalar_uses_block_style(source, scalar),
961 strict_yaml_string: scalar_type == ScalarType::String,
962 span: position_span(source_id, scalar.byte_range()),
963 }
964}
965
966fn scalar_uses_block_style(source: &str, scalar: &Scalar) -> bool {
967 let start = scalar.byte_range().start as usize;
968 source[start..].trim_start().starts_with(['|', '>'])
969 || source[..start]
970 .lines()
971 .rev()
972 .find(|line| !line.trim().is_empty())
973 .is_some_and(|header| header.contains(": |") || header.contains(": >"))
974}
975
976fn yaml_node_span(source_id: SourceId, node: &YamlNode) -> SourceSpan {
977 let Some(syntax) = node.as_node() else {
978 return SourceSpan::from_valid_offsets(source_id, 0, 0);
979 };
980 let range = syntax.text_range();
981 SourceSpan::from_valid_offsets(
982 source_id,
983 u32::from(range.start()) as usize,
984 u32::from(range.end()) as usize,
985 )
986}
987
988fn position_span(source_id: SourceId, position: yaml_edit::TextPosition) -> SourceSpan {
989 SourceSpan::from_valid_offsets(source_id, position.start as usize, position.end as usize)
990}
991
992fn collect_scalar(source_id: SourceId, source: &str, scalar: &Scalar, values: &mut Vec<ValueScalar>) {
993 let raw = scalar_raw_from_source(source, scalar);
994 let eligible_style = !raw.starts_with('\'') && !raw.starts_with('|') && !raw.starts_with('>');
995 if !eligible_style || !raw.contains('$') {
996 return;
997 }
998 let position = scalar.byte_range();
999 values.push(ValueScalar {
1000 value: scalar_string_from_source(source, scalar),
1001 span: SourceSpan::from_valid_offsets(source_id, position.start as usize, position.end as usize),
1002 });
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007 use super::{MergeSyntaxScalar, MergeSyntaxValue, SyntaxDocument, parser_compatible_source, unparsed_input_offset};
1008 use crate::source::SourceId;
1009 use yaml_edit::YamlFile;
1010
1011 fn assert_send_and_sync<T: Send + Sync>() {}
1012
1013 #[test]
1014 fn syntax_documents_are_send_and_sync() {
1015 assert_send_and_sync::<SyntaxDocument>();
1016 }
1017
1018 #[test]
1019 fn parsing_never_reads_the_process_environment() -> Result<(), Box<dyn std::error::Error>> {
1020 let source = "services:\n app:\n image: ${COMPOSE_LENS_SECRET}\n";
1021 let parsed = SyntaxDocument::parse(SourceId::new(1), source)?;
1022
1023 assert_eq!(parsed.document().render_preserved(), source);
1024 assert!(parsed.is_valid());
1025 Ok(())
1026 }
1027
1028 #[test]
1029 fn complete_root_guard_detects_the_private_backends_raw_comma_omission() {
1030 let source = "services:\n app:\n volumes:\n - ./data:/data:Z,ro\n later:\n image: later\n";
1031 let backend = YamlFile::parse(source);
1032
1033 assert!(backend.positioned_errors().is_empty());
1034 assert!(unparsed_input_offset(&backend, source).is_some());
1035 }
1036
1037 #[test]
1038 fn anchor_compatibility_does_not_merge_colliding_names() {
1039 let source = "first: &shared-name one\nsecond: &shared_name two\n";
1040
1041 assert_eq!(parser_compatible_source(source), None);
1042 }
1043
1044 #[test]
1045 fn anchor_compatibility_ignores_scalar_and_comment_content() {
1046 let source = "quoted: \"*not-an-alias\"\nplain: echo ¬-an-anchor\n# *also-not-an-alias\n";
1047
1048 assert_eq!(parser_compatible_source(source), None);
1049 }
1050
1051 #[test]
1052 fn merge_scalars_retain_strict_yaml_string_identity() -> Result<(), Box<dyn std::error::Error>> {
1053 let parsed = SyntaxDocument::parse(
1054 SourceId::new(2),
1055 "plain: gpu\ntimestamp: !!timestamp 2023-12-25\nregex: !!regex 'gpu.*'\nquoted-timestamp: \"2023-12-25\"\nquoted-regex: \"gpu.*\"\n",
1056 )?;
1057 let root = parsed.document().merge_root().ok_or("merge root")?;
1058 let scalar = |name| -> Option<&MergeSyntaxScalar> {
1059 let MergeSyntaxValue::Mapping { entries, .. } = &root else {
1060 return None;
1061 };
1062 let value = &entries.iter().find(|entry| entry.key.value == name)?.value;
1063 match value {
1064 MergeSyntaxValue::Scalar(scalar) => Some(scalar),
1065 MergeSyntaxValue::Tagged { value, .. } => match value.as_ref() {
1066 MergeSyntaxValue::Scalar(scalar) => Some(scalar),
1067 _ => None,
1068 },
1069 _ => None,
1070 }
1071 };
1072 assert!(scalar("plain").is_some_and(|value| value.strict_yaml_string));
1073 for name in ["timestamp", "regex"] {
1074 assert!(
1075 scalar(name).is_some_and(|value| !value.strict_yaml_string),
1076 "{name}: {:?}",
1077 scalar(name)
1078 );
1079 }
1080 for name in ["quoted-timestamp", "quoted-regex"] {
1081 assert!(scalar(name).is_some_and(|value| value.strict_yaml_string));
1082 }
1083 Ok(())
1084 }
1085}