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, ScalarType, ScalarValue,
11 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) span: SourceSpan,
214}
215
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub(crate) struct MergeSyntaxEntry {
218 pub(crate) key: MergeSyntaxScalar,
219 pub(crate) value: MergeSyntaxValue,
220}
221
222#[derive(Debug, Clone, PartialEq, Eq)]
223pub(crate) enum MergeSyntaxValue {
224 Empty(SourceSpan),
225 Scalar(MergeSyntaxScalar),
226 Mapping {
227 entries: Vec<MergeSyntaxEntry>,
228 span: SourceSpan,
229 },
230 Sequence {
231 values: Vec<MergeSyntaxValue>,
232 span: SourceSpan,
233 },
234 Alias {
235 name: String,
236 span: SourceSpan,
237 },
238 Tagged {
239 tag: String,
240 value: Box<MergeSyntaxValue>,
241 span: SourceSpan,
242 },
243}
244
245#[derive(Debug, Clone, PartialEq, Eq)]
247pub struct SyntaxParse {
248 document: SyntaxDocument,
249 diagnostics: Vec<Diagnostic>,
250}
251
252impl SyntaxParse {
253 #[must_use]
255 pub const fn document(&self) -> &SyntaxDocument {
256 &self.document
257 }
258
259 #[must_use]
261 pub fn diagnostics(&self) -> &[Diagnostic] {
262 &self.diagnostics
263 }
264
265 #[must_use]
267 pub fn is_valid(&self) -> bool {
268 !self
269 .diagnostics
270 .iter()
271 .any(|diagnostic| diagnostic.severity() == Severity::Error)
272 }
273
274 #[must_use]
276 pub fn into_parts(self) -> (SyntaxDocument, Vec<Diagnostic>) {
277 (self.document, self.diagnostics)
278 }
279}
280
281#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub struct SyntaxParseError {
284 source_id: SourceId,
285 byte_len: usize,
286}
287
288impl SyntaxParseError {
289 #[must_use]
291 pub const fn source_id(self) -> SourceId {
292 self.source_id
293 }
294
295 #[must_use]
297 pub const fn byte_len(self) -> usize {
298 self.byte_len
299 }
300}
301
302impl fmt::Display for SyntaxParseError {
303 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
304 write!(
305 formatter,
306 "{} contains {} bytes, exceeding the YAML syntax tree limit",
307 self.source_id, self.byte_len
308 )
309 }
310}
311
312impl Error for SyntaxParseError {}
313
314fn syntax_diagnostic(source_id: SourceId, source_len: usize, error: &yaml_edit::PositionedParseError) -> Diagnostic {
315 let (code, message) = match error.kind {
316 ParseErrorKind::UnclosedFlowSequence => (YAML_UNCLOSED_FLOW_SEQUENCE, "flow sequence is missing a closing `]`"),
317 ParseErrorKind::UnclosedFlowMapping => (YAML_UNCLOSED_FLOW_MAPPING, "flow mapping is missing a closing `}`"),
318 ParseErrorKind::UnterminatedString => (YAML_UNTERMINATED_STRING, "quoted scalar is missing its closing quote"),
319 ParseErrorKind::Other => (YAML_SYNTAX_ERROR, "invalid YAML syntax"),
320 };
321 let start = (error.range.start as usize).min(source_len);
322 let end = (error.range.end as usize).clamp(start, source_len);
323 let span = SourceSpan::from_valid_offsets(source_id, start, end);
324
325 Diagnostic::new(code, Severity::Error, message).with_label(DiagnosticLabel::primary(span, "syntax error"))
326}
327
328fn unparsed_input_diagnostic(source_id: SourceId, source_len: usize, document_end: usize) -> Diagnostic {
329 let span = SourceSpan::from_valid_offsets(source_id, document_end.min(source_len), source_len);
330
331 Diagnostic::new(
332 YAML_UNPARSED_INPUT,
333 Severity::Error,
334 "YAML parser did not include the complete input in the document root",
335 )
336 .with_label(DiagnosticLabel::primary(span, "input omitted from YAML document"))
337 .with_note("the original source remains available, but typed processing must not continue silently")
338}
339
340fn unparsed_input_offset(parse: &Parse<YamlFile>, source: &str) -> Option<usize> {
341 let document_end = parse
342 .tree()
343 .document()
344 .and_then(|document| {
345 document
346 .as_node()
347 .map(|node| u32::from(node.text_range().end()) as usize)
348 })
349 .unwrap_or_default();
350 source
351 .as_bytes()
352 .get(document_end..)
353 .is_some_and(|suffix| suffix.iter().any(|byte| !byte.is_ascii_whitespace()))
354 .then_some(document_end)
355}
356
357fn parser_compatible_source(source: &str) -> Option<String> {
362 let mut compatible = source.as_bytes().to_vec();
363 let mut changed = mask_blank_lines_after_mapping_keys(source, &mut compatible);
364 changed |= mask_non_colliding_anchor_hyphens(source, &mut compatible);
365 let mut offset = 0;
366 let mut block_scalar_indent = None;
367 let mut state = ParserCompatibilityState::default();
368
369 for line in source.split_inclusive('\n') {
370 let content = line.trim_end_matches(['\r', '\n']);
371 let indent = content.bytes().take_while(|byte| *byte == b' ').count();
372 let blank = content[indent..].trim().is_empty();
373
374 if let Some(header_indent) = block_scalar_indent {
375 if blank || indent > header_indent {
376 offset += line.len();
377 continue;
378 }
379 block_scalar_indent = None;
380 }
381
382 let (line_changed, starts_block_scalar) = mask_block_plain_commas(content, offset, &mut compatible, &mut state);
383 changed |= line_changed;
384 if starts_block_scalar {
385 block_scalar_indent = Some(indent);
386 }
387 offset += line.len();
388 }
389
390 if changed {
391 String::from_utf8(compatible).ok()
392 } else {
393 None
394 }
395}
396
397fn mask_blank_lines_after_mapping_keys(source: &str, compatible: &mut [u8]) -> bool {
398 let bytes = source.as_bytes();
399 let mut line_starts = vec![0];
400 line_starts.extend(
401 bytes
402 .iter()
403 .enumerate()
404 .filter_map(|(index, byte)| (*byte == b'\n').then_some(index + 1)),
405 );
406 let mut changed = false;
407
408 for line_index in 0..line_starts.len().saturating_sub(2) {
409 let start = line_starts[line_index];
410 let end = line_starts.get(line_index + 1).copied().unwrap_or(bytes.len());
411 let content_end = line_content_end(bytes, start, end);
412 let content = &source[start..content_end];
413 let trimmed = content.trim();
414 if trimmed.starts_with('#') || !trimmed.ends_with(':') {
415 continue;
416 }
417
418 let mut next_line = line_index + 1;
419 while next_line < line_starts.len() {
420 let next_start = line_starts[next_line];
421 let next_end = line_starts.get(next_line + 1).copied().unwrap_or(bytes.len());
422 let next_content_end = line_content_end(bytes, next_start, next_end);
423 if !source[next_start..next_content_end].trim().is_empty() {
424 break;
425 }
426 next_line += 1;
427 }
428 if next_line == line_index + 1 || next_line >= line_starts.len() {
429 continue;
430 }
431
432 let key_indent = content.bytes().take_while(|byte| *byte == b' ').count();
433 let value_start = line_starts[next_line];
434 let value_indent = source[value_start..].bytes().take_while(|byte| *byte == b' ').count();
435 if value_indent <= key_indent {
436 continue;
437 }
438
439 let final_blank_start = line_starts[next_line - 1];
442 compatible[content_end..final_blank_start].fill(b' ');
443 changed = true;
444 }
445 changed
446}
447
448fn line_content_end(bytes: &[u8], start: usize, end: usize) -> usize {
449 let mut content_end = end;
450 if content_end > start && bytes[content_end - 1] == b'\n' {
451 content_end -= 1;
452 }
453 if content_end > start && bytes[content_end - 1] == b'\r' {
454 content_end -= 1;
455 }
456 content_end
457}
458
459fn mask_non_colliding_anchor_hyphens(source: &str, compatible: &mut [u8]) -> bool {
460 let bytes = source.as_bytes();
461 let mut occurrences = Vec::new();
462 let mut originals_by_normalized = BTreeMap::<Vec<u8>, BTreeSet<Vec<u8>>>::new();
463 let mut index = 0;
464
465 while index < bytes.len() {
466 if !matches!(bytes[index], b'&' | b'*') {
467 index += 1;
468 continue;
469 }
470 let line_start = bytes[..index]
471 .iter()
472 .rposition(|byte| *byte == b'\n')
473 .map_or(0, |position| position + 1);
474 let preceding = bytes[line_start..index]
475 .iter()
476 .rfind(|byte| !byte.is_ascii_whitespace())
477 .copied();
478 if preceding.is_some_and(|byte| !matches!(byte, b':' | b'-' | b'[' | b'{' | b',' | b'?')) {
479 index += 1;
480 continue;
481 }
482 let start = index + 1;
483 let mut end = start;
484 while bytes.get(end).is_some_and(|byte| !anchor_name_delimiter(*byte)) {
485 end += 1;
486 }
487 if start == end {
488 index += 1;
489 continue;
490 }
491
492 let original = bytes[start..end].to_vec();
493 let normalized = original
494 .iter()
495 .map(|byte| if *byte == b'-' { b'_' } else { *byte })
496 .collect::<Vec<_>>();
497 originals_by_normalized
498 .entry(normalized.clone())
499 .or_default()
500 .insert(original.clone());
501 occurrences.push((start, end, original, normalized));
502 index = end;
503 }
504
505 let mut changed = false;
506 for (start, end, original, normalized) in occurrences {
507 if !original.contains(&b'-')
508 || originals_by_normalized
509 .get(&normalized)
510 .is_none_or(|originals| originals.len() != 1)
511 {
512 continue;
513 }
514 for byte in &mut compatible[start..end] {
515 if *byte == b'-' {
516 *byte = b'_';
517 changed = true;
518 }
519 }
520 }
521 changed
522}
523
524const fn anchor_name_delimiter(byte: u8) -> bool {
525 byte.is_ascii_whitespace() || matches!(byte, b'[' | b']' | b'{' | b'}' | b',')
526}
527
528#[derive(Debug, Default)]
529struct ParserCompatibilityState {
530 flow_depth: u32,
531 quote: Option<u8>,
532 escaped: bool,
533}
534
535fn mask_block_plain_commas(
536 content: &str,
537 offset: usize,
538 compatible: &mut [u8],
539 state: &mut ParserCompatibilityState,
540) -> (bool, bool) {
541 let bytes = content.as_bytes();
542 let mut index = 0;
543 let mut first_token = true;
544 let mut plain_started = false;
545 let mut eligible_plain_value = false;
546 let mut changed = false;
547
548 while index < bytes.len() {
549 let byte = bytes[index];
550
551 if let Some(delimiter) = state.quote {
552 if delimiter == b'"' && state.escaped {
553 state.escaped = false;
554 } else if delimiter == b'"' && byte == b'\\' {
555 state.escaped = true;
556 } else if byte == delimiter {
557 if delimiter == b'\'' && bytes.get(index + 1) == Some(&b'\'') {
558 index += 1;
559 } else {
560 state.quote = None;
561 }
562 }
563 index += 1;
564 continue;
565 }
566
567 if byte == b'#' && (index == 0 || bytes[index - 1].is_ascii_whitespace()) {
568 break;
569 }
570
571 if eligible_plain_value && !plain_started && matches!(byte, b'!' | b'&') {
572 index += 1;
573 while bytes.get(index).is_some_and(|byte| !byte.is_ascii_whitespace()) {
574 index += 1;
575 }
576 first_token = false;
577 continue;
578 }
579
580 if matches!(byte, b'\'' | b'"') && !plain_started {
581 state.quote = Some(byte);
582 first_token = false;
583 index += 1;
584 continue;
585 }
586
587 if state.flow_depth > 0 {
588 match byte {
589 b'[' | b'{' => state.flow_depth += 1,
590 b']' | b'}' => state.flow_depth -= 1,
591 _ => {}
592 }
593 index += 1;
594 continue;
595 }
596
597 let token_boundary = index == 0 || bytes[index - 1].is_ascii_whitespace();
598 if matches!(byte, b'[' | b'{') && (!plain_started || (eligible_plain_value && token_boundary)) {
599 state.flow_depth = 1;
600 first_token = false;
601 index += 1;
602 continue;
603 }
604
605 if byte.is_ascii_whitespace() {
606 index += 1;
607 continue;
608 }
609
610 if first_token && byte == b'-' && bytes.get(index + 1).is_none_or(u8::is_ascii_whitespace) {
611 eligible_plain_value = true;
612 plain_started = false;
613 first_token = false;
614 index += 1;
615 continue;
616 }
617
618 if byte == b':' && bytes.get(index + 1).is_none_or(u8::is_ascii_whitespace) {
619 eligible_plain_value = true;
620 plain_started = false;
621 first_token = false;
622 index += 1;
623 continue;
624 }
625
626 if eligible_plain_value && !plain_started && matches!(byte, b'|' | b'>') {
627 return (changed, true);
628 }
629
630 if eligible_plain_value && !plain_started && byte == b'-' && bytes.get(index + 1) == Some(&b'-') {
634 compatible[offset + index] = b'_';
635 changed = true;
636 }
637
638 if eligible_plain_value && plain_started && byte == b',' {
639 compatible[offset + index] = b'_';
640 changed = true;
641 }
642
643 plain_started = true;
644 first_token = false;
645 index += 1;
646 }
647
648 state.escaped = false;
649 (changed, false)
650}
651
652pub(crate) fn scalar_raw_from_source(source: &str, scalar: &Scalar) -> String {
653 let range = scalar.byte_range();
654 source
655 .get(range.start as usize..range.end as usize)
656 .map_or_else(|| scalar.value(), str::to_owned)
657}
658
659pub(crate) fn scalar_string_from_source(source: &str, scalar: &Scalar) -> String {
660 let authored = scalar_raw_from_source(source, scalar);
661 if authored == scalar.value() {
662 scalar.as_string()
663 } else {
664 authored
666 }
667}
668
669fn collect_value_scalars(source_id: SourceId, source: &str, node: YamlNode, values: &mut Vec<ValueScalar>) {
670 match node {
671 YamlNode::Scalar(scalar) => collect_scalar(source_id, source, &scalar, values),
672 YamlNode::Mapping(mapping) => {
673 for value in mapping.entries().filter_map(|entry| entry.value_node()) {
674 collect_value_scalars(source_id, source, value, values);
675 }
676 }
677 YamlNode::Sequence(sequence) => {
678 for value in sequence.values() {
679 collect_value_scalars(source_id, source, value, values);
680 }
681 }
682 YamlNode::TaggedNode(tagged) => {
683 if let Some(node) = tagged
684 .as_node()
685 .and_then(|node| node.children().find_map(YamlNode::from_syntax))
686 {
687 collect_value_scalars(source_id, source, node, values);
688 }
689 }
690 YamlNode::Alias(_) => {}
691 }
692}
693
694fn collect_editable_value_scalars(
695 source_id: SourceId,
696 source: &str,
697 node: YamlNode,
698 values: &mut Vec<EditableValueScalar>,
699) {
700 match node {
701 YamlNode::Scalar(scalar) => {
702 values.push(EditableValueScalar {
703 raw: scalar_raw_from_source(source, &scalar),
704 span: position_span(source_id, scalar.byte_range()),
705 });
706 }
707 YamlNode::Mapping(mapping) => {
708 for value in mapping.entries().filter_map(|entry| entry.value_node()) {
709 collect_editable_value_scalars(source_id, source, value, values);
710 }
711 }
712 YamlNode::Sequence(sequence) => {
713 for value in sequence.values() {
714 collect_editable_value_scalars(source_id, source, value, values);
715 }
716 }
717 YamlNode::TaggedNode(tagged) => {
718 if let Some(node) = tagged
719 .as_node()
720 .and_then(|node| node.children().find_map(YamlNode::from_syntax))
721 {
722 collect_editable_value_scalars(source_id, source, node, values);
723 }
724 }
725 YamlNode::Alias(_) => {}
726 }
727}
728
729fn extract_merge_value(
730 source_id: SourceId,
731 source: &str,
732 node: YamlNode,
733 registry: &AnchorRegistry,
734 aliases: &mut Vec<String>,
735) -> MergeSyntaxValue {
736 match node {
737 YamlNode::Scalar(scalar) => MergeSyntaxValue::Scalar(extract_merge_scalar(source_id, source, &scalar)),
738 YamlNode::Mapping(mapping) => extract_merge_mapping(source_id, source, &mapping, registry, aliases),
739 YamlNode::Sequence(sequence) => {
740 let span = position_span(source_id, sequence.byte_range());
741 let values = sequence
742 .values()
743 .map(|value| extract_merge_value(source_id, source, value, registry, aliases))
744 .collect();
745 MergeSyntaxValue::Sequence { values, span }
746 }
747 YamlNode::Alias(alias) => {
748 let name = alias.name();
749 let span = yaml_node_span(source_id, &YamlNode::Alias(alias.clone()));
750 if aliases.contains(&name) || aliases.len() >= 64 {
751 return MergeSyntaxValue::Alias {
752 name: original_alias_name(source, span).unwrap_or(name),
753 span,
754 };
755 }
756 if let Some(target) = registry.resolve(&name).and_then(|node| {
757 YamlNode::from_syntax(node.clone()).or_else(|| node.children().find_map(YamlNode::from_syntax))
758 }) {
759 aliases.push(name);
760 let value = extract_merge_value(source_id, source, target, registry, aliases);
761 let _ = aliases.pop();
762 value
763 } else {
764 MergeSyntaxValue::Alias {
765 name: original_alias_name(source, span).unwrap_or(name),
766 span,
767 }
768 }
769 }
770 YamlNode::TaggedNode(tagged) => {
771 let span = yaml_node_span(source_id, &YamlNode::TaggedNode(tagged.clone()));
772 let value = tagged
773 .as_node()
774 .and_then(|node| node.children().find_map(YamlNode::from_syntax))
775 .map_or_else(
776 || MergeSyntaxValue::Empty(span),
777 |value| extract_merge_value(source_id, source, value, registry, aliases),
778 );
779 MergeSyntaxValue::Tagged {
780 tag: tagged.tag().unwrap_or_default(),
781 value: Box::new(value),
782 span,
783 }
784 }
785 }
786}
787
788fn original_alias_name(source: &str, span: SourceSpan) -> Option<String> {
789 source.get(span.range())?.strip_prefix('*').map(ToOwned::to_owned)
790}
791
792fn extract_merge_mapping(
793 source_id: SourceId,
794 source: &str,
795 mapping: &Mapping,
796 registry: &AnchorRegistry,
797 aliases: &mut Vec<String>,
798) -> MergeSyntaxValue {
799 let span = position_span(source_id, mapping.byte_range());
800 let direct = flatten_merge_fields(source_id, source, raw_merge_fields(source_id, source, mapping));
801 let mut entries = Vec::new();
802 let mut direct_keys = Vec::new();
803
804 for field in direct {
805 if field.key.value == "<<" {
806 continue;
807 }
808 direct_keys.push(field.key.value.clone());
809 let value = field.value.map_or_else(
810 || {
811 MergeSyntaxValue::Empty(SourceSpan::from_valid_offsets(
812 source_id,
813 field.key.span.end(),
814 field.key.span.end(),
815 ))
816 },
817 |value| extract_merge_value(source_id, source, resolve_alias(value, registry), registry, aliases),
818 );
819 entries.push(MergeSyntaxEntry { key: field.key, value });
820 }
821
822 for (key, value) in mapping.merged(registry).iter() {
823 let Some(key) = key
824 .as_scalar()
825 .map(|scalar| extract_merge_scalar(source_id, source, scalar))
826 else {
827 continue;
828 };
829 if direct_keys.contains(&key.value) {
830 continue;
831 }
832 entries.push(MergeSyntaxEntry {
833 key,
834 value: extract_merge_value(source_id, source, value, registry, aliases),
835 });
836 }
837
838 MergeSyntaxValue::Mapping { entries, span }
839}
840
841#[derive(Debug)]
842struct RawMergeField {
843 key: MergeSyntaxScalar,
844 value: Option<YamlNode>,
845}
846
847fn raw_merge_fields(source_id: SourceId, source: &str, mapping: &Mapping) -> Vec<RawMergeField> {
848 mapping
849 .entries()
850 .filter_map(|entry| {
851 let key = entry.key_node()?.as_scalar().cloned()?;
852 Some(RawMergeField {
853 key: extract_merge_scalar(source_id, source, &key),
854 value: entry.value_node(),
855 })
856 })
857 .collect()
858}
859
860fn flatten_merge_fields(source_id: SourceId, source: &str, fields: Vec<RawMergeField>) -> Vec<RawMergeField> {
861 let Some(target_column) = fields
862 .first()
863 .map(|field| source_column(source, field.key.span.start()))
864 else {
865 return fields;
866 };
867 recover_merge_fields(source_id, source, fields, target_column)
868}
869
870fn recover_merge_fields(
871 source_id: SourceId,
872 source: &str,
873 fields: Vec<RawMergeField>,
874 target_column: usize,
875) -> Vec<RawMergeField> {
876 let mut flattened = Vec::new();
877 for mut field in fields {
878 let field_column = source_column(source, field.key.span.start());
879 let nested_mapping = field.value.as_ref().and_then(YamlNode::as_mapping).cloned();
880 let continuation = nested_mapping.as_ref().is_some_and(|mapping| {
881 !is_flow_mapping(source, mapping)
882 && mapping
883 .entries()
884 .find_map(|entry| entry.key_node()?.as_scalar().map(Scalar::byte_range))
885 .is_some_and(|position| source_column(source, position.start as usize) <= field_column)
886 });
887 if continuation {
888 field.value = None;
889 }
890 if field_column == target_column {
891 flattened.push(field);
892 }
893 if let Some(mapping) = nested_mapping.filter(|mapping| !is_flow_mapping(source, mapping)) {
894 let nested = raw_merge_fields(source_id, source, &mapping);
895 flattened.extend(recover_merge_fields(source_id, source, nested, target_column));
896 }
897 }
898 flattened
899}
900
901fn is_flow_mapping(source: &str, mapping: &Mapping) -> bool {
902 let position = mapping.byte_range();
903 source
904 .get(position.start as usize..position.end as usize)
905 .is_some_and(|text| text.trim_start().starts_with('{'))
906}
907
908fn source_column(source: &str, offset: usize) -> usize {
909 let prefix = &source[..offset.min(source.len())];
910 let line_start = prefix.rfind('\n').map_or(0, |index| index + 1);
911 source[line_start..offset.min(source.len())].chars().count()
912}
913
914fn resolve_alias(node: YamlNode, registry: &AnchorRegistry) -> YamlNode {
915 let YamlNode::Alias(alias) = &node else {
916 return node;
917 };
918 registry
919 .resolve(&alias.name())
920 .and_then(|target| {
921 YamlNode::from_syntax(target.clone()).or_else(|| target.children().find_map(YamlNode::from_syntax))
922 })
923 .unwrap_or(node)
924}
925
926fn extract_merge_scalar(source_id: SourceId, source: &str, scalar: &Scalar) -> MergeSyntaxScalar {
927 let kind = match ScalarValue::from_scalar(scalar).scalar_type() {
928 ScalarType::Boolean => MergeScalarKind::Boolean,
929 ScalarType::Integer | ScalarType::Float => MergeScalarKind::Number,
930 ScalarType::Null => MergeScalarKind::Null,
931 ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => MergeScalarKind::String,
932 };
933 MergeSyntaxScalar {
934 raw: scalar_raw_from_source(source, scalar),
935 value: scalar_string_from_source(source, scalar),
936 kind,
937 span: position_span(source_id, scalar.byte_range()),
938 }
939}
940
941fn yaml_node_span(source_id: SourceId, node: &YamlNode) -> SourceSpan {
942 let Some(syntax) = node.as_node() else {
943 return SourceSpan::from_valid_offsets(source_id, 0, 0);
944 };
945 let range = syntax.text_range();
946 SourceSpan::from_valid_offsets(
947 source_id,
948 u32::from(range.start()) as usize,
949 u32::from(range.end()) as usize,
950 )
951}
952
953fn position_span(source_id: SourceId, position: yaml_edit::TextPosition) -> SourceSpan {
954 SourceSpan::from_valid_offsets(source_id, position.start as usize, position.end as usize)
955}
956
957fn collect_scalar(source_id: SourceId, source: &str, scalar: &Scalar, values: &mut Vec<ValueScalar>) {
958 let raw = scalar_raw_from_source(source, scalar);
959 let eligible_style = !raw.starts_with('\'') && !raw.starts_with('|') && !raw.starts_with('>');
960 if !eligible_style || !raw.contains('$') {
961 return;
962 }
963 let position = scalar.byte_range();
964 values.push(ValueScalar {
965 value: scalar_string_from_source(source, scalar),
966 span: SourceSpan::from_valid_offsets(source_id, position.start as usize, position.end as usize),
967 });
968}
969
970#[cfg(test)]
971mod tests {
972 use super::{SyntaxDocument, parser_compatible_source, unparsed_input_offset};
973 use crate::source::SourceId;
974 use yaml_edit::YamlFile;
975
976 fn assert_send_and_sync<T: Send + Sync>() {}
977
978 #[test]
979 fn syntax_documents_are_send_and_sync() {
980 assert_send_and_sync::<SyntaxDocument>();
981 }
982
983 #[test]
984 fn parsing_never_reads_the_process_environment() -> Result<(), Box<dyn std::error::Error>> {
985 let source = "services:\n app:\n image: ${COMPOSE_LENS_SECRET}\n";
986 let parsed = SyntaxDocument::parse(SourceId::new(1), source)?;
987
988 assert_eq!(parsed.document().render_preserved(), source);
989 assert!(parsed.is_valid());
990 Ok(())
991 }
992
993 #[test]
994 fn complete_root_guard_detects_the_private_backends_raw_comma_omission() {
995 let source = "services:\n app:\n volumes:\n - ./data:/data:Z,ro\n later:\n image: later\n";
996 let backend = YamlFile::parse(source);
997
998 assert!(backend.positioned_errors().is_empty());
999 assert!(unparsed_input_offset(&backend, source).is_some());
1000 }
1001
1002 #[test]
1003 fn anchor_compatibility_does_not_merge_colliding_names() {
1004 let source = "first: &shared-name one\nsecond: &shared_name two\n";
1005
1006 assert_eq!(parser_compatible_source(source), None);
1007 }
1008
1009 #[test]
1010 fn anchor_compatibility_ignores_scalar_and_comment_content() {
1011 let source = "quoted: \"*not-an-alias\"\nplain: echo ¬-an-anchor\n# *also-not-an-alias\n";
1012
1013 assert_eq!(parser_compatible_source(source), None);
1014 }
1015}