Skip to main content

compose_lens/loader/
mod.rs

1//! Ordered, caller-supplied Compose project loading.
2
3use crate::diagnostic::{Diagnostic, Severity};
4use crate::interpolation::{
5    DocumentInterpolation, EnvironmentProvider, InterpolationOptions, interpolate_document_with_options,
6};
7use crate::model::{ComposeDocument, ModelParse};
8use crate::source::SourceId;
9use crate::syntax::{SyntaxDocument, SyntaxParseError};
10use std::collections::BTreeMap;
11use std::error::Error;
12use std::fmt;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15
16/// The caller-defined location of one Compose document.
17///
18/// The label is for display and may be a path, URI, or synthetic name. The document directory is
19/// retained verbatim for later path-resolution decisions; `ComposeLens` does not canonicalize it or
20/// access the file system.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct DocumentOrigin {
23    label: String,
24    directory: PathBuf,
25}
26
27impl DocumentOrigin {
28    /// Creates an explicit document origin.
29    #[must_use]
30    pub fn new(label: impl Into<String>, directory: impl Into<PathBuf>) -> Self {
31        Self {
32            label: label.into(),
33            directory: directory.into(),
34        }
35    }
36
37    /// Returns the caller-defined display label.
38    #[must_use]
39    pub fn label(&self) -> &str {
40        &self.label
41    }
42
43    /// Returns the caller-supplied directory associated with this document.
44    #[must_use]
45    pub fn directory(&self) -> &Path {
46        &self.directory
47    }
48}
49
50/// One source document supplied to the project loader.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct DocumentInput {
53    source_id: SourceId,
54    origin: DocumentOrigin,
55    source: Arc<str>,
56}
57
58impl DocumentInput {
59    /// Creates an input without reading the file system or process environment.
60    #[must_use]
61    pub fn new(source_id: SourceId, origin: DocumentOrigin, source: impl Into<Arc<str>>) -> Self {
62        Self {
63            source_id,
64            origin,
65            source: source.into(),
66        }
67    }
68
69    /// Returns the caller-managed source identifier.
70    #[must_use]
71    pub const fn source_id(&self) -> SourceId {
72        self.source_id
73    }
74
75    /// Returns the caller-defined document origin.
76    #[must_use]
77    pub const fn origin(&self) -> &DocumentOrigin {
78        &self.origin
79    }
80
81    /// Returns the supplied source text.
82    #[must_use]
83    pub fn source_text(&self) -> &str {
84        &self.source
85    }
86}
87
88/// One parsed document in an ordered Compose project.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct LoadedDocument {
91    origin: DocumentOrigin,
92    syntax: SyntaxDocument,
93    syntax_diagnostics: Vec<Diagnostic>,
94    model: ModelParse,
95}
96
97impl LoadedDocument {
98    /// Returns the document's source identifier.
99    #[must_use]
100    pub const fn source_id(&self) -> SourceId {
101        self.syntax.source_id()
102    }
103
104    /// Returns the explicit origin retained for this document.
105    #[must_use]
106    pub const fn origin(&self) -> &DocumentOrigin {
107        &self.origin
108    }
109
110    /// Returns the loss-aware syntax document.
111    #[must_use]
112    pub const fn syntax(&self) -> &SyntaxDocument {
113        &self.syntax
114    }
115
116    /// Returns recoverable YAML syntax diagnostics.
117    #[must_use]
118    pub fn syntax_diagnostics(&self) -> &[Diagnostic] {
119        &self.syntax_diagnostics
120    }
121
122    /// Returns the recoverable typed-model parse result.
123    #[must_use]
124    pub const fn model(&self) -> &ModelParse {
125        &self.model
126    }
127
128    /// Reports whether syntax and typed-model parsing emitted no error diagnostics.
129    #[must_use]
130    pub fn is_valid(&self) -> bool {
131        self.syntax_diagnostics
132            .iter()
133            .chain(self.model.diagnostics())
134            .all(|diagnostic| diagnostic.severity() != Severity::Error)
135    }
136}
137
138/// An ordered set of parsed Compose documents and their path origins.
139///
140/// File order is semantically significant. The first document supplies the project directory used
141/// by Compose's multi-file relative-path rules, while every document retains its own origin for
142/// provenance and future `include` support.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct LoadedProject {
145    documents: Vec<LoadedDocument>,
146    base_directory: PathBuf,
147    diagnostics: Vec<Diagnostic>,
148}
149
150impl LoadedProject {
151    /// Parses ordered, caller-supplied documents into a loaded project.
152    ///
153    /// Recoverable YAML and typed-model problems remain available in [`Self::diagnostics`]. No
154    /// interpolation or merge is performed by this operation.
155    ///
156    /// # Errors
157    ///
158    /// Returns [`ProjectLoadError`] when no document is supplied, a source identifier is reused, or
159    /// one source exceeds the syntax tree's byte-offset capacity.
160    pub fn load(inputs: impl IntoIterator<Item = DocumentInput>) -> Result<Self, ProjectLoadError> {
161        let inputs: Vec<_> = inputs.into_iter().collect();
162        let Some(first) = inputs.first() else {
163            return Err(ProjectLoadError::EmptyProject);
164        };
165
166        let mut source_origins = BTreeMap::new();
167        for input in &inputs {
168            if let Some(first_origin) = source_origins.insert(input.source_id, input.origin.label.clone()) {
169                return Err(ProjectLoadError::DuplicateSourceId {
170                    source_id: input.source_id,
171                    first_origin,
172                    duplicate_origin: input.origin.label.clone(),
173                });
174            }
175        }
176
177        let base_directory = first.origin.directory.clone();
178        let mut documents = Vec::with_capacity(inputs.len());
179        let mut diagnostics = Vec::new();
180        for input in inputs {
181            let syntax = SyntaxDocument::parse(input.source_id, input.source).map_err(|error| {
182                ProjectLoadError::SyntaxCapacity {
183                    origin: input.origin.clone(),
184                    error,
185                }
186            })?;
187            let (syntax, syntax_diagnostics) = syntax.into_parts();
188            let model = ComposeDocument::parse(&syntax);
189            diagnostics.extend(syntax_diagnostics.iter().cloned());
190            diagnostics.extend(model.diagnostics().iter().cloned());
191            documents.push(LoadedDocument {
192                origin: input.origin,
193                syntax,
194                syntax_diagnostics,
195                model,
196            });
197        }
198
199        Ok(Self {
200            documents,
201            base_directory,
202            diagnostics,
203        })
204    }
205
206    /// Returns documents in caller-supplied merge order.
207    #[must_use]
208    pub fn documents(&self) -> &[LoadedDocument] {
209        &self.documents
210    }
211
212    /// Finds a document by its unique source identifier.
213    #[must_use]
214    pub fn document(&self, source_id: SourceId) -> Option<&LoadedDocument> {
215        self.documents.iter().find(|document| document.source_id() == source_id)
216    }
217
218    /// Returns the project directory inherited from the first document.
219    #[must_use]
220    pub fn base_directory(&self) -> &Path {
221        &self.base_directory
222    }
223
224    /// Returns aggregated syntax and typed-model diagnostics in document order.
225    #[must_use]
226    pub fn diagnostics(&self) -> &[Diagnostic] {
227        &self.diagnostics
228    }
229
230    /// Reports whether loading emitted no error diagnostics.
231    #[must_use]
232    pub fn is_valid(&self) -> bool {
233        self.diagnostics
234            .iter()
235            .all(|diagnostic| diagnostic.severity() != Severity::Error)
236    }
237
238    /// Interpolates each document independently, in file order, without modifying the project.
239    #[must_use]
240    pub fn interpolate(&self, environment: &dyn EnvironmentProvider) -> ProjectInterpolation {
241        self.interpolate_with_options(environment, InterpolationOptions::default())
242    }
243
244    /// Interpolates each document independently with explicit options.
245    #[must_use]
246    pub fn interpolate_with_options(
247        &self,
248        environment: &dyn EnvironmentProvider,
249        options: InterpolationOptions,
250    ) -> ProjectInterpolation {
251        let documents: Vec<_> = self
252            .documents
253            .iter()
254            .map(|document| interpolate_document_with_options(document.syntax(), environment, options))
255            .collect();
256        let diagnostics = documents
257            .iter()
258            .flat_map(|document| document.diagnostics().iter().cloned())
259            .collect();
260        ProjectInterpolation { documents, diagnostics }
261    }
262}
263
264/// Per-file interpolation overlays for one loaded project.
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub struct ProjectInterpolation {
267    documents: Vec<DocumentInterpolation>,
268    diagnostics: Vec<Diagnostic>,
269}
270
271impl ProjectInterpolation {
272    /// Returns document overlays in file order.
273    #[must_use]
274    pub fn documents(&self) -> &[DocumentInterpolation] {
275        &self.documents
276    }
277
278    /// Finds a document overlay by source identifier.
279    #[must_use]
280    pub fn document(&self, source_id: SourceId) -> Option<&DocumentInterpolation> {
281        self.documents.iter().find(|document| document.source_id() == source_id)
282    }
283
284    /// Returns aggregated interpolation diagnostics in file order.
285    #[must_use]
286    pub fn diagnostics(&self) -> &[Diagnostic] {
287        &self.diagnostics
288    }
289
290    /// Reports whether interpolation emitted no error diagnostics.
291    #[must_use]
292    pub fn is_valid(&self) -> bool {
293        self.diagnostics
294            .iter()
295            .all(|diagnostic| diagnostic.severity() != Severity::Error)
296    }
297}
298
299/// A fatal project-loading failure.
300#[derive(Debug, Clone, PartialEq, Eq)]
301pub enum ProjectLoadError {
302    /// At least one Compose document is required to establish ordering and a base directory.
303    EmptyProject,
304    /// Two inputs reused a caller-managed source identifier.
305    DuplicateSourceId {
306        /// The reused identifier.
307        source_id: SourceId,
308        /// The first document's display label.
309        first_origin: String,
310        /// The later document's display label.
311        duplicate_origin: String,
312    },
313    /// A document exceeded the syntax tree's byte-offset capacity.
314    SyntaxCapacity {
315        /// The rejected document's origin.
316        origin: DocumentOrigin,
317        /// The underlying parser capacity error.
318        error: SyntaxParseError,
319    },
320}
321
322impl fmt::Display for ProjectLoadError {
323    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
324        match self {
325            Self::EmptyProject => formatter.write_str("a Compose project requires at least one document"),
326            Self::DuplicateSourceId {
327                source_id,
328                first_origin,
329                duplicate_origin,
330            } => write!(
331                formatter,
332                "{source_id} is assigned to both `{first_origin}` and `{duplicate_origin}`"
333            ),
334            Self::SyntaxCapacity { origin, error } => write!(formatter, "{}: {error}", origin.label),
335        }
336    }
337}
338
339impl Error for ProjectLoadError {
340    fn source(&self) -> Option<&(dyn Error + 'static)> {
341        match self {
342            Self::SyntaxCapacity { error, .. } => Some(error),
343            Self::EmptyProject | Self::DuplicateSourceId { .. } => None,
344        }
345    }
346}