1mod include_paths;
4
5use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
6use crate::interpolation::{
7 DocumentInterpolation, EnvironmentProvider, InterpolationOptions, interpolate_document_with_options,
8};
9use crate::merge::{MergedProject, merge_project};
10use crate::model::{
11 ComposeDocument, ConfigDefinition, IncludeItem, Located, ModelDefinition, ModelParse, NetworkDefinition,
12 SecretDefinition, VolumeDefinition,
13};
14use crate::project::{ProjectResource, ProjectService, ProjectView, build_project_view};
15use crate::source::{SourceId, SourceSpan};
16use crate::syntax::{SyntaxDocument, SyntaxParseError};
17use std::collections::BTreeMap;
18use std::error::Error;
19use std::fmt;
20use std::path::{Path, PathBuf};
21use std::sync::Arc;
22
23pub use include_paths::{
24 INCLUDE_PROJECT_DIRECTORY_UNRESOLVED, IncludeProjectDirectoryEntry, IncludeProjectDirectoryPlan,
25 IncludeProjectDirectoryRequest, IncludeProjectDirectoryResolution, IncludeProjectDirectoryResolveError,
26 IncludeProjectDirectoryResolver, IncludeProjectDirectoryStatus,
27};
28
29pub const INCLUDE_UNMODELED: DiagnosticCode = DiagnosticCode::new("compose.include.unmodeled");
31pub const INCLUDE_LOADER_DENIED: DiagnosticCode = DiagnosticCode::new("compose.include.loader-denied");
33pub const INCLUDE_LOADER_FAILED: DiagnosticCode = DiagnosticCode::new("compose.include.loader-failed");
35pub const INCLUDE_EMPTY_RESULT: DiagnosticCode = DiagnosticCode::new("compose.include.empty-result");
37pub const INCLUDE_CYCLE: DiagnosticCode = DiagnosticCode::new("compose.include.cycle");
39pub const INCLUDE_DUPLICATE_SOURCE_ID: DiagnosticCode = DiagnosticCode::new("compose.include.duplicate-source-id");
41pub const INCLUDE_PROJECT_LOAD_FAILED: DiagnosticCode = DiagnosticCode::new("compose.include.project-load-failed");
43pub const INCLUDE_EMPTY_ROOT: DiagnosticCode = DiagnosticCode::new("compose.include.empty-root");
45pub const INCLUDE_RESOURCE_CONFLICT: DiagnosticCode = DiagnosticCode::new("compose.include.resource-conflict");
47
48#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct DocumentOrigin {
55 label: String,
56 directory: PathBuf,
57}
58
59impl DocumentOrigin {
60 #[must_use]
62 pub fn new(label: impl Into<String>, directory: impl Into<PathBuf>) -> Self {
63 Self {
64 label: label.into(),
65 directory: directory.into(),
66 }
67 }
68
69 #[must_use]
71 pub fn label(&self) -> &str {
72 &self.label
73 }
74
75 #[must_use]
77 pub fn directory(&self) -> &Path {
78 &self.directory
79 }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct DocumentInput {
85 source_id: SourceId,
86 origin: DocumentOrigin,
87 source: Arc<str>,
88}
89
90impl DocumentInput {
91 #[must_use]
93 pub fn new(source_id: SourceId, origin: DocumentOrigin, source: impl Into<Arc<str>>) -> Self {
94 Self {
95 source_id,
96 origin,
97 source: source.into(),
98 }
99 }
100
101 #[must_use]
103 pub const fn source_id(&self) -> SourceId {
104 self.source_id
105 }
106
107 #[must_use]
109 pub const fn origin(&self) -> &DocumentOrigin {
110 &self.origin
111 }
112
113 #[must_use]
115 pub fn source_text(&self) -> &str {
116 &self.source
117 }
118}
119
120#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
125pub struct IncludeIdentity(String);
126
127impl IncludeIdentity {
128 #[must_use]
130 pub fn new(canonical: impl Into<String>) -> Self {
131 Self(canonical.into())
132 }
133
134 #[must_use]
136 pub fn as_str(&self) -> &str {
137 &self.0
138 }
139}
140
141impl fmt::Display for IncludeIdentity {
142 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
143 formatter.write_str(&self.0)
144 }
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct IncludedProjectInput {
153 identity: IncludeIdentity,
154 documents: Vec<DocumentInput>,
155}
156
157impl IncludedProjectInput {
158 #[must_use]
160 pub fn new(identity: IncludeIdentity, documents: impl IntoIterator<Item = DocumentInput>) -> Self {
161 Self {
162 identity,
163 documents: documents.into_iter().collect(),
164 }
165 }
166
167 #[must_use]
169 pub const fn identity(&self) -> &IncludeIdentity {
170 &self.identity
171 }
172
173 #[must_use]
175 pub fn documents(&self) -> &[DocumentInput] {
176 &self.documents
177 }
178}
179
180#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct IncludeRequest {
186 parent_identity: IncludeIdentity,
187 parent_base_directory: PathBuf,
188 declaration_span: SourceSpan,
189 declaration_origin: Option<DocumentOrigin>,
190 item: IncludeItem,
191 paths: Vec<Located<String>>,
192 env_files: Vec<Located<String>>,
193 project_directory: Option<Located<String>>,
194}
195
196impl IncludeRequest {
197 fn from_item(
198 parent_identity: IncludeIdentity,
199 parent_base_directory: PathBuf,
200 declaration_origin: Option<DocumentOrigin>,
201 item: IncludeItem,
202 ) -> Option<Self> {
203 let declaration_span = include_item_span(&item)?;
204 let (paths, env_files, project_directory) = match &item {
205 IncludeItem::Short(path) => (vec![path.clone()], Vec::new(), None),
206 IncludeItem::Long(include) if include.unmodeled_fields().is_empty() && !include.paths().is_empty() => (
207 include.paths().to_vec(),
208 include.env_files().to_vec(),
209 include.project_directory().cloned(),
210 ),
211 IncludeItem::Long(_) | IncludeItem::Unmodeled => return None,
212 };
213 Some(Self {
214 parent_identity,
215 parent_base_directory,
216 declaration_span,
217 declaration_origin,
218 item,
219 paths,
220 env_files,
221 project_directory,
222 })
223 }
224
225 #[must_use]
227 pub const fn parent_identity(&self) -> &IncludeIdentity {
228 &self.parent_identity
229 }
230
231 #[must_use]
233 pub fn parent_base_directory(&self) -> &Path {
234 &self.parent_base_directory
235 }
236
237 #[must_use]
239 pub const fn declaration_span(&self) -> SourceSpan {
240 self.declaration_span
241 }
242
243 #[must_use]
245 pub const fn declaration_source_id(&self) -> SourceId {
246 self.declaration_span.source_id()
247 }
248
249 #[must_use]
251 pub const fn declaration_origin(&self) -> Option<&DocumentOrigin> {
252 self.declaration_origin.as_ref()
253 }
254
255 #[must_use]
257 pub const fn item(&self) -> &IncludeItem {
258 &self.item
259 }
260
261 #[must_use]
263 pub fn paths(&self) -> &[Located<String>] {
264 &self.paths
265 }
266
267 #[must_use]
269 pub fn env_files(&self) -> &[Located<String>] {
270 &self.env_files
271 }
272
273 #[must_use]
275 pub const fn project_directory(&self) -> Option<&Located<String>> {
276 self.project_directory.as_ref()
277 }
278}
279
280pub trait IncludeLoader {
282 fn load_include(&self, request: &IncludeRequest) -> Result<IncludedProjectInput, IncludeLoadError>;
292}
293
294#[derive(Debug, Clone, PartialEq, Eq)]
296#[non_exhaustive]
297pub enum IncludeLoadError {
298 Denied(String),
300 Failed(String),
302}
303
304impl IncludeLoadError {
305 #[must_use]
307 pub fn denied(message: impl Into<String>) -> Self {
308 Self::Denied(message.into())
309 }
310
311 #[must_use]
313 pub fn failed(message: impl Into<String>) -> Self {
314 Self::Failed(message.into())
315 }
316}
317
318impl fmt::Display for IncludeLoadError {
319 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
320 match self {
321 Self::Denied(message) | Self::Failed(message) => formatter.write_str(message),
322 }
323 }
324}
325
326impl Error for IncludeLoadError {}
327
328#[derive(Debug, Clone, PartialEq, Eq)]
330pub struct LoadedDocument {
331 origin: DocumentOrigin,
332 syntax: SyntaxDocument,
333 syntax_diagnostics: Vec<Diagnostic>,
334 model: ModelParse,
335}
336
337impl LoadedDocument {
338 #[must_use]
340 pub const fn source_id(&self) -> SourceId {
341 self.syntax.source_id()
342 }
343
344 #[must_use]
346 pub const fn origin(&self) -> &DocumentOrigin {
347 &self.origin
348 }
349
350 #[must_use]
352 pub const fn syntax(&self) -> &SyntaxDocument {
353 &self.syntax
354 }
355
356 #[must_use]
358 pub fn syntax_diagnostics(&self) -> &[Diagnostic] {
359 &self.syntax_diagnostics
360 }
361
362 #[must_use]
364 pub const fn model(&self) -> &ModelParse {
365 &self.model
366 }
367
368 #[must_use]
370 pub fn is_valid(&self) -> bool {
371 self.syntax_diagnostics
372 .iter()
373 .chain(self.model.diagnostics())
374 .all(|diagnostic| diagnostic.severity() != Severity::Error)
375 }
376}
377
378#[derive(Debug, Clone, PartialEq, Eq)]
384pub struct LoadedProject {
385 documents: Vec<LoadedDocument>,
386 base_directory: PathBuf,
387 diagnostics: Vec<Diagnostic>,
388}
389
390impl LoadedProject {
391 pub fn load(inputs: impl IntoIterator<Item = DocumentInput>) -> Result<Self, ProjectLoadError> {
401 let inputs: Vec<_> = inputs.into_iter().collect();
402 let Some(first) = inputs.first() else {
403 return Err(ProjectLoadError::EmptyProject);
404 };
405
406 let mut source_origins = BTreeMap::new();
407 for input in &inputs {
408 if let Some(first_origin) = source_origins.insert(input.source_id, input.origin.label.clone()) {
409 return Err(ProjectLoadError::DuplicateSourceId {
410 source_id: input.source_id,
411 first_origin,
412 duplicate_origin: input.origin.label.clone(),
413 });
414 }
415 }
416
417 let base_directory = first.origin.directory.clone();
418 let mut documents = Vec::with_capacity(inputs.len());
419 let mut diagnostics = Vec::new();
420 for input in inputs {
421 let syntax = SyntaxDocument::parse(input.source_id, input.source).map_err(|error| {
422 ProjectLoadError::SyntaxCapacity {
423 origin: input.origin.clone(),
424 error,
425 }
426 })?;
427 let (syntax, syntax_diagnostics) = syntax.into_parts();
428 let model = ComposeDocument::parse(&syntax);
429 diagnostics.extend(syntax_diagnostics.iter().cloned());
430 diagnostics.extend(model.diagnostics().iter().cloned());
431 documents.push(LoadedDocument {
432 origin: input.origin,
433 syntax,
434 syntax_diagnostics,
435 model,
436 });
437 }
438
439 Ok(Self {
440 documents,
441 base_directory,
442 diagnostics,
443 })
444 }
445
446 #[must_use]
448 pub fn documents(&self) -> &[LoadedDocument] {
449 &self.documents
450 }
451
452 #[must_use]
454 pub fn document(&self, source_id: SourceId) -> Option<&LoadedDocument> {
455 self.documents.iter().find(|document| document.source_id() == source_id)
456 }
457
458 #[must_use]
460 pub fn base_directory(&self) -> &Path {
461 &self.base_directory
462 }
463
464 #[must_use]
466 pub fn diagnostics(&self) -> &[Diagnostic] {
467 &self.diagnostics
468 }
469
470 #[must_use]
472 pub fn is_valid(&self) -> bool {
473 self.diagnostics
474 .iter()
475 .all(|diagnostic| diagnostic.severity() != Severity::Error)
476 }
477
478 #[must_use]
480 pub fn interpolate(&self, environment: &dyn EnvironmentProvider) -> ProjectInterpolation {
481 self.interpolate_with_options(environment, InterpolationOptions::default())
482 }
483
484 #[must_use]
486 pub fn interpolate_with_options(
487 &self,
488 environment: &dyn EnvironmentProvider,
489 options: InterpolationOptions,
490 ) -> ProjectInterpolation {
491 let documents: Vec<_> = self
492 .documents
493 .iter()
494 .map(|document| interpolate_document_with_options(document.syntax(), environment, options))
495 .collect();
496 let diagnostics = documents
497 .iter()
498 .flat_map(|document| document.diagnostics().iter().cloned())
499 .collect();
500 ProjectInterpolation { documents, diagnostics }
501 }
502}
503
504#[derive(Debug, Clone, PartialEq, Eq)]
509pub struct IncludeNode {
510 index: usize,
511 identity: IncludeIdentity,
512 inputs: IncludedProjectInput,
513 origins: Vec<DocumentOrigin>,
514 loaded_project: Option<LoadedProject>,
515 merged_project: Option<MergedProject>,
516 project_view: Option<ProjectView>,
517}
518
519impl IncludeNode {
520 #[must_use]
522 pub const fn index(&self) -> usize {
523 self.index
524 }
525
526 #[must_use]
528 pub const fn identity(&self) -> &IncludeIdentity {
529 &self.identity
530 }
531
532 #[must_use]
534 pub const fn inputs(&self) -> &IncludedProjectInput {
535 &self.inputs
536 }
537
538 #[must_use]
540 pub fn origins(&self) -> &[DocumentOrigin] {
541 &self.origins
542 }
543
544 #[must_use]
546 pub const fn loaded_project(&self) -> Option<&LoadedProject> {
547 self.loaded_project.as_ref()
548 }
549
550 #[must_use]
552 pub const fn merged_project(&self) -> Option<&MergedProject> {
553 self.merged_project.as_ref()
554 }
555
556 #[must_use]
558 pub const fn project_view(&self) -> Option<&ProjectView> {
559 self.project_view.as_ref()
560 }
561}
562
563#[derive(Debug, Clone, PartialEq, Eq)]
565pub struct IncludeEdge {
566 parent: IncludeIdentity,
567 child: IncludeIdentity,
568 parent_node_index: usize,
569 child_node_index: usize,
570 request_index: usize,
571 cycle: bool,
572}
573
574impl IncludeEdge {
575 #[must_use]
577 pub const fn parent(&self) -> &IncludeIdentity {
578 &self.parent
579 }
580
581 #[must_use]
583 pub const fn child(&self) -> &IncludeIdentity {
584 &self.child
585 }
586
587 #[must_use]
589 pub const fn parent_node_index(&self) -> usize {
590 self.parent_node_index
591 }
592
593 #[must_use]
598 pub const fn child_node_index(&self) -> usize {
599 self.child_node_index
600 }
601
602 #[must_use]
604 pub const fn request_index(&self) -> usize {
605 self.request_index
606 }
607
608 #[must_use]
610 pub const fn is_cycle(&self) -> bool {
611 self.cycle
612 }
613}
614
615#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
617#[non_exhaustive]
618pub enum IncludeResourceNamespace {
619 Services,
621 Networks,
623 Volumes,
625 Configs,
627 Secrets,
629 Models,
631}
632
633impl IncludeResourceNamespace {
634 const fn as_str(self) -> &'static str {
635 match self {
636 Self::Services => "service",
637 Self::Networks => "network",
638 Self::Volumes => "volume",
639 Self::Configs => "config",
640 Self::Secrets => "secret",
641 Self::Models => "model",
642 }
643 }
644}
645
646#[derive(Debug, Clone, PartialEq, Eq)]
648pub struct IncludeDefinitionEvidence {
649 occurrence_index: usize,
650 identity: IncludeIdentity,
651 source: Option<SourceSpan>,
652 source_label: Option<String>,
653}
654
655impl IncludeDefinitionEvidence {
656 #[must_use]
658 pub const fn occurrence_index(&self) -> usize {
659 self.occurrence_index
660 }
661
662 #[must_use]
664 pub const fn identity(&self) -> &IncludeIdentity {
665 &self.identity
666 }
667
668 #[must_use]
670 pub const fn source(&self) -> Option<SourceSpan> {
671 self.source
672 }
673
674 #[must_use]
676 pub fn source_label(&self) -> Option<&str> {
677 self.source_label.as_deref()
678 }
679}
680
681#[derive(Debug, Clone, PartialEq, Eq)]
683pub struct IncludeDefinition<T> {
684 name: String,
685 definition: T,
686 evidence: IncludeDefinitionEvidence,
687}
688
689impl<T> IncludeDefinition<T> {
690 #[must_use]
692 pub fn name(&self) -> &str {
693 &self.name
694 }
695
696 #[must_use]
698 pub const fn definition(&self) -> &T {
699 &self.definition
700 }
701
702 #[must_use]
704 pub const fn evidence(&self) -> &IncludeDefinitionEvidence {
705 &self.evidence
706 }
707}
708
709#[derive(Debug, Clone, PartialEq, Eq)]
711pub struct IncludeResourceConflict {
712 namespace: IncludeResourceNamespace,
713 name: String,
714 edge_index: usize,
715 incoming: IncludeDefinitionEvidence,
716 incumbent: IncludeDefinitionEvidence,
717}
718
719impl IncludeResourceConflict {
720 #[must_use]
722 pub const fn namespace(&self) -> IncludeResourceNamespace {
723 self.namespace
724 }
725
726 #[must_use]
728 pub fn name(&self) -> &str {
729 &self.name
730 }
731
732 #[must_use]
734 pub const fn edge_index(&self) -> usize {
735 self.edge_index
736 }
737
738 #[must_use]
740 pub const fn incoming(&self) -> &IncludeDefinitionEvidence {
741 &self.incoming
742 }
743
744 #[must_use]
746 pub const fn incumbent(&self) -> &IncludeDefinitionEvidence {
747 &self.incumbent
748 }
749}
750
751#[derive(Debug, Clone, PartialEq, Eq)]
753pub struct IncludeComposition {
754 node_index: usize,
755 services: Vec<IncludeDefinition<ProjectService>>,
756 networks: Vec<IncludeDefinition<NetworkDefinition>>,
757 volumes: Vec<IncludeDefinition<VolumeDefinition>>,
758 configs: Vec<IncludeDefinition<ConfigDefinition>>,
759 secrets: Vec<IncludeDefinition<SecretDefinition>>,
760 models: Vec<IncludeDefinition<ModelDefinition>>,
761}
762
763impl IncludeComposition {
764 #[must_use]
766 pub const fn node_index(&self) -> usize {
767 self.node_index
768 }
769
770 #[must_use]
772 pub fn services(&self) -> &[IncludeDefinition<ProjectService>] {
773 &self.services
774 }
775
776 #[must_use]
778 pub fn service(&self, name: &str) -> Option<&IncludeDefinition<ProjectService>> {
779 self.services.iter().find(|definition| definition.name == name)
780 }
781
782 #[must_use]
784 pub fn networks(&self) -> &[IncludeDefinition<NetworkDefinition>] {
785 &self.networks
786 }
787
788 #[must_use]
790 pub fn network(&self, name: &str) -> Option<&IncludeDefinition<NetworkDefinition>> {
791 self.networks.iter().find(|definition| definition.name == name)
792 }
793
794 #[must_use]
796 pub fn volumes(&self) -> &[IncludeDefinition<VolumeDefinition>] {
797 &self.volumes
798 }
799
800 #[must_use]
802 pub fn volume(&self, name: &str) -> Option<&IncludeDefinition<VolumeDefinition>> {
803 self.volumes.iter().find(|definition| definition.name == name)
804 }
805
806 #[must_use]
808 pub fn configs(&self) -> &[IncludeDefinition<ConfigDefinition>] {
809 &self.configs
810 }
811
812 #[must_use]
814 pub fn config(&self, name: &str) -> Option<&IncludeDefinition<ConfigDefinition>> {
815 self.configs.iter().find(|definition| definition.name == name)
816 }
817
818 #[must_use]
820 pub fn secrets(&self) -> &[IncludeDefinition<SecretDefinition>] {
821 &self.secrets
822 }
823
824 #[must_use]
826 pub fn secret(&self, name: &str) -> Option<&IncludeDefinition<SecretDefinition>> {
827 self.secrets.iter().find(|definition| definition.name == name)
828 }
829
830 #[must_use]
832 pub fn models(&self) -> &[IncludeDefinition<ModelDefinition>] {
833 &self.models
834 }
835
836 #[must_use]
838 pub fn model(&self, name: &str) -> Option<&IncludeDefinition<ModelDefinition>> {
839 self.models.iter().find(|definition| definition.name == name)
840 }
841}
842
843#[derive(Debug, Clone, PartialEq, Eq)]
845pub struct IncludeCompositionResult {
846 compositions: Vec<IncludeComposition>,
847 diagnostics: Vec<Diagnostic>,
848 conflicts: Vec<IncludeResourceConflict>,
849}
850
851impl IncludeCompositionResult {
852 #[must_use]
854 pub fn root(&self) -> Option<&IncludeComposition> {
855 self.compositions.first()
856 }
857
858 #[must_use]
860 pub fn compositions(&self) -> &[IncludeComposition] {
861 &self.compositions
862 }
863
864 #[must_use]
866 pub fn composition(&self, node_index: usize) -> Option<&IncludeComposition> {
867 self.compositions.get(node_index)
868 }
869
870 #[must_use]
872 pub fn diagnostics(&self) -> &[Diagnostic] {
873 &self.diagnostics
874 }
875
876 #[must_use]
878 pub fn conflicts(&self) -> &[IncludeResourceConflict] {
879 &self.conflicts
880 }
881
882 #[must_use]
884 pub fn is_valid(&self) -> bool {
885 self.diagnostics
886 .iter()
887 .all(|diagnostic| diagnostic.severity() != Severity::Error)
888 }
889
890 #[must_use]
892 pub fn is_complete(&self) -> bool {
893 self.is_valid() && self.conflicts.is_empty()
894 }
895}
896
897#[derive(Debug, Clone, PartialEq, Eq)]
899pub struct IncludeResolution {
900 root: IncludeIdentity,
901 nodes: Vec<IncludeNode>,
902 edges: Vec<IncludeEdge>,
903 requests: Vec<IncludeRequest>,
904 diagnostics: Vec<Diagnostic>,
905}
906
907impl IncludeResolution {
908 #[must_use]
916 pub fn load(root: IncludedProjectInput, loader: &dyn IncludeLoader) -> Self {
917 let root_identity = root.identity().clone();
918 let mut resolution = Self {
919 root: root_identity.clone(),
920 nodes: Vec::new(),
921 edges: Vec::new(),
922 requests: Vec::new(),
923 diagnostics: Vec::new(),
924 };
925 let mut active = Vec::new();
926 let mut source_origins = BTreeMap::new();
927 visit_project(root, loader, &mut active, &mut source_origins, &mut resolution, None);
928 resolution
929 }
930
931 #[must_use]
933 pub const fn root(&self) -> &IncludeIdentity {
934 &self.root
935 }
936
937 #[must_use]
939 pub fn nodes(&self) -> &[IncludeNode] {
940 &self.nodes
941 }
942
943 #[must_use]
945 pub fn edges(&self) -> &[IncludeEdge] {
946 &self.edges
947 }
948
949 #[must_use]
951 pub fn requests(&self) -> &[IncludeRequest] {
952 &self.requests
953 }
954
955 #[must_use]
957 pub fn diagnostics(&self) -> &[Diagnostic] {
958 &self.diagnostics
959 }
960
961 #[must_use]
967 pub fn compose(&self) -> IncludeCompositionResult {
968 let mut diagnostics = self.diagnostics.clone();
969 let mut conflicts = Vec::new();
970 let mut compositions = vec![None; self.nodes.len()];
971 for node_index in 0..self.nodes.len() {
972 let _ = compose_node(node_index, self, &mut compositions, &mut diagnostics, &mut conflicts);
973 }
974 IncludeCompositionResult {
975 compositions: compositions.into_iter().flatten().collect(),
976 diagnostics,
977 conflicts,
978 }
979 }
980
981 #[must_use]
986 pub fn plan_project_directories(
987 &self,
988 resolver: &dyn IncludeProjectDirectoryResolver,
989 ) -> IncludeProjectDirectoryPlan {
990 include_paths::plan_project_directories(self, resolver)
991 }
992
993 #[must_use]
995 pub fn is_valid(&self) -> bool {
996 self.diagnostics
997 .iter()
998 .all(|diagnostic| diagnostic.severity() != Severity::Error)
999 }
1000}
1001
1002fn visit_project(
1003 input: IncludedProjectInput,
1004 include_loader: &dyn IncludeLoader,
1005 active: &mut Vec<(IncludeIdentity, usize)>,
1006 source_origins: &mut BTreeMap<SourceId, DocumentOrigin>,
1007 resolution: &mut IncludeResolution,
1008 request_span: Option<SourceSpan>,
1009) -> usize {
1010 if input.documents().is_empty() {
1011 resolution.diagnostics.push(include_diagnostic(
1012 if request_span.is_some() {
1013 INCLUDE_EMPTY_RESULT
1014 } else {
1015 INCLUDE_EMPTY_ROOT
1016 },
1017 "included Compose project contains no documents",
1018 request_span,
1019 ));
1020 retain_unloaded_node(input, resolution);
1021 return resolution.nodes.len() - 1;
1022 }
1023
1024 if !register_source_ids(input.documents(), source_origins, resolution, request_span) {
1025 retain_unloaded_node(input, resolution);
1026 return resolution.nodes.len() - 1;
1027 }
1028
1029 let Some(prepared) = load_include_node(input, resolution, request_span) else {
1030 return resolution.nodes.len() - 1;
1031 };
1032 let PreparedIncludeNode {
1033 identity,
1034 base_directory,
1035 items,
1036 node_index,
1037 } = prepared;
1038 active.push((identity.clone(), node_index));
1039
1040 visit_includes(
1041 items,
1042 &identity,
1043 &base_directory,
1044 node_index,
1045 include_loader,
1046 (active, source_origins, resolution),
1047 );
1048
1049 let popped = active.pop();
1050 debug_assert_eq!(popped.as_ref().map(|(identity, _)| identity), Some(&identity));
1051 node_index
1052}
1053
1054fn visit_includes(
1055 items: Vec<IncludeItem>,
1056 identity: &IncludeIdentity,
1057 base_directory: &Path,
1058 node_index: usize,
1059 include_loader: &dyn IncludeLoader,
1060 state: (
1061 &mut Vec<(IncludeIdentity, usize)>,
1062 &mut BTreeMap<SourceId, DocumentOrigin>,
1063 &mut IncludeResolution,
1064 ),
1065) {
1066 let (active, source_origins, resolution) = state;
1067 for item in items {
1068 let declaration_span = include_item_span(&item);
1069 let declaration_origin = declaration_span.and_then(|span| {
1070 resolution.nodes[node_index]
1071 .inputs()
1072 .documents()
1073 .iter()
1074 .find(|document| document.source_id() == span.source_id())
1075 .map(|document| document.origin().clone())
1076 });
1077 let Some(request) =
1078 IncludeRequest::from_item(identity.clone(), base_directory.to_path_buf(), declaration_origin, item)
1079 else {
1080 resolution.diagnostics.push(include_diagnostic(
1081 INCLUDE_UNMODELED,
1082 "effective include declaration is malformed or contains unmodeled members",
1083 declaration_span,
1084 ));
1085 continue;
1086 };
1087 let request_index = resolution.requests.len();
1088 let request_span = request.declaration_span();
1089 let child = match include_loader.load_include(&request) {
1090 Ok(child) => child,
1091 Err(IncludeLoadError::Denied(_)) => {
1092 resolution.diagnostics.push(include_diagnostic(
1093 INCLUDE_LOADER_DENIED,
1094 "include loader denied this declaration",
1095 Some(request_span),
1096 ));
1097 resolution.requests.push(request);
1098 continue;
1099 }
1100 Err(IncludeLoadError::Failed(_)) => {
1101 resolution.diagnostics.push(include_diagnostic(
1102 INCLUDE_LOADER_FAILED,
1103 "include loader failed to supply this declaration",
1104 Some(request_span),
1105 ));
1106 resolution.requests.push(request);
1107 continue;
1108 }
1109 };
1110 let child_identity = child.identity().clone();
1111 resolution.requests.push(request);
1112 let edge_index = resolution.edges.len();
1113 if let Some((_, active_node_index)) = active
1114 .iter()
1115 .find(|(active_identity, _)| active_identity == &child_identity)
1116 {
1117 resolution.edges.push(IncludeEdge {
1118 parent: identity.clone(),
1119 child: child_identity,
1120 parent_node_index: node_index,
1121 child_node_index: *active_node_index,
1122 request_index,
1123 cycle: true,
1124 });
1125 resolution.diagnostics.push(
1126 include_diagnostic(
1127 INCLUDE_CYCLE,
1128 "include identity is already active in this traversal",
1129 Some(request_span),
1130 )
1131 .with_note("the caller controls identity canonicalization"),
1132 );
1133 continue;
1134 }
1135 let child_node_index = visit_project(
1136 child,
1137 include_loader,
1138 active,
1139 source_origins,
1140 resolution,
1141 Some(request_span),
1142 );
1143 resolution.edges.insert(
1144 edge_index,
1145 IncludeEdge {
1146 parent: identity.clone(),
1147 child: child_identity,
1148 parent_node_index: node_index,
1149 child_node_index,
1150 request_index,
1151 cycle: false,
1152 },
1153 );
1154 }
1155}
1156
1157struct PreparedIncludeNode {
1158 identity: IncludeIdentity,
1159 base_directory: PathBuf,
1160 items: Vec<IncludeItem>,
1161 node_index: usize,
1162}
1163
1164fn load_include_node(
1165 input: IncludedProjectInput,
1166 resolution: &mut IncludeResolution,
1167 request_span: Option<SourceSpan>,
1168) -> Option<PreparedIncludeNode> {
1169 let identity = input.identity().clone();
1170 let origins = input
1171 .documents()
1172 .iter()
1173 .map(|document| document.origin().clone())
1174 .collect();
1175 let Ok(project) = LoadedProject::load(input.documents().iter().cloned()) else {
1176 resolution.diagnostics.push(include_diagnostic(
1177 INCLUDE_PROJECT_LOAD_FAILED,
1178 "included Compose project could not enter the ordered loader",
1179 request_span,
1180 ));
1181 resolution.nodes.push(IncludeNode {
1182 index: resolution.nodes.len(),
1183 identity,
1184 inputs: input,
1185 origins,
1186 loaded_project: None,
1187 merged_project: None,
1188 project_view: None,
1189 });
1190 return None;
1191 };
1192
1193 let merge = merge_project(&project, None);
1194 resolution.diagnostics.extend(merge.diagnostics().iter().cloned());
1195 let merged_project = merge.project().cloned();
1196 let (project_view, view_diagnostics) = match merged_project.as_ref() {
1197 Some(merged) => build_project_view(merged, None).into_parts(),
1198 None => (None, Vec::new()),
1199 };
1200 resolution.diagnostics.extend(view_diagnostics);
1201 let base_directory = project.base_directory().to_path_buf();
1202 let items = project_view
1203 .as_ref()
1204 .and_then(ProjectView::include)
1205 .map(|includes| includes.value().items().to_vec())
1206 .unwrap_or_default();
1207 let node_index = resolution.nodes.len();
1208 resolution.nodes.push(IncludeNode {
1209 index: node_index,
1210 identity: identity.clone(),
1211 inputs: input,
1212 origins,
1213 loaded_project: Some(project),
1214 merged_project,
1215 project_view,
1216 });
1217 Some(PreparedIncludeNode {
1218 identity,
1219 base_directory,
1220 items,
1221 node_index,
1222 })
1223}
1224
1225fn retain_unloaded_node(input: IncludedProjectInput, resolution: &mut IncludeResolution) {
1226 let origins = input
1227 .documents()
1228 .iter()
1229 .map(|document| document.origin().clone())
1230 .collect();
1231 resolution.nodes.push(IncludeNode {
1232 index: resolution.nodes.len(),
1233 identity: input.identity().clone(),
1234 inputs: input,
1235 origins,
1236 loaded_project: None,
1237 merged_project: None,
1238 project_view: None,
1239 });
1240}
1241
1242fn register_source_ids(
1243 documents: &[DocumentInput],
1244 source_origins: &mut BTreeMap<SourceId, DocumentOrigin>,
1245 resolution: &mut IncludeResolution,
1246 request_span: Option<SourceSpan>,
1247) -> bool {
1248 let mut accepted = true;
1249 for document in documents {
1250 match source_origins.entry(document.source_id()) {
1251 std::collections::btree_map::Entry::Occupied(_) => {
1252 resolution.diagnostics.push(include_diagnostic(
1253 INCLUDE_DUPLICATE_SOURCE_ID,
1254 "include traversal reused a caller-managed source identifier",
1255 request_span,
1256 ));
1257 accepted = false;
1258 }
1259 std::collections::btree_map::Entry::Vacant(entry) => {
1260 entry.insert(document.origin().clone());
1261 }
1262 }
1263 }
1264 accepted
1265}
1266
1267fn include_item_span(item: &IncludeItem) -> Option<SourceSpan> {
1268 match item {
1269 IncludeItem::Short(path) => Some(path.span()),
1270 IncludeItem::Long(include) => Some(include.span()),
1271 IncludeItem::Unmodeled => None,
1272 }
1273}
1274
1275fn include_diagnostic(code: DiagnosticCode, message: &'static str, span: Option<SourceSpan>) -> Diagnostic {
1276 let diagnostic = Diagnostic::new(code, Severity::Error, message);
1277 match span {
1278 Some(span) => diagnostic.with_label(DiagnosticLabel::primary(span, "include declaration")),
1279 None => diagnostic,
1280 }
1281}
1282
1283fn compose_node(
1284 node_index: usize,
1285 resolution: &IncludeResolution,
1286 compositions: &mut [Option<IncludeComposition>],
1287 diagnostics: &mut Vec<Diagnostic>,
1288 conflicts: &mut Vec<IncludeResourceConflict>,
1289) -> IncludeComposition {
1290 if let Some(composition) = &compositions[node_index] {
1291 return composition.clone();
1292 }
1293
1294 let mut composition = local_composition(node_index, resolution);
1295 for (edge_index, edge) in resolution.edges.iter().enumerate() {
1296 if edge.parent_node_index != node_index || edge.cycle {
1297 continue;
1298 }
1299 let child = compose_node(edge.child_node_index, resolution, compositions, diagnostics, conflicts);
1300 import_definitions(
1301 IncludeResourceNamespace::Services,
1302 edge_index,
1303 &mut composition.services,
1304 &child.services,
1305 edge,
1306 diagnostics,
1307 conflicts,
1308 );
1309 import_definitions(
1310 IncludeResourceNamespace::Networks,
1311 edge_index,
1312 &mut composition.networks,
1313 &child.networks,
1314 edge,
1315 diagnostics,
1316 conflicts,
1317 );
1318 import_definitions(
1319 IncludeResourceNamespace::Volumes,
1320 edge_index,
1321 &mut composition.volumes,
1322 &child.volumes,
1323 edge,
1324 diagnostics,
1325 conflicts,
1326 );
1327 import_definitions(
1328 IncludeResourceNamespace::Configs,
1329 edge_index,
1330 &mut composition.configs,
1331 &child.configs,
1332 edge,
1333 diagnostics,
1334 conflicts,
1335 );
1336 import_definitions(
1337 IncludeResourceNamespace::Secrets,
1338 edge_index,
1339 &mut composition.secrets,
1340 &child.secrets,
1341 edge,
1342 diagnostics,
1343 conflicts,
1344 );
1345 import_definitions(
1346 IncludeResourceNamespace::Models,
1347 edge_index,
1348 &mut composition.models,
1349 &child.models,
1350 edge,
1351 diagnostics,
1352 conflicts,
1353 );
1354 }
1355 compositions[node_index] = Some(composition.clone());
1356 composition
1357}
1358
1359fn local_composition(node_index: usize, resolution: &IncludeResolution) -> IncludeComposition {
1360 let node = &resolution.nodes[node_index];
1361 let Some(view) = node.project_view() else {
1362 return IncludeComposition {
1363 node_index,
1364 services: Vec::new(),
1365 networks: Vec::new(),
1366 volumes: Vec::new(),
1367 configs: Vec::new(),
1368 secrets: Vec::new(),
1369 models: Vec::new(),
1370 };
1371 };
1372
1373 IncludeComposition {
1374 node_index,
1375 services: view
1376 .services()
1377 .iter()
1378 .map(|service| {
1379 local_definition(
1380 service.name().value(),
1381 service.clone(),
1382 node_index,
1383 node,
1384 service
1385 .provenance()
1386 .effective_source()
1387 .or_else(|| service.name().effective_source()),
1388 )
1389 })
1390 .collect(),
1391 networks: local_resources(view.networks(), node_index, node),
1392 volumes: local_resources(view.volumes(), node_index, node),
1393 configs: local_resources(view.configs(), node_index, node),
1394 secrets: local_resources(view.secrets(), node_index, node),
1395 models: view
1396 .models()
1397 .into_iter()
1398 .flat_map(|models| models.value().definitions())
1399 .map(|model| local_definition(model.key().value(), model.clone(), node_index, node, Some(model.span())))
1400 .collect(),
1401 }
1402}
1403
1404fn local_resources<T: Clone>(
1405 resources: &[ProjectResource<T>],
1406 node_index: usize,
1407 node: &IncludeNode,
1408) -> Vec<IncludeDefinition<T>> {
1409 resources
1410 .iter()
1411 .map(|resource| {
1412 local_definition(
1413 resource.name().value(),
1414 resource.definition().value().clone(),
1415 node_index,
1416 node,
1417 resource
1418 .definition()
1419 .effective_source()
1420 .or_else(|| resource.name().effective_source()),
1421 )
1422 })
1423 .collect()
1424}
1425
1426fn local_definition<T>(
1427 name: &str,
1428 definition: T,
1429 node_index: usize,
1430 node: &IncludeNode,
1431 source: Option<SourceSpan>,
1432) -> IncludeDefinition<T> {
1433 let source_label = source.and_then(|source| {
1434 node.origins()
1435 .iter()
1436 .zip(node.inputs().documents())
1437 .find(|(_, input)| input.source_id() == source.source_id())
1438 .map(|(origin, _)| origin.label().to_owned())
1439 });
1440 IncludeDefinition {
1441 name: name.to_owned(),
1442 definition,
1443 evidence: IncludeDefinitionEvidence {
1444 occurrence_index: node_index,
1445 identity: node.identity().clone(),
1446 source,
1447 source_label,
1448 },
1449 }
1450}
1451
1452fn import_definitions<T: Clone>(
1453 namespace: IncludeResourceNamespace,
1454 edge_index: usize,
1455 parent: &mut Vec<IncludeDefinition<T>>,
1456 child: &[IncludeDefinition<T>],
1457 edge: &IncludeEdge,
1458 diagnostics: &mut Vec<Diagnostic>,
1459 conflicts: &mut Vec<IncludeResourceConflict>,
1460) {
1461 for incoming in child {
1462 if let Some(incumbent) = parent.iter().find(|candidate| candidate.name == incoming.name) {
1463 let conflict = IncludeResourceConflict {
1464 namespace,
1465 name: incoming.name.clone(),
1466 edge_index,
1467 incoming: incoming.evidence.clone(),
1468 incumbent: incumbent.evidence.clone(),
1469 };
1470 diagnostics.push(include_resource_conflict_diagnostic(&conflict, edge));
1471 conflicts.push(conflict);
1472 } else {
1473 parent.push(incoming.clone());
1474 }
1475 }
1476}
1477
1478fn include_resource_conflict_diagnostic(conflict: &IncludeResourceConflict, edge: &IncludeEdge) -> Diagnostic {
1479 let mut diagnostic = Diagnostic::new(
1480 INCLUDE_RESOURCE_CONFLICT,
1481 Severity::Warning,
1482 format!(
1483 "included {} `{}` conflicts with an already selected definition",
1484 conflict.namespace.as_str(),
1485 conflict.name
1486 ),
1487 );
1488 if let Some(source) = conflict.incoming.source {
1489 diagnostic = diagnostic.with_label(DiagnosticLabel::primary(
1490 source,
1491 format!(
1492 "incoming {} `{}` from {}",
1493 conflict.namespace.as_str(),
1494 conflict.name,
1495 conflict.incoming.source_label().unwrap_or("included occurrence")
1496 ),
1497 ));
1498 }
1499 if let Some(source) = conflict.incumbent.source {
1500 diagnostic = diagnostic.with_label(DiagnosticLabel::secondary(
1501 source,
1502 format!(
1503 "incumbent {} `{}` from {}",
1504 conflict.namespace.as_str(),
1505 conflict.name,
1506 conflict.incumbent.source_label().unwrap_or("parent occurrence")
1507 ),
1508 ));
1509 }
1510 diagnostic.with_note(format!(
1511 "declaring include edge #{}, occurrence #{} ({}) -> #{} ({})",
1512 conflict.edge_index, edge.parent_node_index, edge.parent, edge.child_node_index, edge.child
1513 ))
1514}
1515
1516#[derive(Debug, Clone, PartialEq, Eq)]
1518pub struct ProjectInterpolation {
1519 documents: Vec<DocumentInterpolation>,
1520 diagnostics: Vec<Diagnostic>,
1521}
1522
1523impl ProjectInterpolation {
1524 #[must_use]
1526 pub fn documents(&self) -> &[DocumentInterpolation] {
1527 &self.documents
1528 }
1529
1530 #[must_use]
1532 pub fn document(&self, source_id: SourceId) -> Option<&DocumentInterpolation> {
1533 self.documents.iter().find(|document| document.source_id() == source_id)
1534 }
1535
1536 #[must_use]
1538 pub fn diagnostics(&self) -> &[Diagnostic] {
1539 &self.diagnostics
1540 }
1541
1542 #[must_use]
1544 pub fn is_valid(&self) -> bool {
1545 self.diagnostics
1546 .iter()
1547 .all(|diagnostic| diagnostic.severity() != Severity::Error)
1548 }
1549}
1550
1551#[derive(Debug, Clone, PartialEq, Eq)]
1553pub enum ProjectLoadError {
1554 EmptyProject,
1556 DuplicateSourceId {
1558 source_id: SourceId,
1560 first_origin: String,
1562 duplicate_origin: String,
1564 },
1565 SyntaxCapacity {
1567 origin: DocumentOrigin,
1569 error: SyntaxParseError,
1571 },
1572}
1573
1574impl fmt::Display for ProjectLoadError {
1575 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1576 match self {
1577 Self::EmptyProject => formatter.write_str("a Compose project requires at least one document"),
1578 Self::DuplicateSourceId {
1579 source_id,
1580 first_origin,
1581 duplicate_origin,
1582 } => write!(
1583 formatter,
1584 "{source_id} is assigned to both `{first_origin}` and `{duplicate_origin}`"
1585 ),
1586 Self::SyntaxCapacity { origin, error } => write!(formatter, "{}: {error}", origin.label),
1587 }
1588 }
1589}
1590
1591impl Error for ProjectLoadError {
1592 fn source(&self) -> Option<&(dyn Error + 'static)> {
1593 match self {
1594 Self::SyntaxCapacity { error, .. } => Some(error),
1595 Self::EmptyProject | Self::DuplicateSourceId { .. } => None,
1596 }
1597 }
1598}