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