Skip to main content

compose_lens/model/
mod.rs

1//! Source-aware native Compose document types.
2
3mod command;
4mod dependency;
5mod environment;
6mod host;
7mod identity;
8mod image;
9mod network;
10mod port;
11mod resource;
12mod sections;
13mod ulimit;
14mod value;
15mod volume;
16
17pub use command::Command;
18pub use dependency::{
19    DependencyCondition, DependsOn, Healthcheck, HealthcheckDuration, HealthcheckRetries, HealthcheckTest,
20    HealthcheckTestKind, ServiceDependency,
21};
22pub use environment::{Environment, EnvironmentListEntry, EnvironmentMapEntry};
23pub use host::{ExtraHostSeparator, ExtraHosts, HostAddress, HostAddressKind, LongExtraHost, ShortExtraHost};
24pub use identity::{IdentityComponent, UserNamespaceMode, UserNamespaceModeKind, UserSpec};
25pub use image::{ImageDigest, ImageReference};
26pub use network::{Ipam, IpamConfig, NetworkDefinition, ServiceNetwork, ServiceNetworks};
27pub use port::{LongPort, Port, ShortPort};
28pub use resource::{ConfigDefinition, ConfigGrant, LongGrant, SecretDefinition, SecretGrant, VolumeDefinition};
29pub use sections::{
30    Build, BuildDefinition, BuildField, BuildFieldKind, DeployDefinition, DeployField, DeployFieldKind,
31};
32pub use ulimit::{LimitValue, Ulimit, UlimitRange, UlimitValue, Ulimits};
33pub use value::{BooleanValue, ComposeScalar, KeyValueEntry, Labels};
34pub use volume::{
35    BindOptions, ContainerPath, ContainerPathKind, LongVolumeMount, MountType, SelinuxRelabel, ShortVolumeMount,
36    VolumeMount, VolumeSyntax,
37};
38
39use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
40use crate::source::{SourceId, SourceSpan};
41use crate::syntax::{SyntaxDocument, scalar_string_from_source};
42use std::collections::BTreeMap;
43use yaml_edit::{AsYaml, Mapping, ScalarType, ScalarValue, YamlNode};
44
45/// A Compose document root must be a mapping.
46pub const DOCUMENT_ROOT_TYPE: DiagnosticCode = DiagnosticCode::new("compose.document.expected-mapping");
47
48/// `ComposeLens` currently types the first document in a multi-document YAML stream.
49pub const MULTIPLE_DOCUMENTS: DiagnosticCode = DiagnosticCode::new("compose.document.multiple-documents");
50
51/// A mapping contains a duplicate field.
52pub const DUPLICATE_FIELD: DiagnosticCode = DiagnosticCode::new("compose.model.duplicate-field");
53
54/// A Compose value has to be a mapping at this location.
55pub const EXPECTED_MAPPING: DiagnosticCode = DiagnosticCode::new("compose.model.expected-mapping");
56
57/// A Compose value has to be a sequence at this location.
58pub const EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.model.expected-sequence");
59
60/// A Compose value has to be a scalar at this location.
61pub const EXPECTED_SCALAR: DiagnosticCode = DiagnosticCode::new("compose.model.expected-scalar");
62
63/// A Compose value has to be a boolean at this location.
64pub const EXPECTED_BOOLEAN: DiagnosticCode = DiagnosticCode::new("compose.model.expected-boolean");
65
66/// A field supports multiple Compose syntax forms, but the authored form is invalid here.
67pub const EXPECTED_FIELD_FORM: DiagnosticCode = DiagnosticCode::new("compose.model.expected-field-form");
68
69/// A service port is neither scalar short syntax nor mapping long syntax.
70pub const PORT_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.port.expected-short-or-long");
71
72/// A long-syntax service port is missing `target`.
73pub const PORT_MISSING_TARGET: DiagnosticCode = DiagnosticCode::new("compose.port.long.missing-target");
74
75/// A service config or secret grant is neither scalar short syntax nor mapping long syntax.
76pub const GRANT_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.grant.expected-short-or-long");
77
78/// A long-syntax service config or secret grant is missing `source`.
79pub const GRANT_MISSING_SOURCE: DiagnosticCode = DiagnosticCode::new("compose.grant.long.missing-source");
80
81/// A top-level resource definition must be a mapping or an explicit null.
82pub const RESOURCE_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.resource.expected-mapping-or-null");
83
84/// A service-volume item is neither short nor long syntax.
85pub const VOLUME_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.volume.expected-short-or-long");
86
87/// A long-syntax service volume is missing `type`.
88pub const VOLUME_MISSING_TYPE: DiagnosticCode = DiagnosticCode::new("compose.volume.long.missing-type");
89
90/// A long-syntax service volume is missing `target`.
91pub const VOLUME_MISSING_TARGET: DiagnosticCode = DiagnosticCode::new("compose.volume.long.missing-target");
92
93/// A long-syntax bind mount has an invalid `SELinux` value.
94pub const VOLUME_INVALID_SELINUX: DiagnosticCode = DiagnosticCode::new("compose.volume.bind.invalid-selinux");
95
96/// A short `extra_hosts` entry does not contain a hostname/address separator.
97pub const EXTRA_HOST_INVALID_ENTRY: DiagnosticCode = DiagnosticCode::new("compose.extra-hosts.invalid-entry");
98
99/// A service limit is neither unlimited, a non-negative integer, nor deferred.
100pub const ULIMIT_INVALID_VALUE: DiagnosticCode = DiagnosticCode::new("compose.ulimits.invalid-value");
101
102/// A health-check list has no valid command-mode token.
103pub const HEALTHCHECK_INVALID_TEST: DiagnosticCode = DiagnosticCode::new("compose.healthcheck.invalid-test");
104
105/// A health-check duration does not follow Compose duration syntax.
106pub const HEALTHCHECK_INVALID_DURATION: DiagnosticCode = DiagnosticCode::new("compose.healthcheck.invalid-duration");
107
108/// A health-check retry count is not a non-negative integer or deferred expression.
109pub const HEALTHCHECK_INVALID_RETRIES: DiagnosticCode = DiagnosticCode::new("compose.healthcheck.invalid-retries");
110
111/// A long dependency uses an unrecognized condition.
112pub const DEPENDENCY_INVALID_CONDITION: DiagnosticCode = DiagnosticCode::new("compose.dependencies.invalid-condition");
113
114/// A typed dependency names a service missing from the same document.
115pub const DEPENDENCY_MISSING_SERVICE: DiagnosticCode = DiagnosticCode::new("compose.dependencies.missing-service");
116
117/// A `service_healthy` dependency has no enabled health check.
118pub const DEPENDENCY_MISSING_HEALTHCHECK: DiagnosticCode =
119    DiagnosticCode::new("compose.dependencies.missing-healthcheck");
120
121/// A `service_healthy` dependency may rely on health metadata from its image.
122pub const DEPENDENCY_HEALTHCHECK_UNVERIFIED: DiagnosticCode =
123    DiagnosticCode::new("compose.dependencies.healthcheck-unverified");
124
125/// A typed value and the exact source span from which it was read.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct Located<T> {
128    value: T,
129    span: SourceSpan,
130}
131
132impl<T> Located<T> {
133    pub(crate) const fn new(value: T, span: SourceSpan) -> Self {
134        Self { value, span }
135    }
136
137    /// Returns the typed value.
138    #[must_use]
139    pub const fn value(&self) -> &T {
140        &self.value
141    }
142
143    /// Returns the value's source span.
144    #[must_use]
145    pub const fn span(&self) -> SourceSpan {
146        self.span
147    }
148
149    /// Removes the source wrapper and returns the typed value.
150    #[must_use]
151    pub fn into_value(self) -> T {
152        self.value
153    }
154}
155
156/// Source provenance for an extension or not-yet-typed field.
157///
158/// The loss-aware [`SyntaxDocument`] retains the actual value and spelling. This reference lets
159/// typed callers locate it without exposing the private YAML implementation.
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct FieldReference {
162    name: Located<String>,
163    span: SourceSpan,
164    value_span: Option<SourceSpan>,
165}
166
167impl FieldReference {
168    /// Returns the semantic field name and its source span.
169    #[must_use]
170    pub const fn name(&self) -> &Located<String> {
171        &self.name
172    }
173
174    /// Returns the span covering the key and value when both are available.
175    #[must_use]
176    pub const fn span(&self) -> SourceSpan {
177        self.span
178    }
179
180    /// Returns the value span when the YAML node exposes one.
181    #[must_use]
182    pub const fn value_span(&self) -> Option<SourceSpan> {
183        self.value_span
184    }
185}
186
187/// A source-aware typed Compose service.
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub struct Service {
190    name: Located<String>,
191    span: SourceSpan,
192    image: Option<Located<ImageReference>>,
193    command: Option<Command>,
194    environment: Option<Environment>,
195    labels: Option<Labels>,
196    extra_hosts: Option<ExtraHosts>,
197    user: Option<UserSpec>,
198    userns_mode: Option<UserNamespaceMode>,
199    group_add: Vec<Located<String>>,
200    working_dir: Option<Located<String>>,
201    read_only: Option<Located<BooleanValue>>,
202    ulimits: Option<Ulimits>,
203    depends_on: Option<DependsOn>,
204    healthcheck: Option<Healthcheck>,
205    build: Option<Build>,
206    deploy: Option<DeployDefinition>,
207    ports: Vec<Port>,
208    volumes: Vec<VolumeMount>,
209    networks: Option<ServiceNetworks>,
210    profiles: Vec<Located<String>>,
211    configs: Vec<ConfigGrant>,
212    secrets: Vec<SecretGrant>,
213    extension_fields: Vec<FieldReference>,
214    unknown_fields: Vec<FieldReference>,
215}
216
217impl Service {
218    fn new(name: Located<String>, span: SourceSpan) -> Self {
219        Self {
220            name,
221            span,
222            image: None,
223            command: None,
224            environment: None,
225            labels: None,
226            extra_hosts: None,
227            user: None,
228            userns_mode: None,
229            group_add: Vec::new(),
230            working_dir: None,
231            read_only: None,
232            ulimits: None,
233            depends_on: None,
234            healthcheck: None,
235            build: None,
236            deploy: None,
237            ports: Vec::new(),
238            volumes: Vec::new(),
239            networks: None,
240            profiles: Vec::new(),
241            configs: Vec::new(),
242            secrets: Vec::new(),
243            extension_fields: Vec::new(),
244            unknown_fields: Vec::new(),
245        }
246    }
247
248    /// Returns the service name.
249    #[must_use]
250    pub const fn name(&self) -> &Located<String> {
251        &self.name
252    }
253
254    /// Returns the complete service definition span.
255    #[must_use]
256    pub const fn span(&self) -> SourceSpan {
257        self.span
258    }
259
260    /// Returns the explicitly authored image reference.
261    #[must_use]
262    pub const fn image(&self) -> Option<&Located<ImageReference>> {
263        self.image.as_ref()
264    }
265
266    /// Returns the command without normalizing its authored form.
267    #[must_use]
268    pub const fn command(&self) -> Option<&Command> {
269        self.command.as_ref()
270    }
271
272    /// Returns environment variables with list and mapping forms kept distinct.
273    #[must_use]
274    pub const fn environment(&self) -> Option<&Environment> {
275        self.environment.as_ref()
276    }
277
278    /// Returns service metadata labels with list and mapping forms kept distinct.
279    #[must_use]
280    pub const fn labels(&self) -> Option<&Labels> {
281        self.labels.as_ref()
282    }
283
284    /// Returns additional host mappings with short and long forms retained.
285    #[must_use]
286    pub const fn extra_hosts(&self) -> Option<&ExtraHosts> {
287        self.extra_hosts.as_ref()
288    }
289
290    /// Returns the raw-preserving container user/group value.
291    #[must_use]
292    pub const fn user(&self) -> Option<&UserSpec> {
293        self.user.as_ref()
294    }
295
296    /// Returns the raw-preserving user-namespace mode.
297    #[must_use]
298    pub const fn userns_mode(&self) -> Option<&UserNamespaceMode> {
299        self.userns_mode.as_ref()
300    }
301
302    /// Returns supplementary groups in authored order without resolving names or IDs.
303    #[must_use]
304    pub fn group_add(&self) -> &[Located<String>] {
305        &self.group_add
306    }
307
308    /// Returns the container working-directory override.
309    #[must_use]
310    pub const fn working_dir(&self) -> Option<&Located<String>> {
311        self.working_dir.as_ref()
312    }
313
314    /// Returns the explicit read-only root-filesystem choice.
315    #[must_use]
316    pub const fn read_only(&self) -> Option<&Located<BooleanValue>> {
317        self.read_only.as_ref()
318    }
319
320    /// Returns explicitly authored service resource limits.
321    #[must_use]
322    pub const fn ulimits(&self) -> Option<&Ulimits> {
323        self.ulimits.as_ref()
324    }
325
326    /// Returns service dependencies with short and long forms retained.
327    #[must_use]
328    pub const fn depends_on(&self) -> Option<&DependsOn> {
329        self.depends_on.as_ref()
330    }
331
332    /// Returns the service health-check definition.
333    #[must_use]
334    pub const fn healthcheck(&self) -> Option<&Healthcheck> {
335        self.healthcheck.as_ref()
336    }
337
338    /// Returns the build declaration with short and long forms retained.
339    #[must_use]
340    pub const fn build(&self) -> Option<&Build> {
341        self.build.as_ref()
342    }
343
344    /// Returns independently classified deploy subfields.
345    #[must_use]
346    pub const fn deploy(&self) -> Option<&DeployDefinition> {
347        self.deploy.as_ref()
348    }
349
350    /// Returns published ports in authored order.
351    #[must_use]
352    pub fn ports(&self) -> &[Port] {
353        &self.ports
354    }
355
356    /// Returns service-volume mounts in authored order.
357    #[must_use]
358    pub fn volumes(&self) -> &[VolumeMount] {
359        &self.volumes
360    }
361
362    /// Returns service network attachments with short and long forms kept distinct.
363    #[must_use]
364    pub const fn networks(&self) -> Option<&ServiceNetworks> {
365        self.networks.as_ref()
366    }
367
368    /// Returns explicitly authored profile names.
369    #[must_use]
370    pub fn profiles(&self) -> &[Located<String>] {
371        &self.profiles
372    }
373
374    /// Returns service config grants in authored order.
375    #[must_use]
376    pub fn configs(&self) -> &[ConfigGrant] {
377        &self.configs
378    }
379
380    /// Returns service secret grants in authored order.
381    #[must_use]
382    pub fn secrets(&self) -> &[SecretGrant] {
383        &self.secrets
384    }
385
386    /// Returns retained service `x-` extension fields.
387    #[must_use]
388    pub fn extension_fields(&self) -> &[FieldReference] {
389        &self.extension_fields
390    }
391
392    /// Returns service fields not yet represented by the typed subset.
393    #[must_use]
394    pub fn unknown_fields(&self) -> &[FieldReference] {
395        &self.unknown_fields
396    }
397}
398
399/// A source-aware native Compose document.
400#[derive(Debug, Clone, PartialEq, Eq)]
401pub struct ComposeDocument {
402    source_id: SourceId,
403    span: SourceSpan,
404    name: Option<Located<String>>,
405    services: Vec<Service>,
406    networks: Vec<NetworkDefinition>,
407    volumes: Vec<VolumeDefinition>,
408    configs: Vec<ConfigDefinition>,
409    secrets: Vec<SecretDefinition>,
410    extension_fields: Vec<FieldReference>,
411    unknown_fields: Vec<FieldReference>,
412}
413
414impl ComposeDocument {
415    /// Extracts the initial typed Compose subset from a loss-aware syntax document.
416    ///
417    /// Parsing does not interpolate values, apply defaults, normalize short and long forms, or
418    /// access the environment. Structural problems produce diagnostics and as much typed data as
419    /// can be recovered.
420    #[must_use]
421    pub fn parse(syntax: &SyntaxDocument) -> ModelParse {
422        Parser::new(syntax).parse()
423    }
424
425    /// Returns the source identifier.
426    #[must_use]
427    pub const fn source_id(&self) -> SourceId {
428        self.source_id
429    }
430
431    /// Returns the typed root mapping span.
432    #[must_use]
433    pub const fn span(&self) -> SourceSpan {
434        self.span
435    }
436
437    /// Returns the explicitly authored project name.
438    #[must_use]
439    pub const fn name(&self) -> Option<&Located<String>> {
440        self.name.as_ref()
441    }
442
443    /// Returns services in authored order.
444    #[must_use]
445    pub fn services(&self) -> &[Service] {
446        &self.services
447    }
448
449    /// Finds the first service with the requested name.
450    #[must_use]
451    pub fn service(&self, name: &str) -> Option<&Service> {
452        self.services.iter().find(|service| service.name.value == name)
453    }
454
455    /// Validates dependency targets and `service_healthy` health-check requirements in this document.
456    ///
457    /// Multi-file callers should validate the merged project view through
458    /// [`crate::resolution::validate_references`] instead.
459    #[must_use]
460    pub fn validate_dependencies(&self) -> Vec<Diagnostic> {
461        let mut diagnostics = Vec::new();
462        for service in &self.services {
463            let Some(depends_on) = service.depends_on() else {
464                continue;
465            };
466            match depends_on {
467                DependsOn::Short { services, .. } => {
468                    for target in services {
469                        if self.service(target.value()).is_none() {
470                            diagnostics.push(missing_dependency_diagnostic(target.span(), false, true));
471                        }
472                    }
473                }
474                DependsOn::Long { services, .. } => {
475                    for dependency in services {
476                        let required = !matches!(
477                            dependency.required().map(Located::value),
478                            Some(BooleanValue::Literal(false))
479                        );
480                        let Some(target) = self.service(dependency.service().value()) else {
481                            diagnostics.push(missing_dependency_diagnostic(
482                                dependency.service().span(),
483                                false,
484                                required,
485                            ));
486                            continue;
487                        };
488                        let needs_healthcheck = matches!(
489                            dependency.condition().map(Located::value),
490                            Some(DependencyCondition::ServiceHealthy)
491                        );
492                        if needs_healthcheck && target.healthcheck().is_none() {
493                            let span = dependency
494                                .condition()
495                                .map_or_else(|| dependency.service().span(), Located::span);
496                            diagnostics.push(unverified_healthcheck_diagnostic(span));
497                        } else if needs_healthcheck && target.healthcheck().is_some_and(Healthcheck::is_disabled) {
498                            let span = dependency
499                                .condition()
500                                .map_or_else(|| dependency.service().span(), Located::span);
501                            diagnostics.push(missing_dependency_diagnostic(span, true, required));
502                        }
503                    }
504                }
505            }
506        }
507        diagnostics
508    }
509
510    /// Returns top-level network definitions in authored order.
511    #[must_use]
512    pub fn networks(&self) -> &[NetworkDefinition] {
513        &self.networks
514    }
515
516    /// Returns top-level volume definitions in authored order.
517    #[must_use]
518    pub fn volumes(&self) -> &[VolumeDefinition] {
519        &self.volumes
520    }
521
522    /// Returns top-level config definitions in authored order.
523    #[must_use]
524    pub fn configs(&self) -> &[ConfigDefinition] {
525        &self.configs
526    }
527
528    /// Returns top-level secret definitions in authored order.
529    #[must_use]
530    pub fn secrets(&self) -> &[SecretDefinition] {
531        &self.secrets
532    }
533
534    /// Returns retained top-level `x-` extension fields.
535    #[must_use]
536    pub fn extension_fields(&self) -> &[FieldReference] {
537        &self.extension_fields
538    }
539
540    /// Returns top-level fields not yet represented by the typed subset.
541    #[must_use]
542    pub fn unknown_fields(&self) -> &[FieldReference] {
543        &self.unknown_fields
544    }
545}
546
547/// A recoverable typed-model parse result.
548#[derive(Debug, Clone, PartialEq, Eq)]
549pub struct ModelParse {
550    document: Option<ComposeDocument>,
551    diagnostics: Vec<Diagnostic>,
552}
553
554impl ModelParse {
555    /// Returns the typed document when the root could be interpreted.
556    #[must_use]
557    pub const fn document(&self) -> Option<&ComposeDocument> {
558        self.document.as_ref()
559    }
560
561    /// Returns structural typed-model diagnostics in source order.
562    #[must_use]
563    pub fn diagnostics(&self) -> &[Diagnostic] {
564        &self.diagnostics
565    }
566
567    /// Reports whether no error diagnostics were emitted.
568    #[must_use]
569    pub fn is_valid(&self) -> bool {
570        !self
571            .diagnostics
572            .iter()
573            .any(|diagnostic| diagnostic.severity() == Severity::Error)
574    }
575
576    /// Separates the recovered document and diagnostics.
577    #[must_use]
578    pub fn into_parts(self) -> (Option<ComposeDocument>, Vec<Diagnostic>) {
579        (self.document, self.diagnostics)
580    }
581}
582
583fn missing_dependency_diagnostic(span: SourceSpan, healthcheck: bool, required: bool) -> Diagnostic {
584    let severity = if required { Severity::Error } else { Severity::Warning };
585    if healthcheck {
586        Diagnostic::new(
587            DEPENDENCY_MISSING_HEALTHCHECK,
588            severity,
589            if required {
590                "service_healthy dependency requires an enabled health check"
591            } else {
592                "optional service_healthy dependency has no enabled health check"
593            },
594        )
595        .with_label(DiagnosticLabel::primary(span, "dependency cannot become healthy"))
596    } else {
597        Diagnostic::new(
598            DEPENDENCY_MISSING_SERVICE,
599            severity,
600            if required {
601                "service dependency is not declared in this Compose document"
602            } else {
603                "optional service dependency is not declared in this Compose document"
604            },
605        )
606        .with_label(DiagnosticLabel::primary(span, "missing dependency service"))
607    }
608}
609
610fn unverified_healthcheck_diagnostic(span: SourceSpan) -> Diagnostic {
611    Diagnostic::new(
612        DEPENDENCY_HEALTHCHECK_UNVERIFIED,
613        Severity::Warning,
614        "service_healthy dependency has no Compose healthcheck to validate",
615    )
616    .with_label(DiagnosticLabel::primary(span, "image health metadata is not available"))
617    .with_note("the dependency image may still define a health check; verify it at build or runtime")
618}
619
620#[derive(Debug)]
621struct Parser {
622    source_id: SourceId,
623    source_span: SourceSpan,
624    source: String,
625    tree: yaml_edit::YamlFile,
626    diagnostics: Vec<Diagnostic>,
627}
628
629impl Parser {
630    fn new(syntax: &SyntaxDocument) -> Self {
631        Self {
632            source_id: syntax.source_id(),
633            source_span: syntax.source_span(),
634            source: syntax.source_text().to_owned(),
635            tree: syntax.yaml_file(),
636            diagnostics: Vec::new(),
637        }
638    }
639
640    fn parse(mut self) -> ModelParse {
641        if self.tree.documents().count() > 1 {
642            self.diagnostics.push(
643                Diagnostic::new(
644                    MULTIPLE_DOCUMENTS,
645                    Severity::Error,
646                    "Compose input must contain one YAML document",
647                )
648                .with_label(DiagnosticLabel::primary(self.source_span, "multiple YAML documents")),
649            );
650        }
651
652        let Some(root) = self.tree.document() else {
653            self.diagnostics.push(
654                Diagnostic::new(
655                    DOCUMENT_ROOT_TYPE,
656                    Severity::Error,
657                    "Compose document root must be a mapping",
658                )
659                .with_label(DiagnosticLabel::primary(self.source_span, "empty document")),
660            );
661            return ModelParse {
662                document: None,
663                diagnostics: self.diagnostics,
664            };
665        };
666        let root_span = span_from_position(self.source_id, root.byte_range());
667        let Some(mapping) = root.as_mapping() else {
668            self.diagnostics.push(
669                Diagnostic::new(
670                    DOCUMENT_ROOT_TYPE,
671                    Severity::Error,
672                    "Compose document root must be a mapping",
673                )
674                .with_label(DiagnosticLabel::primary(root_span, "not a mapping")),
675            );
676            return ModelParse {
677                document: None,
678                diagnostics: self.diagnostics,
679            };
680        };
681
682        let document = self.parse_root(&mapping, root_span);
683        ModelParse {
684            document: Some(document),
685            diagnostics: self.diagnostics,
686        }
687    }
688
689    fn parse_root(&mut self, mapping: &Mapping, span: SourceSpan) -> ComposeDocument {
690        let mut document = ComposeDocument {
691            source_id: self.source_id,
692            span,
693            name: None,
694            services: Vec::new(),
695            networks: Vec::new(),
696            volumes: Vec::new(),
697            configs: Vec::new(),
698            secrets: Vec::new(),
699            extension_fields: Vec::new(),
700            unknown_fields: Vec::new(),
701        };
702        let mut seen = BTreeMap::new();
703
704        for field in self.fields(mapping) {
705            let duplicate = self.record_duplicate(&mut seen, &field);
706            match field.name.value.as_str() {
707                "name" if !duplicate => {
708                    document.name = self.parse_string(&field, "project name");
709                }
710                "services" if !duplicate => {
711                    document.services = self.parse_services(&field);
712                }
713                "networks" if !duplicate => {
714                    document.networks = self.parse_network_definitions(&field);
715                }
716                "volumes" if !duplicate => {
717                    document.volumes = self.parse_volume_definitions(&field);
718                }
719                "configs" if !duplicate => {
720                    document.configs = self.parse_config_definitions(&field);
721                }
722                "secrets" if !duplicate => {
723                    document.secrets = self.parse_secret_definitions(&field);
724                }
725                name if name.starts_with("x-") => {
726                    document.extension_fields.push(field.reference());
727                }
728                _ if duplicate => {}
729                _ => document.unknown_fields.push(field.reference()),
730            }
731        }
732        document
733    }
734
735    fn parse_services(&mut self, field: &ParsedField) -> Vec<Service> {
736        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
737            self.expected(EXPECTED_MAPPING, field, "services must be a mapping");
738            return Vec::new();
739        };
740        let mut services = Vec::new();
741        let mut seen = BTreeMap::new();
742        for service_field in self.fields(mapping) {
743            self.record_duplicate(&mut seen, &service_field);
744            let Some(service_mapping) = service_field.value.as_ref().and_then(YamlNode::as_mapping) else {
745                self.expected(EXPECTED_MAPPING, &service_field, "service definition must be a mapping");
746                continue;
747            };
748            services.push(self.parse_service(&service_field, service_mapping));
749        }
750        services
751    }
752
753    fn parse_service(&mut self, field: &ParsedField, mapping: &Mapping) -> Service {
754        let mut service = Service::new(field.name.clone(), field.span);
755        let mut seen = BTreeMap::new();
756        for service_field in self.fields(mapping) {
757            let duplicate = self.record_duplicate(&mut seen, &service_field);
758            match service_field.name.value.as_str() {
759                "image" if !duplicate => {
760                    service.image = self
761                        .parse_string(&service_field, "service image")
762                        .map(|value| Located::new(ImageReference::parse(value.value), value.span));
763                }
764                "command" if !duplicate => {
765                    service.command = self.parse_command(&service_field);
766                }
767                "environment" if !duplicate => {
768                    service.environment = self.parse_environment(&service_field);
769                }
770                "labels" if !duplicate => {
771                    service.labels = self.parse_labels(&service_field);
772                }
773                "extra_hosts" if !duplicate => {
774                    service.extra_hosts = self.parse_extra_hosts(&service_field);
775                }
776                "user" if !duplicate => {
777                    service.user = self.parse_string(&service_field, "service user").map(UserSpec::parse);
778                }
779                "userns_mode" if !duplicate => {
780                    service.userns_mode = self
781                        .parse_string(&service_field, "service user namespace mode")
782                        .map(UserNamespaceMode::parse);
783                }
784                "group_add" if !duplicate => {
785                    service.group_add = self.parse_string_sequence(&service_field, "service supplementary groups");
786                }
787                "working_dir" if !duplicate => {
788                    service.working_dir = self.parse_string(&service_field, "service working directory");
789                }
790                "read_only" if !duplicate => {
791                    service.read_only = self.parse_boolean(&service_field, "service read_only");
792                }
793                "ulimits" if !duplicate => {
794                    service.ulimits = self.parse_ulimits(&service_field);
795                }
796                "depends_on" if !duplicate => {
797                    service.depends_on = self.parse_depends_on(&service_field);
798                }
799                "healthcheck" if !duplicate => {
800                    service.healthcheck = self.parse_healthcheck(&service_field);
801                }
802                "build" if !duplicate => {
803                    service.build = self.parse_build(&service_field);
804                }
805                "deploy" if !duplicate => {
806                    service.deploy = self.parse_deploy(&service_field);
807                }
808                "ports" if !duplicate => {
809                    service.ports = self.parse_service_ports(&service_field);
810                }
811                "volumes" if !duplicate => {
812                    service.volumes = self.parse_service_volumes(&service_field);
813                }
814                "networks" if !duplicate => {
815                    service.networks = self.parse_service_networks(&service_field);
816                }
817                "profiles" if !duplicate => {
818                    service.profiles = self.parse_string_sequence(&service_field, "service profiles");
819                }
820                "configs" if !duplicate => {
821                    service.configs = self.parse_config_grants(&service_field);
822                }
823                "secrets" if !duplicate => {
824                    service.secrets = self.parse_secret_grants(&service_field);
825                }
826                name if name.starts_with("x-") => {
827                    service.extension_fields.push(service_field.reference());
828                }
829                _ if duplicate => {}
830                _ => service.unknown_fields.push(service_field.reference()),
831            }
832        }
833        service
834    }
835
836    fn parse_command(&mut self, field: &ParsedField) -> Option<Command> {
837        match field.value.as_ref() {
838            Some(YamlNode::Scalar(scalar)) => {
839                let span = span_from_position(self.source_id, scalar.byte_range());
840                if ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::Null {
841                    Some(Command::Null(span))
842                } else {
843                    Some(Command::String(Located::new(
844                        scalar_string_from_source(&self.source, scalar),
845                        span,
846                    )))
847                }
848            }
849            Some(YamlNode::Sequence(sequence)) => {
850                let span = span_from_position(self.source_id, sequence.byte_range());
851                let values =
852                    self.parse_scalar_nodes(sequence.values(), field.span, "command list items must be scalars");
853                Some(Command::List { span, values })
854            }
855            _ => {
856                self.expected(
857                    EXPECTED_FIELD_FORM,
858                    field,
859                    "command must be null, a scalar, or a sequence",
860                );
861                None
862            }
863        }
864    }
865
866    fn parse_environment(&mut self, field: &ParsedField) -> Option<Environment> {
867        match field.value.as_ref() {
868            Some(YamlNode::Sequence(sequence)) => {
869                let span = span_from_position(self.source_id, sequence.byte_range());
870                let entries = self
871                    .parse_scalar_nodes(sequence.values(), field.span, "environment list items must be scalars")
872                    .into_iter()
873                    .map(EnvironmentListEntry::parse)
874                    .collect();
875                Some(Environment::List { span, entries })
876            }
877            Some(YamlNode::Mapping(mapping)) => {
878                let span = span_from_position(self.source_id, mapping.byte_range());
879                let entries = self.parse_environment_map(mapping);
880                Some(Environment::Map { span, entries })
881            }
882            _ => {
883                self.expected(EXPECTED_FIELD_FORM, field, "environment must be a sequence or mapping");
884                None
885            }
886        }
887    }
888
889    fn parse_environment_map(&mut self, mapping: &Mapping) -> Vec<EnvironmentMapEntry> {
890        let mut entries = Vec::new();
891        let mut seen = BTreeMap::new();
892        for field in self.fields(mapping) {
893            if self.record_duplicate(&mut seen, &field) {
894                continue;
895            }
896            let value = self.parse_compose_scalar(&field, "environment values must be scalars");
897            if let Some(value) = value {
898                entries.push(EnvironmentMapEntry::new(field.name, value, field.span));
899            }
900        }
901        entries
902    }
903
904    fn parse_extra_hosts(&mut self, field: &ParsedField) -> Option<ExtraHosts> {
905        match field.value.as_ref() {
906            Some(YamlNode::Sequence(sequence)) => {
907                let span = span_from_position(self.source_id, sequence.byte_range());
908                let entries = self
909                    .parse_scalar_nodes(sequence.values(), field.span, "extra_hosts entries must be scalars")
910                    .into_iter()
911                    .map(|raw| {
912                        let entry = ShortExtraHost::parse(raw);
913                        if !entry.is_complete() {
914                            self.diagnostics.push(
915                                Diagnostic::new(
916                                    EXTRA_HOST_INVALID_ENTRY,
917                                    Severity::Error,
918                                    "short extra_hosts entry must contain a hostname and address",
919                                )
920                                .with_label(DiagnosticLabel::primary(
921                                    entry.raw().span(),
922                                    "missing separator or value",
923                                )),
924                            );
925                        }
926                        entry
927                    })
928                    .collect();
929                Some(ExtraHosts::Short { span, entries })
930            }
931            Some(YamlNode::Mapping(mapping)) => {
932                let span = span_from_position(self.source_id, mapping.byte_range());
933                let mut entries = Vec::new();
934                let mut seen = BTreeMap::new();
935                for host in self.fields(mapping) {
936                    if self.record_duplicate(&mut seen, &host) {
937                        continue;
938                    }
939                    if let Some(address) = self.parse_string(&host, "extra host address") {
940                        let address = Located::new(HostAddress::parse(address.value), address.span);
941                        entries.push(LongExtraHost::new(host.name, address, host.span));
942                    }
943                }
944                Some(ExtraHosts::Long { span, entries })
945            }
946            _ => {
947                self.expected(EXPECTED_FIELD_FORM, field, "extra_hosts must be a sequence or mapping");
948                None
949            }
950        }
951    }
952
953    fn parse_ulimits(&mut self, field: &ParsedField) -> Option<Ulimits> {
954        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
955            self.expected(EXPECTED_MAPPING, field, "ulimits must be a mapping");
956            return None;
957        };
958        let span = span_from_position(self.source_id, mapping.byte_range());
959        let mut entries = Vec::new();
960        let mut seen = BTreeMap::new();
961        for limit in self.fields(mapping) {
962            if self.record_duplicate(&mut seen, &limit) {
963                continue;
964            }
965            let value = match limit.value.as_ref() {
966                Some(YamlNode::Scalar(_)) => self.parse_limit_value(&limit, "ulimit value").map(UlimitValue::Single),
967                Some(YamlNode::Mapping(range)) => Some(UlimitValue::Range(self.parse_ulimit_range(range))),
968                _ => {
969                    self.expected(
970                        EXPECTED_FIELD_FORM,
971                        &limit,
972                        "ulimit must be a scalar or soft/hard mapping",
973                    );
974                    None
975                }
976            };
977            if let Some(value) = value {
978                entries.push(Ulimit::new(limit.name, limit.span, value));
979            }
980        }
981        Some(Ulimits::new(span, entries))
982    }
983
984    fn parse_ulimit_range(&mut self, mapping: &Mapping) -> UlimitRange {
985        let span = span_from_position(self.source_id, mapping.byte_range());
986        let mut range = UlimitRange::new(span);
987        let mut seen = BTreeMap::new();
988        for field in self.fields(mapping) {
989            let duplicate = self.record_duplicate(&mut seen, &field);
990            match field.name.value.as_str() {
991                "soft" if !duplicate => self
992                    .parse_limit_value(&field, "ulimit soft value")
993                    .into_iter()
994                    .for_each(|value| range.set_soft(value)),
995                "hard" if !duplicate => self
996                    .parse_limit_value(&field, "ulimit hard value")
997                    .into_iter()
998                    .for_each(|value| range.set_hard(value)),
999                name if name.starts_with("x-") => range.push_extension(field.reference()),
1000                _ if duplicate => {}
1001                _ => range.push_unknown(field.reference()),
1002            }
1003        }
1004        range
1005    }
1006
1007    fn parse_limit_value(&mut self, field: &ParsedField, description: &str) -> Option<Located<LimitValue>> {
1008        let value = self.parse_string(field, description)?;
1009        let parsed = LimitValue::parse(value.value);
1010        if !parsed.is_valid() {
1011            self.diagnostics.push(
1012                Diagnostic::new(
1013                    ULIMIT_INVALID_VALUE,
1014                    Severity::Error,
1015                    "ulimit must be -1, a non-negative integer, or an interpolation expression",
1016                )
1017                .with_label(DiagnosticLabel::primary(value.span, "invalid ulimit value")),
1018            );
1019        }
1020        Some(Located::new(parsed, value.span))
1021    }
1022
1023    fn parse_depends_on(&mut self, field: &ParsedField) -> Option<DependsOn> {
1024        match field.value.as_ref() {
1025            Some(YamlNode::Sequence(sequence)) => {
1026                let span = span_from_position(self.source_id, sequence.byte_range());
1027                let services = self.parse_scalar_nodes(
1028                    sequence.values(),
1029                    field.span,
1030                    "dependency service names must be scalars",
1031                );
1032                Some(DependsOn::Short { span, services })
1033            }
1034            Some(YamlNode::Mapping(mapping)) => {
1035                let span = span_from_position(self.source_id, mapping.byte_range());
1036                let mut services = Vec::new();
1037                let mut seen = BTreeMap::new();
1038                for dependency in self.fields(mapping) {
1039                    if self.record_duplicate(&mut seen, &dependency) {
1040                        continue;
1041                    }
1042                    let mut parsed = ServiceDependency::new(dependency.name.clone(), dependency.span);
1043                    if Self::field_is_null(&dependency) {
1044                        services.push(parsed);
1045                        continue;
1046                    }
1047                    let Some(options) = dependency.value.as_ref().and_then(YamlNode::as_mapping) else {
1048                        self.expected(
1049                            EXPECTED_MAPPING,
1050                            &dependency,
1051                            "long dependency options must be a mapping or null",
1052                        );
1053                        continue;
1054                    };
1055                    let mut option_seen = BTreeMap::new();
1056                    for option in self.fields(options) {
1057                        let duplicate = self.record_duplicate(&mut option_seen, &option);
1058                        match option.name.value.as_str() {
1059                            "condition" if !duplicate => {
1060                                if let Some(value) = self.parse_string(&option, "dependency condition") {
1061                                    let condition = DependencyCondition::parse(value.value);
1062                                    if !condition.is_known() {
1063                                        self.diagnostics.push(
1064                                            Diagnostic::new(
1065                                                DEPENDENCY_INVALID_CONDITION,
1066                                                Severity::Error,
1067                                                "dependency condition is not defined by Compose",
1068                                            )
1069                                            .with_label(
1070                                                DiagnosticLabel::primary(value.span, "unknown dependency condition"),
1071                                            ),
1072                                        );
1073                                    }
1074                                    parsed.set_condition(Located::new(condition, value.span));
1075                                }
1076                            }
1077                            "restart" if !duplicate => self
1078                                .parse_boolean(&option, "dependency restart")
1079                                .into_iter()
1080                                .for_each(|value| parsed.set_restart(value)),
1081                            "required" if !duplicate => self
1082                                .parse_boolean(&option, "dependency required")
1083                                .into_iter()
1084                                .for_each(|value| parsed.set_required(value)),
1085                            name if name.starts_with("x-") => parsed.push_extension(option.reference()),
1086                            _ if duplicate => {}
1087                            _ => parsed.push_unknown(option.reference()),
1088                        }
1089                    }
1090                    services.push(parsed);
1091                }
1092                Some(DependsOn::Long { span, services })
1093            }
1094            _ => {
1095                self.expected(EXPECTED_FIELD_FORM, field, "depends_on must be a sequence or mapping");
1096                None
1097            }
1098        }
1099    }
1100
1101    fn parse_healthcheck(&mut self, field: &ParsedField) -> Option<Healthcheck> {
1102        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
1103            self.expected(EXPECTED_MAPPING, field, "healthcheck must be a mapping");
1104            return None;
1105        };
1106        let span = span_from_position(self.source_id, mapping.byte_range());
1107        let mut healthcheck = Healthcheck::new(span);
1108        let mut seen = BTreeMap::new();
1109        for option in self.fields(mapping) {
1110            let duplicate = self.record_duplicate(&mut seen, &option);
1111            match option.name.value.as_str() {
1112                "test" if !duplicate => self
1113                    .parse_healthcheck_test(&option)
1114                    .into_iter()
1115                    .for_each(|value| healthcheck.set_test(value)),
1116                "interval" if !duplicate => self
1117                    .parse_healthcheck_duration(&option, "healthcheck interval")
1118                    .into_iter()
1119                    .for_each(|value| healthcheck.set_interval(value)),
1120                "timeout" if !duplicate => self
1121                    .parse_healthcheck_duration(&option, "healthcheck timeout")
1122                    .into_iter()
1123                    .for_each(|value| healthcheck.set_timeout(value)),
1124                "retries" if !duplicate => self
1125                    .parse_healthcheck_retries(&option)
1126                    .into_iter()
1127                    .for_each(|value| healthcheck.set_retries(value)),
1128                "start_period" if !duplicate => self
1129                    .parse_healthcheck_duration(&option, "healthcheck start period")
1130                    .into_iter()
1131                    .for_each(|value| healthcheck.set_start_period(value)),
1132                "start_interval" if !duplicate => self
1133                    .parse_healthcheck_duration(&option, "healthcheck start interval")
1134                    .into_iter()
1135                    .for_each(|value| healthcheck.set_start_interval(value)),
1136                "disable" if !duplicate => self
1137                    .parse_boolean(&option, "healthcheck disable")
1138                    .into_iter()
1139                    .for_each(|value| healthcheck.set_disable(value)),
1140                name if name.starts_with("x-") => healthcheck.push_extension(option.reference()),
1141                _ if duplicate => {}
1142                _ => healthcheck.push_unknown(option.reference()),
1143            }
1144        }
1145        Some(healthcheck)
1146    }
1147
1148    fn parse_healthcheck_duration(
1149        &mut self,
1150        field: &ParsedField,
1151        description: &str,
1152    ) -> Option<Located<HealthcheckDuration>> {
1153        let value = self.parse_string(field, description)?;
1154        let duration = HealthcheckDuration::parse(value.value);
1155        if !duration.is_valid() {
1156            self.diagnostics.push(
1157                Diagnostic::new(
1158                    HEALTHCHECK_INVALID_DURATION,
1159                    Severity::Error,
1160                    "healthcheck duration must use Compose duration syntax or interpolation",
1161                )
1162                .with_label(DiagnosticLabel::primary(value.span, "invalid healthcheck duration")),
1163            );
1164        }
1165        Some(Located::new(duration, value.span))
1166    }
1167
1168    fn parse_healthcheck_retries(&mut self, field: &ParsedField) -> Option<Located<HealthcheckRetries>> {
1169        let value = self.parse_string(field, "healthcheck retries")?;
1170        let retries = HealthcheckRetries::parse(value.value);
1171        if !retries.is_valid() {
1172            self.diagnostics.push(
1173                Diagnostic::new(
1174                    HEALTHCHECK_INVALID_RETRIES,
1175                    Severity::Error,
1176                    "healthcheck retries must be a non-negative integer or interpolation expression",
1177                )
1178                .with_label(DiagnosticLabel::primary(value.span, "invalid healthcheck retry count")),
1179            );
1180        }
1181        Some(Located::new(retries, value.span))
1182    }
1183
1184    fn parse_healthcheck_test(&mut self, field: &ParsedField) -> Option<HealthcheckTest> {
1185        match field.value.as_ref() {
1186            Some(YamlNode::Scalar(_)) => self
1187                .parse_string(field, "healthcheck test")
1188                .map(HealthcheckTest::String),
1189            Some(YamlNode::Sequence(sequence)) => {
1190                let span = span_from_position(self.source_id, sequence.byte_range());
1191                let values =
1192                    self.parse_scalar_nodes(sequence.values(), field.span, "healthcheck test items must be scalars");
1193                let kind = values.first().map(|value| HealthcheckTestKind::parse(value.value()));
1194                if kind.is_none()
1195                    || kind == Some(HealthcheckTestKind::Other)
1196                    || (kind == Some(HealthcheckTestKind::None) && values.len() != 1)
1197                {
1198                    self.diagnostics.push(
1199                        Diagnostic::new(
1200                            HEALTHCHECK_INVALID_TEST,
1201                            Severity::Error,
1202                            "healthcheck list must begin with NONE, CMD, or CMD-SHELL",
1203                        )
1204                        .with_label(DiagnosticLabel::primary(span, "invalid healthcheck command mode")),
1205                    );
1206                }
1207                Some(HealthcheckTest::List { span, kind, values })
1208            }
1209            _ => {
1210                self.expected(
1211                    EXPECTED_FIELD_FORM,
1212                    field,
1213                    "healthcheck test must be a scalar or sequence",
1214                );
1215                None
1216            }
1217        }
1218    }
1219
1220    fn parse_build(&mut self, field: &ParsedField) -> Option<Build> {
1221        match field.value.as_ref() {
1222            Some(YamlNode::Scalar(_)) => self.parse_string(field, "build context").map(Build::Context),
1223            Some(YamlNode::Mapping(mapping)) => {
1224                let span = span_from_position(self.source_id, mapping.byte_range());
1225                let mut definition = BuildDefinition::new(span);
1226                let mut seen = BTreeMap::new();
1227                for option in self.fields(mapping) {
1228                    let duplicate = self.record_duplicate(&mut seen, &option);
1229                    if duplicate {
1230                        continue;
1231                    }
1232                    if let Some(kind) = BuildFieldKind::from_name(option.name.value()) {
1233                        definition.push_field(BuildField::new(kind, option.reference()));
1234                    } else if option.name.value().starts_with("x-") {
1235                        definition.push_extension(option.reference());
1236                    } else {
1237                        definition.push_unknown(option.reference());
1238                    }
1239                }
1240                Some(Build::Definition(definition))
1241            }
1242            _ => {
1243                self.expected(EXPECTED_FIELD_FORM, field, "build must be a scalar context or mapping");
1244                None
1245            }
1246        }
1247    }
1248
1249    fn parse_deploy(&mut self, field: &ParsedField) -> Option<DeployDefinition> {
1250        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
1251            self.expected(EXPECTED_MAPPING, field, "deploy must be a mapping");
1252            return None;
1253        };
1254        let span = span_from_position(self.source_id, mapping.byte_range());
1255        let mut definition = DeployDefinition::new(span);
1256        let mut seen = BTreeMap::new();
1257        for option in self.fields(mapping) {
1258            let duplicate = self.record_duplicate(&mut seen, &option);
1259            if duplicate {
1260                continue;
1261            }
1262            if let Some(kind) = DeployFieldKind::from_name(option.name.value()) {
1263                definition.push_field(DeployField::new(kind, option.reference()));
1264            } else if option.name.value().starts_with("x-") {
1265                definition.push_extension(option.reference());
1266            } else {
1267                definition.push_unknown(option.reference());
1268            }
1269        }
1270        Some(definition)
1271    }
1272
1273    fn source_column(&self, offset: usize) -> usize {
1274        let prefix = self.source.get(..offset).unwrap_or_default();
1275        let line_start = prefix.rfind('\n').map_or(0, |index| index + 1);
1276        self.source[line_start..offset].chars().count()
1277    }
1278
1279    fn parse_service_ports(&mut self, field: &ParsedField) -> Vec<Port> {
1280        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
1281            self.expected(EXPECTED_SEQUENCE, field, "service ports must be a sequence");
1282            return Vec::new();
1283        };
1284
1285        let mut ports = Vec::new();
1286        for value in sequence.values() {
1287            match value {
1288                YamlNode::Scalar(scalar) => {
1289                    let span = span_from_position(self.source_id, scalar.byte_range());
1290                    ports.push(Port::Short(ShortPort::parse(Located::new(
1291                        scalar_string_from_source(&self.source, &scalar),
1292                        span,
1293                    ))));
1294                }
1295                YamlNode::Mapping(mapping) => {
1296                    ports.push(Port::Long(Box::new(self.parse_long_port(&mapping))));
1297                }
1298                other => self.unsupported_sequence_item(
1299                    PORT_EXPECTED_FORM,
1300                    &other,
1301                    field.span,
1302                    "service port must use scalar short syntax or mapping long syntax",
1303                ),
1304            }
1305        }
1306        ports
1307    }
1308
1309    fn parse_long_port(&mut self, mapping: &Mapping) -> LongPort {
1310        let span = span_from_position(self.source_id, mapping.byte_range());
1311        let mut port = LongPort::new(span);
1312        let mut seen = BTreeMap::new();
1313        for field in self.fields(mapping) {
1314            let duplicate = self.record_duplicate(&mut seen, &field);
1315            match field.name.value.as_str() {
1316                "target" if !duplicate => self
1317                    .parse_string(&field, "port target")
1318                    .into_iter()
1319                    .for_each(|value| port.set_target(value)),
1320                "published" if !duplicate => self
1321                    .parse_string(&field, "published port")
1322                    .into_iter()
1323                    .for_each(|value| port.set_published(value)),
1324                "host_ip" if !duplicate => self
1325                    .parse_string(&field, "port host IP")
1326                    .into_iter()
1327                    .for_each(|value| port.set_host_ip(value)),
1328                "protocol" if !duplicate => self
1329                    .parse_string(&field, "port protocol")
1330                    .into_iter()
1331                    .for_each(|value| port.set_protocol(value)),
1332                "app_protocol" if !duplicate => self
1333                    .parse_string(&field, "port application protocol")
1334                    .into_iter()
1335                    .for_each(|value| port.set_app_protocol(value)),
1336                "mode" if !duplicate => self
1337                    .parse_string(&field, "port mode")
1338                    .into_iter()
1339                    .for_each(|value| port.set_mode(value)),
1340                "name" if !duplicate => self
1341                    .parse_string(&field, "port name")
1342                    .into_iter()
1343                    .for_each(|value| port.set_name(value)),
1344                name if name.starts_with("x-") => port.push_extension(field.reference()),
1345                _ if duplicate => {}
1346                _ => port.push_unknown(field.reference()),
1347            }
1348        }
1349        if port.target().is_none() {
1350            self.missing(PORT_MISSING_TARGET, span, "long port is missing `target`");
1351        }
1352        port
1353    }
1354
1355    fn parse_service_networks(&mut self, field: &ParsedField) -> Option<ServiceNetworks> {
1356        match field.value.as_ref() {
1357            Some(YamlNode::Sequence(sequence)) => {
1358                let span = span_from_position(self.source_id, sequence.byte_range());
1359                let names =
1360                    self.parse_scalar_nodes(sequence.values(), field.span, "service network names must be scalars");
1361                Some(ServiceNetworks::Short { span, names })
1362            }
1363            Some(YamlNode::Mapping(mapping)) => {
1364                let span = span_from_position(self.source_id, mapping.byte_range());
1365                let networks = self.parse_service_network_map(mapping);
1366                Some(ServiceNetworks::Long { span, networks })
1367            }
1368            _ => {
1369                self.expected(
1370                    EXPECTED_FIELD_FORM,
1371                    field,
1372                    "service networks must be a sequence or mapping",
1373                );
1374                None
1375            }
1376        }
1377    }
1378
1379    fn parse_service_network_map(&mut self, mapping: &Mapping) -> Vec<ServiceNetwork> {
1380        let mut networks = Vec::new();
1381        let mut seen = BTreeMap::new();
1382        for field in self.fields(mapping) {
1383            if self.record_duplicate(&mut seen, &field) {
1384                continue;
1385            }
1386            if Self::field_is_null(&field) {
1387                networks.push(ServiceNetwork::new(field.name, field.span));
1388                continue;
1389            }
1390            let Some(options) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
1391                self.expected(
1392                    EXPECTED_MAPPING,
1393                    &field,
1394                    "service network options must be a mapping or null",
1395                );
1396                continue;
1397            };
1398            networks.push(self.parse_service_network(&field, options));
1399        }
1400        networks
1401    }
1402
1403    fn parse_service_network(&mut self, field: &ParsedField, mapping: &Mapping) -> ServiceNetwork {
1404        let mut network = ServiceNetwork::new(field.name.clone(), field.span);
1405        let mut seen = BTreeMap::new();
1406        for option in self.fields(mapping) {
1407            let duplicate = self.record_duplicate(&mut seen, &option);
1408            match option.name.value.as_str() {
1409                "aliases" if !duplicate => network.set_aliases(self.parse_string_sequence(&option, "network aliases")),
1410                "interface_name" if !duplicate => self
1411                    .parse_string(&option, "network interface name")
1412                    .into_iter()
1413                    .for_each(|value| network.set_interface_name(value)),
1414                "ipv4_address" if !duplicate => self
1415                    .parse_string(&option, "network IPv4 address")
1416                    .into_iter()
1417                    .for_each(|value| network.set_ipv4_address(value)),
1418                "ipv6_address" if !duplicate => self
1419                    .parse_string(&option, "network IPv6 address")
1420                    .into_iter()
1421                    .for_each(|value| network.set_ipv6_address(value)),
1422                "link_local_ips" if !duplicate => {
1423                    network.set_link_local_ips(self.parse_string_sequence(&option, "link-local IP addresses"));
1424                }
1425                "mac_address" if !duplicate => self
1426                    .parse_string(&option, "network MAC address")
1427                    .into_iter()
1428                    .for_each(|value| network.set_mac_address(value)),
1429                "driver_opts" if !duplicate => {
1430                    network.set_driver_opts(self.parse_scalar_mapping(&option, "network driver options"));
1431                }
1432                "gw_priority" if !duplicate => self
1433                    .parse_string(&option, "network gateway priority")
1434                    .into_iter()
1435                    .for_each(|value| network.set_gw_priority(value)),
1436                "priority" if !duplicate => self
1437                    .parse_string(&option, "network priority")
1438                    .into_iter()
1439                    .for_each(|value| network.set_priority(value)),
1440                name if name.starts_with("x-") => network.push_extension(option.reference()),
1441                _ if duplicate => {}
1442                _ => network.push_unknown(option.reference()),
1443            }
1444        }
1445        network
1446    }
1447
1448    fn parse_config_grants(&mut self, field: &ParsedField) -> Vec<ConfigGrant> {
1449        self.parse_grants(field)
1450            .into_iter()
1451            .map(|grant| match grant {
1452                ParsedGrant::Short(value) => ConfigGrant::Short(value),
1453                ParsedGrant::Long(value) => ConfigGrant::Long(value),
1454            })
1455            .collect()
1456    }
1457
1458    fn parse_secret_grants(&mut self, field: &ParsedField) -> Vec<SecretGrant> {
1459        self.parse_grants(field)
1460            .into_iter()
1461            .map(|grant| match grant {
1462                ParsedGrant::Short(value) => SecretGrant::Short(value),
1463                ParsedGrant::Long(value) => SecretGrant::Long(value),
1464            })
1465            .collect()
1466    }
1467
1468    fn parse_grants(&mut self, field: &ParsedField) -> Vec<ParsedGrant> {
1469        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
1470            self.expected(EXPECTED_SEQUENCE, field, "service grants must be a sequence");
1471            return Vec::new();
1472        };
1473        let mut grants = Vec::new();
1474        for value in sequence.values() {
1475            match value {
1476                YamlNode::Scalar(scalar) => {
1477                    let span = span_from_position(self.source_id, scalar.byte_range());
1478                    grants.push(ParsedGrant::Short(Located::new(
1479                        scalar_string_from_source(&self.source, &scalar),
1480                        span,
1481                    )));
1482                }
1483                YamlNode::Mapping(mapping) => {
1484                    grants.push(ParsedGrant::Long(Box::new(self.parse_long_grant(&mapping))));
1485                }
1486                other => self.unsupported_sequence_item(
1487                    GRANT_EXPECTED_FORM,
1488                    &other,
1489                    field.span,
1490                    "grant must use scalar short syntax or mapping long syntax",
1491                ),
1492            }
1493        }
1494        grants
1495    }
1496
1497    fn parse_long_grant(&mut self, mapping: &Mapping) -> LongGrant {
1498        let span = span_from_position(self.source_id, mapping.byte_range());
1499        let mut grant = LongGrant::new(span);
1500        let mut seen = BTreeMap::new();
1501        for field in self.fields(mapping) {
1502            let duplicate = self.record_duplicate(&mut seen, &field);
1503            match field.name.value.as_str() {
1504                "source" if !duplicate => self
1505                    .parse_string(&field, "grant source")
1506                    .into_iter()
1507                    .for_each(|value| grant.set_source(value)),
1508                "target" if !duplicate => self
1509                    .parse_string(&field, "grant target")
1510                    .into_iter()
1511                    .for_each(|value| grant.set_target(value)),
1512                "uid" if !duplicate => self
1513                    .parse_string(&field, "grant user ID")
1514                    .into_iter()
1515                    .for_each(|value| grant.set_uid(value)),
1516                "gid" if !duplicate => self
1517                    .parse_string(&field, "grant group ID")
1518                    .into_iter()
1519                    .for_each(|value| grant.set_gid(value)),
1520                "mode" if !duplicate => self
1521                    .parse_string(&field, "grant mode")
1522                    .into_iter()
1523                    .for_each(|value| grant.set_mode(value)),
1524                name if name.starts_with("x-") => grant.push_extension(field.reference()),
1525                _ if duplicate => {}
1526                _ => grant.push_unknown(field.reference()),
1527            }
1528        }
1529        if grant.source().is_none() {
1530            self.missing(GRANT_MISSING_SOURCE, span, "long grant is missing `source`");
1531        }
1532        grant
1533    }
1534
1535    fn parse_service_volumes(&mut self, field: &ParsedField) -> Vec<VolumeMount> {
1536        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
1537            self.expected(EXPECTED_SEQUENCE, field, "service volumes must be a sequence");
1538            return Vec::new();
1539        };
1540
1541        sequence
1542            .values()
1543            .filter_map(|value| match value {
1544                YamlNode::Scalar(scalar) => {
1545                    let span = span_from_position(self.source_id, scalar.byte_range());
1546                    let raw = Located::new(scalar_string_from_source(&self.source, &scalar), span);
1547                    Some(VolumeMount::Short(ShortVolumeMount::new(raw)))
1548                }
1549                YamlNode::Mapping(mapping) => Some(VolumeMount::Long(Box::new(self.parse_long_volume(&mapping)))),
1550                other => {
1551                    let span = node_span(self.source_id, &other).unwrap_or(field.span);
1552                    self.diagnostics.push(
1553                        Diagnostic::new(
1554                            VOLUME_EXPECTED_FORM,
1555                            Severity::Error,
1556                            "service volume must use scalar short syntax or mapping long syntax",
1557                        )
1558                        .with_label(DiagnosticLabel::primary(span, "unsupported volume form")),
1559                    );
1560                    None
1561                }
1562            })
1563            .collect()
1564    }
1565
1566    fn parse_long_volume(&mut self, mapping: &Mapping) -> LongVolumeMount {
1567        let span = span_from_position(self.source_id, mapping.byte_range());
1568        let mut mount = LongVolumeMount::new(span);
1569        let mut seen = BTreeMap::new();
1570        for field in self.fields(mapping) {
1571            let duplicate = self.record_duplicate(&mut seen, &field);
1572            match field.name.value.as_str() {
1573                "type" if !duplicate => {
1574                    if let Some(value) = self.parse_string(&field, "volume type") {
1575                        mount.set_mount_type(Located::new(MountType::from_text(value.value), value.span));
1576                    }
1577                }
1578                "source" if !duplicate => {
1579                    if let Some(value) = self.parse_string(&field, "volume source") {
1580                        mount.set_source(value);
1581                    }
1582                }
1583                "target" if !duplicate => {
1584                    if let Some(value) = self.parse_string(&field, "volume target") {
1585                        mount.set_target(value);
1586                    }
1587                }
1588                "read_only" if !duplicate => {
1589                    if let Some(value) = self.parse_boolean(&field, "read_only") {
1590                        mount.set_read_only(value);
1591                    }
1592                }
1593                "bind" if !duplicate => {
1594                    if let Some(value) = self.parse_bind_options(&field) {
1595                        mount.set_bind(value);
1596                    }
1597                }
1598                name if name.starts_with("x-") => mount.push_extension(field.reference()),
1599                _ if duplicate => {}
1600                _ => mount.push_unknown(field.reference()),
1601            }
1602        }
1603
1604        if mount.mount_type().is_none() {
1605            self.missing(VOLUME_MISSING_TYPE, span, "long volume is missing `type`");
1606        }
1607        if mount.target().is_none() {
1608            self.missing(VOLUME_MISSING_TARGET, span, "long volume is missing `target`");
1609        }
1610        mount
1611    }
1612
1613    fn parse_bind_options(&mut self, field: &ParsedField) -> Option<BindOptions> {
1614        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
1615            self.expected(EXPECTED_MAPPING, field, "bind options must be a mapping");
1616            return None;
1617        };
1618        let span = span_from_position(self.source_id, mapping.byte_range());
1619        let mut bind = BindOptions::new(span);
1620        let mut seen = BTreeMap::new();
1621        for bind_field in self.fields(mapping) {
1622            let duplicate = self.record_duplicate(&mut seen, &bind_field);
1623            match bind_field.name.value.as_str() {
1624                "propagation" if !duplicate => {
1625                    if let Some(value) = self.parse_string(&bind_field, "bind propagation") {
1626                        bind.set_propagation(value);
1627                    }
1628                }
1629                "create_host_path" if !duplicate => {
1630                    if let Some(value) = self.parse_boolean(&bind_field, "create_host_path") {
1631                        bind.set_create_host_path(value);
1632                    }
1633                }
1634                "selinux" if !duplicate => {
1635                    if let Some(value) = self.parse_string(&bind_field, "SELinux relabel mode") {
1636                        let mode = match value.value.as_str() {
1637                            "z" => Some(SelinuxRelabel::Shared),
1638                            "Z" => Some(SelinuxRelabel::Private),
1639                            _ => None,
1640                        };
1641                        if let Some(mode) = mode {
1642                            bind.set_selinux(Located::new(mode, value.span));
1643                        } else {
1644                            self.diagnostics.push(
1645                                Diagnostic::new(
1646                                    VOLUME_INVALID_SELINUX,
1647                                    Severity::Error,
1648                                    "SELinux relabel mode must be `z` or `Z`",
1649                                )
1650                                .with_label(DiagnosticLabel::primary(value.span, "invalid SELinux mode")),
1651                            );
1652                        }
1653                    }
1654                }
1655                name if name.starts_with("x-") => bind.push_extension(bind_field.reference()),
1656                _ if duplicate => {}
1657                _ => bind.push_unknown(bind_field.reference()),
1658            }
1659        }
1660        Some(bind)
1661    }
1662
1663    fn parse_network_definitions(&mut self, field: &ParsedField) -> Vec<NetworkDefinition> {
1664        let Some(mapping) = self.resource_collection(field, "networks") else {
1665            return Vec::new();
1666        };
1667        let mut definitions = Vec::new();
1668        let mut seen = BTreeMap::new();
1669        for resource in self.fields(&mapping) {
1670            if self.record_duplicate(&mut seen, &resource) {
1671                continue;
1672            }
1673            if Self::field_is_null(&resource) {
1674                definitions.push(NetworkDefinition::new(resource.name, resource.span));
1675                continue;
1676            }
1677            let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
1678                self.expected(
1679                    RESOURCE_EXPECTED_FORM,
1680                    &resource,
1681                    "network definition must be a mapping or null",
1682                );
1683                continue;
1684            };
1685            definitions.push(self.parse_network_definition(&resource, definition));
1686        }
1687        definitions
1688    }
1689
1690    fn parse_network_definition(&mut self, field: &ParsedField, mapping: &Mapping) -> NetworkDefinition {
1691        let mut network = NetworkDefinition::new(field.name.clone(), field.span);
1692        let mut seen = BTreeMap::new();
1693        for option in self.fields(mapping) {
1694            let duplicate = self.record_duplicate(&mut seen, &option);
1695            match option.name.value.as_str() {
1696                "driver" if !duplicate => self
1697                    .parse_string(&option, "network driver")
1698                    .into_iter()
1699                    .for_each(|value| network.set_driver(value)),
1700                "driver_opts" if !duplicate => {
1701                    network.set_driver_opts(self.parse_scalar_mapping(&option, "network driver options"));
1702                }
1703                "attachable" if !duplicate => self
1704                    .parse_boolean(&option, "network attachable")
1705                    .into_iter()
1706                    .for_each(|value| network.set_attachable(value)),
1707                "enable_ipv4" if !duplicate => self
1708                    .parse_boolean(&option, "network enable_ipv4")
1709                    .into_iter()
1710                    .for_each(|value| network.set_enable_ipv4(value)),
1711                "enable_ipv6" if !duplicate => self
1712                    .parse_boolean(&option, "network enable_ipv6")
1713                    .into_iter()
1714                    .for_each(|value| network.set_enable_ipv6(value)),
1715                "external" if !duplicate => self
1716                    .parse_boolean(&option, "network external")
1717                    .into_iter()
1718                    .for_each(|value| network.set_external(value)),
1719                "internal" if !duplicate => self
1720                    .parse_boolean(&option, "network internal")
1721                    .into_iter()
1722                    .for_each(|value| network.set_internal(value)),
1723                "ipam" if !duplicate => self
1724                    .parse_ipam(&option)
1725                    .into_iter()
1726                    .for_each(|value| network.set_ipam(value)),
1727                "labels" if !duplicate => self
1728                    .parse_labels(&option)
1729                    .into_iter()
1730                    .for_each(|value| network.set_labels(value)),
1731                "name" if !duplicate => self
1732                    .parse_string(&option, "network custom name")
1733                    .into_iter()
1734                    .for_each(|value| network.set_custom_name(value)),
1735                name if name.starts_with("x-") => network.push_extension(option.reference()),
1736                _ if duplicate => {}
1737                _ => network.push_unknown(option.reference()),
1738            }
1739        }
1740        network
1741    }
1742
1743    fn parse_ipam(&mut self, field: &ParsedField) -> Option<Ipam> {
1744        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
1745            self.expected(EXPECTED_MAPPING, field, "network IPAM must be a mapping");
1746            return None;
1747        };
1748        let span = span_from_position(self.source_id, mapping.byte_range());
1749        let mut ipam = Ipam::new(span);
1750        let mut seen = BTreeMap::new();
1751        for option in self.fields(mapping) {
1752            let duplicate = self.record_duplicate(&mut seen, &option);
1753            match option.name.value.as_str() {
1754                "driver" if !duplicate => self
1755                    .parse_string(&option, "IPAM driver")
1756                    .into_iter()
1757                    .for_each(|value| ipam.set_driver(value)),
1758                "config" if !duplicate => ipam.set_config(self.parse_ipam_configs(&option)),
1759                "options" if !duplicate => {
1760                    ipam.set_options(self.parse_scalar_mapping(&option, "IPAM options"));
1761                }
1762                name if name.starts_with("x-") => ipam.push_extension(option.reference()),
1763                _ if duplicate => {}
1764                _ => ipam.push_unknown(option.reference()),
1765            }
1766        }
1767        Some(ipam)
1768    }
1769
1770    fn parse_ipam_configs(&mut self, field: &ParsedField) -> Vec<IpamConfig> {
1771        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
1772            self.expected(EXPECTED_SEQUENCE, field, "IPAM config must be a sequence");
1773            return Vec::new();
1774        };
1775        let mut configs = Vec::new();
1776        for value in sequence.values() {
1777            let YamlNode::Mapping(mapping) = value else {
1778                self.unsupported_sequence_item(
1779                    EXPECTED_MAPPING,
1780                    &value,
1781                    field.span,
1782                    "IPAM config entries must be mappings",
1783                );
1784                continue;
1785            };
1786            configs.push(self.parse_ipam_config(&mapping));
1787        }
1788        configs
1789    }
1790
1791    fn parse_ipam_config(&mut self, mapping: &Mapping) -> IpamConfig {
1792        let span = span_from_position(self.source_id, mapping.byte_range());
1793        let mut config = IpamConfig::new(span);
1794        let mut seen = BTreeMap::new();
1795        for field in self.fields(mapping) {
1796            let duplicate = self.record_duplicate(&mut seen, &field);
1797            match field.name.value.as_str() {
1798                "subnet" if !duplicate => self
1799                    .parse_string(&field, "IPAM subnet")
1800                    .into_iter()
1801                    .for_each(|value| config.set_subnet(value)),
1802                "ip_range" if !duplicate => self
1803                    .parse_string(&field, "IPAM allocation range")
1804                    .into_iter()
1805                    .for_each(|value| config.set_ip_range(value)),
1806                "gateway" if !duplicate => self
1807                    .parse_string(&field, "IPAM gateway")
1808                    .into_iter()
1809                    .for_each(|value| config.set_gateway(value)),
1810                "aux_addresses" if !duplicate => {
1811                    config.set_aux_addresses(self.parse_scalar_mapping(&field, "IPAM auxiliary addresses"));
1812                }
1813                name if name.starts_with("x-") => config.push_extension(field.reference()),
1814                _ if duplicate => {}
1815                _ => config.push_unknown(field.reference()),
1816            }
1817        }
1818        config
1819    }
1820
1821    fn parse_volume_definitions(&mut self, field: &ParsedField) -> Vec<VolumeDefinition> {
1822        let Some(mapping) = self.resource_collection(field, "volumes") else {
1823            return Vec::new();
1824        };
1825        let mut definitions = Vec::new();
1826        let mut seen = BTreeMap::new();
1827        for resource in self.fields(&mapping) {
1828            if self.record_duplicate(&mut seen, &resource) {
1829                continue;
1830            }
1831            let mut volume = VolumeDefinition::new(resource.name.clone(), resource.span);
1832            if Self::field_is_null(&resource) {
1833                definitions.push(volume);
1834                continue;
1835            }
1836            let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
1837                self.expected(
1838                    RESOURCE_EXPECTED_FORM,
1839                    &resource,
1840                    "volume definition must be a mapping or null",
1841                );
1842                continue;
1843            };
1844            let mut nested_seen = BTreeMap::new();
1845            for option in self.fields(definition) {
1846                let duplicate = self.record_duplicate(&mut nested_seen, &option);
1847                match option.name.value.as_str() {
1848                    "driver" if !duplicate => self
1849                        .parse_string(&option, "volume driver")
1850                        .into_iter()
1851                        .for_each(|value| volume.set_driver(value)),
1852                    "driver_opts" if !duplicate => {
1853                        volume.set_driver_opts(self.parse_scalar_mapping(&option, "volume driver options"));
1854                    }
1855                    "external" if !duplicate => self
1856                        .parse_boolean(&option, "volume external")
1857                        .into_iter()
1858                        .for_each(|value| volume.set_external(value)),
1859                    "labels" if !duplicate => self
1860                        .parse_labels(&option)
1861                        .into_iter()
1862                        .for_each(|value| volume.set_labels(value)),
1863                    "name" if !duplicate => self
1864                        .parse_string(&option, "volume custom name")
1865                        .into_iter()
1866                        .for_each(|value| volume.set_custom_name(value)),
1867                    name if name.starts_with("x-") => volume.push_extension(option.reference()),
1868                    _ if duplicate => {}
1869                    _ => volume.push_unknown(option.reference()),
1870                }
1871            }
1872            definitions.push(volume);
1873        }
1874        definitions
1875    }
1876
1877    fn parse_config_definitions(&mut self, field: &ParsedField) -> Vec<ConfigDefinition> {
1878        let Some(mapping) = self.resource_collection(field, "configs") else {
1879            return Vec::new();
1880        };
1881        let mut definitions = Vec::new();
1882        let mut seen = BTreeMap::new();
1883        for resource in self.fields(&mapping) {
1884            if self.record_duplicate(&mut seen, &resource) {
1885                continue;
1886            }
1887            let mut config = ConfigDefinition::new(resource.name.clone(), resource.span);
1888            if Self::field_is_null(&resource) {
1889                definitions.push(config);
1890                continue;
1891            }
1892            let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
1893                self.expected(
1894                    RESOURCE_EXPECTED_FORM,
1895                    &resource,
1896                    "config definition must be a mapping or null",
1897                );
1898                continue;
1899            };
1900            let mut nested_seen = BTreeMap::new();
1901            for option in self.fields(definition) {
1902                let duplicate = self.record_duplicate(&mut nested_seen, &option);
1903                match option.name.value.as_str() {
1904                    "file" if !duplicate => self
1905                        .parse_string(&option, "config file")
1906                        .into_iter()
1907                        .for_each(|value| config.set_file(value)),
1908                    "environment" if !duplicate => self
1909                        .parse_string(&option, "config environment source")
1910                        .into_iter()
1911                        .for_each(|value| config.set_environment(value)),
1912                    "content" if !duplicate => self
1913                        .parse_string(&option, "config content")
1914                        .into_iter()
1915                        .for_each(|value| config.set_content(value)),
1916                    "external" if !duplicate => self
1917                        .parse_boolean(&option, "config external")
1918                        .into_iter()
1919                        .for_each(|value| config.set_external(value)),
1920                    "name" if !duplicate => self
1921                        .parse_string(&option, "config custom name")
1922                        .into_iter()
1923                        .for_each(|value| config.set_custom_name(value)),
1924                    name if name.starts_with("x-") => config.push_extension(option.reference()),
1925                    _ if duplicate => {}
1926                    _ => config.push_unknown(option.reference()),
1927                }
1928            }
1929            definitions.push(config);
1930        }
1931        definitions
1932    }
1933
1934    fn parse_secret_definitions(&mut self, field: &ParsedField) -> Vec<SecretDefinition> {
1935        let Some(mapping) = self.resource_collection(field, "secrets") else {
1936            return Vec::new();
1937        };
1938        let mut definitions = Vec::new();
1939        let mut seen = BTreeMap::new();
1940        for resource in self.fields(&mapping) {
1941            if self.record_duplicate(&mut seen, &resource) {
1942                continue;
1943            }
1944            let mut secret = SecretDefinition::new(resource.name.clone(), resource.span);
1945            if Self::field_is_null(&resource) {
1946                definitions.push(secret);
1947                continue;
1948            }
1949            let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
1950                self.expected(
1951                    RESOURCE_EXPECTED_FORM,
1952                    &resource,
1953                    "secret definition must be a mapping or null",
1954                );
1955                continue;
1956            };
1957            let mut nested_seen = BTreeMap::new();
1958            for option in self.fields(definition) {
1959                let duplicate = self.record_duplicate(&mut nested_seen, &option);
1960                match option.name.value.as_str() {
1961                    "file" if !duplicate => self
1962                        .parse_string(&option, "secret file")
1963                        .into_iter()
1964                        .for_each(|value| secret.set_file(value)),
1965                    "environment" if !duplicate => self
1966                        .parse_string(&option, "secret environment source")
1967                        .into_iter()
1968                        .for_each(|value| secret.set_environment(value)),
1969                    "external" if !duplicate => self
1970                        .parse_boolean(&option, "secret external")
1971                        .into_iter()
1972                        .for_each(|value| secret.set_external(value)),
1973                    "name" if !duplicate => self
1974                        .parse_string(&option, "secret custom name")
1975                        .into_iter()
1976                        .for_each(|value| secret.set_custom_name(value)),
1977                    name if name.starts_with("x-") => secret.push_extension(option.reference()),
1978                    _ if duplicate => {}
1979                    _ => secret.push_unknown(option.reference()),
1980                }
1981            }
1982            definitions.push(secret);
1983        }
1984        definitions
1985    }
1986
1987    fn resource_collection(&mut self, field: &ParsedField, kind: &str) -> Option<Mapping> {
1988        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
1989            self.expected(EXPECTED_MAPPING, field, format!("top-level {kind} must be a mapping"));
1990            return None;
1991        };
1992        Some(mapping.clone())
1993    }
1994
1995    fn parse_string(&mut self, field: &ParsedField, description: &str) -> Option<Located<String>> {
1996        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
1997            self.expected(EXPECTED_SCALAR, field, format!("{description} must be a scalar"));
1998            return None;
1999        };
2000        if ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::Null {
2001            self.expected(
2002                EXPECTED_SCALAR,
2003                field,
2004                format!("{description} must be a non-null scalar"),
2005            );
2006            return None;
2007        }
2008        Some(Located::new(
2009            scalar_string_from_source(&self.source, scalar),
2010            span_from_position(self.source_id, scalar.byte_range()),
2011        ))
2012    }
2013
2014    fn parse_boolean(&mut self, field: &ParsedField, description: &str) -> Option<Located<BooleanValue>> {
2015        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
2016            self.expected(EXPECTED_BOOLEAN, field, format!("{description} must be a boolean"));
2017            return None;
2018        };
2019        let span = span_from_position(self.source_id, scalar.byte_range());
2020        let scalar_value = ScalarValue::from_scalar(scalar);
2021        if let Some(value) = scalar_value.to_bool() {
2022            return Some(Located::new(BooleanValue::Literal(value), span));
2023        }
2024        let value = scalar_string_from_source(&self.source, scalar);
2025        if value.contains('$') {
2026            return Some(Located::new(BooleanValue::Expression(value), span));
2027        }
2028        self.diagnostics.push(
2029            Diagnostic::new(
2030                EXPECTED_BOOLEAN,
2031                Severity::Error,
2032                format!("{description} must be a boolean or interpolation expression"),
2033            )
2034            .with_label(DiagnosticLabel::primary(span, "not a boolean expression")),
2035        );
2036        None
2037    }
2038
2039    fn parse_string_sequence(&mut self, field: &ParsedField, description: &str) -> Vec<Located<String>> {
2040        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
2041            self.expected(EXPECTED_SEQUENCE, field, format!("{description} must be a sequence"));
2042            return Vec::new();
2043        };
2044        self.parse_scalar_nodes(
2045            sequence.values(),
2046            field.span,
2047            format!("{description} entries must be scalars"),
2048        )
2049    }
2050
2051    fn parse_scalar_nodes(
2052        &mut self,
2053        nodes: impl Iterator<Item = YamlNode>,
2054        fallback_span: SourceSpan,
2055        message: impl Into<String>,
2056    ) -> Vec<Located<String>> {
2057        let message = message.into();
2058        let mut values = Vec::new();
2059        for node in nodes {
2060            let YamlNode::Scalar(scalar) = node else {
2061                self.unsupported_sequence_item(EXPECTED_SCALAR, &node, fallback_span, &message);
2062                continue;
2063            };
2064            let scalar_value = ScalarValue::from_scalar(&scalar);
2065            if scalar_value.scalar_type() == ScalarType::Null {
2066                self.unsupported_sequence_item(EXPECTED_SCALAR, &YamlNode::Scalar(scalar), fallback_span, &message);
2067                continue;
2068            }
2069            let span = span_from_position(self.source_id, scalar.byte_range());
2070            values.push(Located::new(scalar_string_from_source(&self.source, &scalar), span));
2071        }
2072        values
2073    }
2074
2075    fn parse_scalar_mapping(&mut self, field: &ParsedField, description: &str) -> Vec<KeyValueEntry> {
2076        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
2077            self.expected(EXPECTED_MAPPING, field, format!("{description} must be a mapping"));
2078            return Vec::new();
2079        };
2080        let mut entries = Vec::new();
2081        let mut seen = BTreeMap::new();
2082        for entry in self.fields(mapping) {
2083            if self.record_duplicate(&mut seen, &entry) {
2084                continue;
2085            }
2086            if let Some(value) = self.parse_compose_scalar(&entry, format!("{description} values must be scalars")) {
2087                entries.push(KeyValueEntry::new(entry.name, value, entry.span));
2088            }
2089        }
2090        entries
2091    }
2092
2093    fn parse_compose_scalar(
2094        &mut self,
2095        field: &ParsedField,
2096        message: impl Into<String>,
2097    ) -> Option<Located<ComposeScalar>> {
2098        let Some(node) = field.value.as_ref() else {
2099            return Some(Located::new(ComposeScalar::Null, field.name.span));
2100        };
2101        let Some(scalar) = node.as_scalar() else {
2102            self.expected(EXPECTED_SCALAR, field, message);
2103            return None;
2104        };
2105        let span = span_from_position(self.source_id, scalar.byte_range());
2106        let value = ScalarValue::from_scalar(scalar);
2107        let typed = match value.scalar_type() {
2108            ScalarType::Null => ComposeScalar::Null,
2109            ScalarType::Boolean => ComposeScalar::Boolean(value.to_bool().unwrap_or(false)),
2110            ScalarType::Integer | ScalarType::Float => {
2111                ComposeScalar::Number(scalar_string_from_source(&self.source, scalar))
2112            }
2113            ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
2114                ComposeScalar::String(scalar_string_from_source(&self.source, scalar))
2115            }
2116        };
2117        Some(Located::new(typed, span))
2118    }
2119
2120    fn parse_labels(&mut self, field: &ParsedField) -> Option<Labels> {
2121        match field.value.as_ref() {
2122            Some(YamlNode::Sequence(sequence)) => {
2123                let span = span_from_position(self.source_id, sequence.byte_range());
2124                let values =
2125                    self.parse_scalar_nodes(sequence.values(), field.span, "label list entries must be scalars");
2126                Some(Labels::List { span, values })
2127            }
2128            Some(YamlNode::Mapping(mapping)) => {
2129                let span = span_from_position(self.source_id, mapping.byte_range());
2130                let entries = self.parse_scalar_mapping(field, "labels");
2131                Some(Labels::Map { span, entries })
2132            }
2133            _ => {
2134                self.expected(EXPECTED_FIELD_FORM, field, "labels must be a sequence or mapping");
2135                None
2136            }
2137        }
2138    }
2139
2140    fn field_is_null(field: &ParsedField) -> bool {
2141        field.value.as_ref().is_none_or(|node| {
2142            node.as_scalar()
2143                .is_some_and(|scalar| ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::Null)
2144        })
2145    }
2146
2147    fn unsupported_sequence_item(
2148        &mut self,
2149        code: DiagnosticCode,
2150        node: &YamlNode,
2151        fallback_span: SourceSpan,
2152        message: impl Into<String>,
2153    ) {
2154        let span = node_span(self.source_id, node).unwrap_or(fallback_span);
2155        self.diagnostics.push(
2156            Diagnostic::new(code, Severity::Error, message)
2157                .with_label(DiagnosticLabel::primary(span, "unsupported value form")),
2158        );
2159    }
2160
2161    fn fields(&mut self, mapping: &Mapping) -> Vec<ParsedField> {
2162        let fields = self.raw_fields(mapping);
2163        self.flatten_empty_value_continuations(fields)
2164    }
2165
2166    fn raw_fields(&mut self, mapping: &Mapping) -> Vec<ParsedField> {
2167        mapping
2168            .entries()
2169            .filter_map(|entry| {
2170                let key = entry.key_node()?;
2171                let Some(scalar) = key.as_scalar() else {
2172                    let span = node_span(self.source_id, &key)
2173                        .unwrap_or_else(|| span_from_position(self.source_id, mapping.byte_range()));
2174                    self.diagnostics.push(
2175                        Diagnostic::new(EXPECTED_SCALAR, Severity::Error, "Compose mapping keys must be scalars")
2176                            .with_label(DiagnosticLabel::primary(span, "non-scalar key")),
2177                    );
2178                    return None;
2179                };
2180                let name_span = span_from_position(self.source_id, scalar.byte_range());
2181                let authored_value = entry.value_node();
2182                let value_span = authored_value
2183                    .as_ref()
2184                    .and_then(|value| node_span(self.source_id, value));
2185                let value = authored_value.map(unwrap_processing_tag);
2186                let span = value_span.map_or(name_span, |value_span| union(name_span, value_span));
2187                Some(ParsedField {
2188                    name: Located::new(scalar_string_from_source(&self.source, scalar), name_span),
2189                    value,
2190                    value_span,
2191                    span,
2192                })
2193            })
2194            .collect()
2195    }
2196
2197    fn flatten_empty_value_continuations(&mut self, fields: Vec<ParsedField>) -> Vec<ParsedField> {
2198        let Some(target_column) = fields.first().map(|field| self.source_column(field.name.span.start())) else {
2199            return fields;
2200        };
2201        self.recover_fields(fields, target_column)
2202    }
2203
2204    fn recover_fields(&mut self, fields: Vec<ParsedField>, target_column: usize) -> Vec<ParsedField> {
2205        let mut flattened = Vec::new();
2206        for mut field in fields {
2207            let field_column = self.source_column(field.name.span.start());
2208            let nested_mapping = field.value.as_ref().and_then(YamlNode::as_mapping).cloned();
2209            let continuation = nested_mapping.as_ref().is_some_and(|mapping| {
2210                !self.is_flow_mapping(mapping)
2211                    && mapping
2212                        .entries()
2213                        .find_map(|entry| {
2214                            let key = entry.key_node()?;
2215                            let scalar = key.as_scalar()?;
2216                            Some(scalar.byte_range().start as usize)
2217                        })
2218                        .is_some_and(|key_start| self.source_column(key_start) <= field_column)
2219            });
2220
2221            if continuation {
2222                field.value = None;
2223                field.value_span = None;
2224                field.span = field.name.span;
2225            }
2226            if field_column == target_column {
2227                flattened.push(field);
2228            }
2229            if let Some(mapping) = nested_mapping.filter(|mapping| !self.is_flow_mapping(mapping)) {
2230                let nested = self.raw_fields(&mapping);
2231                flattened.extend(self.recover_fields(nested, target_column));
2232            }
2233        }
2234        flattened
2235    }
2236
2237    fn is_flow_mapping(&self, mapping: &Mapping) -> bool {
2238        let position = mapping.byte_range();
2239        self.source
2240            .get(position.start as usize..position.end as usize)
2241            .is_some_and(|text| text.trim_start().starts_with('{'))
2242    }
2243
2244    fn record_duplicate(&mut self, seen: &mut BTreeMap<String, SourceSpan>, field: &ParsedField) -> bool {
2245        if let Some(first) = seen.get(field.name.value()) {
2246            self.diagnostics.push(
2247                Diagnostic::new(
2248                    DUPLICATE_FIELD,
2249                    Severity::Error,
2250                    "Compose mapping fields must be unique",
2251                )
2252                .with_label(DiagnosticLabel::primary(field.name.span, "duplicate field"))
2253                .with_label(DiagnosticLabel::secondary(*first, "first field")),
2254            );
2255            true
2256        } else {
2257            seen.insert(field.name.value.clone(), field.name.span);
2258            false
2259        }
2260    }
2261
2262    fn expected(&mut self, code: DiagnosticCode, field: &ParsedField, message: impl Into<String>) {
2263        self.diagnostics.push(
2264            Diagnostic::new(code, Severity::Error, message)
2265                .with_label(DiagnosticLabel::primary(field.span, "unexpected value form")),
2266        );
2267    }
2268
2269    fn missing(&mut self, code: DiagnosticCode, span: SourceSpan, message: &'static str) {
2270        self.diagnostics.push(
2271            Diagnostic::new(code, Severity::Error, message)
2272                .with_label(DiagnosticLabel::primary(span, "incomplete long syntax")),
2273        );
2274    }
2275}
2276
2277fn unwrap_processing_tag(node: YamlNode) -> YamlNode {
2278    let YamlNode::TaggedNode(tagged) = &node else {
2279        return node;
2280    };
2281    if !matches!(tagged.tag().as_deref(), Some("!reset" | "!override")) {
2282        return node;
2283    }
2284    tagged
2285        .as_node()
2286        .and_then(|syntax| syntax.children().find_map(YamlNode::from_syntax))
2287        .unwrap_or(node)
2288}
2289
2290#[derive(Debug, Clone)]
2291enum ParsedGrant {
2292    Short(Located<String>),
2293    Long(Box<LongGrant>),
2294}
2295
2296#[derive(Debug, Clone)]
2297struct ParsedField {
2298    name: Located<String>,
2299    value: Option<YamlNode>,
2300    value_span: Option<SourceSpan>,
2301    span: SourceSpan,
2302}
2303
2304impl ParsedField {
2305    fn reference(&self) -> FieldReference {
2306        FieldReference {
2307            name: self.name.clone(),
2308            span: self.span,
2309            value_span: self.value_span,
2310        }
2311    }
2312}
2313
2314fn node_span(source_id: SourceId, node: &YamlNode) -> Option<SourceSpan> {
2315    let position = match node {
2316        YamlNode::Scalar(value) => value.byte_range(),
2317        YamlNode::Mapping(value) => value.byte_range(),
2318        YamlNode::Sequence(value) => value.byte_range(),
2319        YamlNode::Alias(_) | YamlNode::TaggedNode(_) => {
2320            let range = node.as_node()?.text_range();
2321            return Some(SourceSpan::from_valid_offsets(
2322                source_id,
2323                u32::from(range.start()) as usize,
2324                u32::from(range.end()) as usize,
2325            ));
2326        }
2327    };
2328    Some(span_from_position(source_id, position))
2329}
2330
2331fn span_from_position(source_id: SourceId, position: yaml_edit::TextPosition) -> SourceSpan {
2332    SourceSpan::from_valid_offsets(source_id, position.start as usize, position.end as usize)
2333}
2334
2335fn union(left: SourceSpan, right: SourceSpan) -> SourceSpan {
2336    SourceSpan::from_valid_offsets(
2337        left.source_id(),
2338        left.start().min(right.start()),
2339        left.end().max(right.end()),
2340    )
2341}