Skip to main content

compose_lens/loader/
include_paths.rs

1//! Caller-owned planning for effective include project directories.
2
3use super::{IncludeIdentity, IncludeResolution};
4use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
5use crate::model::Located;
6use crate::source::SourceSpan;
7use std::fmt;
8use std::path::{Path, PathBuf};
9
10/// An explicit include project directory could not be resolved by caller policy.
11pub const INCLUDE_PROJECT_DIRECTORY_UNRESOLVED: DiagnosticCode =
12    DiagnosticCode::new("compose.include.project-directory-unresolved");
13
14/// The successful result of resolving an explicit include project directory.
15#[derive(Clone, PartialEq, Eq)]
16pub enum IncludeProjectDirectoryResolution {
17    /// The caller authorized this effective directory.
18    Resolved(PathBuf),
19    /// The caller intentionally deferred this declaration without producing an error.
20    Deferred,
21}
22
23impl fmt::Debug for IncludeProjectDirectoryResolution {
24    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
25        match self {
26            Self::Resolved(_) => formatter.write_str("Resolved(<authorized-directory>)"),
27            Self::Deferred => formatter.write_str("Deferred"),
28        }
29    }
30}
31
32/// A typed resolver failure that does not carry caller-controlled message text.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34#[non_exhaustive]
35pub enum IncludeProjectDirectoryResolveError {
36    /// Caller policy could not resolve the explicit declaration.
37    Unresolved,
38}
39
40/// Context supplied to a caller-owned explicit project-directory resolver.
41pub struct IncludeProjectDirectoryRequest<'a> {
42    edge_index: usize,
43    request_index: usize,
44    parent_node_index: usize,
45    child_node_index: usize,
46    parent_identity: &'a IncludeIdentity,
47    child_identity: &'a IncludeIdentity,
48    parent_effective_directory: Option<&'a Path>,
49    declaration: &'a Located<String>,
50    child_first_document_directory: Option<&'a Path>,
51}
52
53impl IncludeProjectDirectoryRequest<'_> {
54    /// Returns the index of the include edge that authorized this planning request.
55    #[must_use]
56    pub const fn edge_index(&self) -> usize {
57        self.edge_index
58    }
59
60    /// Returns the index of the original include declaration request.
61    #[must_use]
62    pub const fn request_index(&self) -> usize {
63        self.request_index
64    }
65
66    /// Returns the occurrence index that declared this include.
67    #[must_use]
68    pub const fn parent_node_index(&self) -> usize {
69        self.parent_node_index
70    }
71
72    /// Returns the occurrence index of the included project.
73    #[must_use]
74    pub const fn child_node_index(&self) -> usize {
75        self.child_node_index
76    }
77
78    /// Returns the caller-defined identity of the including occurrence.
79    #[must_use]
80    pub const fn parent_identity(&self) -> &IncludeIdentity {
81        self.parent_identity
82    }
83
84    /// Returns the caller-defined identity of the included occurrence.
85    #[must_use]
86    pub const fn child_identity(&self) -> &IncludeIdentity {
87        self.child_identity
88    }
89
90    /// Returns the recursively effective parent directory, when planning has one.
91    #[must_use]
92    pub const fn parent_effective_directory(&self) -> Option<&Path> {
93        self.parent_effective_directory
94    }
95
96    /// Returns the raw, un-interpolated explicit declaration and its source span.
97    #[must_use]
98    pub const fn declaration(&self) -> &Located<String> {
99        self.declaration
100    }
101
102    /// Returns the explicit declaration source span.
103    #[must_use]
104    pub const fn declaration_span(&self) -> SourceSpan {
105        self.declaration.span()
106    }
107
108    /// Returns the child first-document directory retained by traversal, when available.
109    #[must_use]
110    pub const fn child_first_document_directory(&self) -> Option<&Path> {
111        self.child_first_document_directory
112    }
113}
114
115/// The only caller-owned policy boundary for explicit include project directories.
116pub trait IncludeProjectDirectoryResolver {
117    /// Resolves or defers one explicit include `project_directory` declaration.
118    ///
119    /// Implementations decide whether raw declarations are relative, absolute, opaque, URI-like,
120    /// or otherwise valid. `ComposeLens` does not join, normalize, canonicalize, open, or inspect
121    /// any path.
122    ///
123    /// # Errors
124    ///
125    /// Returns [`IncludeProjectDirectoryResolveError::Unresolved`] when caller policy cannot
126    /// authorize an effective directory for the explicit declaration.
127    fn resolve_project_directory(
128        &self,
129        request: &IncludeProjectDirectoryRequest<'_>,
130    ) -> Result<IncludeProjectDirectoryResolution, IncludeProjectDirectoryResolveError>;
131}
132
133/// How one occurrence received its planned project directory.
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
135#[non_exhaustive]
136pub enum IncludeProjectDirectoryStatus {
137    /// The root reused its first caller-supplied document directory.
138    Root,
139    /// A child had no explicit declaration and reused its first document directory.
140    Defaulted,
141    /// Caller policy authorized an explicit declaration.
142    Resolved,
143    /// Caller policy deliberately deferred an explicit declaration.
144    Deferred,
145    /// Caller policy could not resolve an explicit declaration.
146    Unresolved,
147}
148
149/// One deterministic occurrence entry in an [`IncludeProjectDirectoryPlan`].
150#[derive(Clone, PartialEq, Eq)]
151pub struct IncludeProjectDirectoryEntry {
152    node_index: usize,
153    identity: IncludeIdentity,
154    status: IncludeProjectDirectoryStatus,
155    effective_directory: Option<PathBuf>,
156    edge_index: Option<usize>,
157    request_index: Option<usize>,
158    declaration: Option<Located<String>>,
159}
160
161impl fmt::Debug for IncludeProjectDirectoryEntry {
162    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
163        formatter
164            .debug_struct("IncludeProjectDirectoryEntry")
165            .field("node_index", &self.node_index)
166            .field("identity", &"<redacted-identity>")
167            .field("status", &self.status)
168            .field(
169                "effective_directory",
170                &self.effective_directory.as_ref().map(|_| "<authorized-directory>"),
171            )
172            .field("edge_index", &self.edge_index)
173            .field("request_index", &self.request_index)
174            .field(
175                "declaration",
176                &self.declaration.as_ref().map(|_| "<redacted-declaration>"),
177            )
178            .finish()
179    }
180}
181
182impl IncludeProjectDirectoryEntry {
183    /// Returns the retained include occurrence index represented by this entry.
184    #[must_use]
185    pub const fn node_index(&self) -> usize {
186        self.node_index
187    }
188
189    /// Returns the caller-defined identity of this retained occurrence.
190    #[must_use]
191    pub const fn identity(&self) -> &IncludeIdentity {
192        &self.identity
193    }
194
195    /// Returns how this occurrence received its planned directory.
196    #[must_use]
197    pub const fn status(&self) -> IncludeProjectDirectoryStatus {
198        self.status
199    }
200
201    /// Returns the caller-authorized effective directory, when one is available.
202    #[must_use]
203    pub fn effective_directory(&self) -> Option<&Path> {
204        self.effective_directory.as_deref()
205    }
206
207    /// Returns the edge through which this child occurred; root has no incoming edge.
208    #[must_use]
209    pub const fn edge_index(&self) -> Option<usize> {
210        self.edge_index
211    }
212
213    /// Returns the originating include request index; root has no incoming request.
214    #[must_use]
215    pub const fn request_index(&self) -> Option<usize> {
216        self.request_index
217    }
218
219    /// Returns the raw explicit declaration and its provenance, when one was authored.
220    #[must_use]
221    pub const fn declaration(&self) -> Option<&Located<String>> {
222        self.declaration.as_ref()
223    }
224}
225
226/// The I/O-free, caller-authorized project-directory plan for an include traversal.
227#[derive(Clone, PartialEq, Eq)]
228pub struct IncludeProjectDirectoryPlan {
229    entries: Vec<IncludeProjectDirectoryEntry>,
230    diagnostics: Vec<Diagnostic>,
231}
232
233impl fmt::Debug for IncludeProjectDirectoryPlan {
234    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
235        formatter
236            .debug_struct("IncludeProjectDirectoryPlan")
237            .field("entries", &self.entries)
238            .field("diagnostics", &self.diagnostics)
239            .finish()
240    }
241}
242
243impl IncludeProjectDirectoryPlan {
244    /// Returns entries in retained node-index order.
245    #[must_use]
246    pub fn entries(&self) -> &[IncludeProjectDirectoryEntry] {
247        &self.entries
248    }
249
250    /// Finds an occurrence entry by its retained node index.
251    #[must_use]
252    pub fn entry(&self, node_index: usize) -> Option<&IncludeProjectDirectoryEntry> {
253        self.entries.get(node_index)
254    }
255
256    /// Returns unchanged traversal diagnostics followed by planning diagnostics.
257    #[must_use]
258    pub fn diagnostics(&self) -> &[Diagnostic] {
259        &self.diagnostics
260    }
261
262    /// Reports whether traversal and explicit directory resolution emitted no errors.
263    #[must_use]
264    pub fn is_valid(&self) -> bool {
265        self.diagnostics
266            .iter()
267            .all(|diagnostic| diagnostic.severity() != Severity::Error)
268    }
269
270    /// Reports whether every retained occurrence has an effective directory and no errors.
271    #[must_use]
272    pub fn is_complete(&self) -> bool {
273        self.is_valid() && self.entries.iter().all(|entry| entry.effective_directory.is_some())
274    }
275}
276
277pub(super) fn plan_project_directories(
278    resolution: &IncludeResolution,
279    resolver: &dyn IncludeProjectDirectoryResolver,
280) -> IncludeProjectDirectoryPlan {
281    let mut entries = vec![None; resolution.nodes.len()];
282    let mut diagnostics = resolution.diagnostics.clone();
283    if !resolution.nodes.is_empty() {
284        let root = &resolution.nodes[0];
285        entries[0] = Some(IncludeProjectDirectoryEntry {
286            node_index: 0,
287            identity: root.identity().clone(),
288            status: IncludeProjectDirectoryStatus::Root,
289            effective_directory: first_document_directory(root),
290            edge_index: None,
291            request_index: None,
292            declaration: None,
293        });
294        plan_children(0, resolution, resolver, &mut entries, &mut diagnostics);
295    }
296    IncludeProjectDirectoryPlan {
297        entries: entries.into_iter().flatten().collect(),
298        diagnostics,
299    }
300}
301
302fn plan_children(
303    parent_node_index: usize,
304    resolution: &IncludeResolution,
305    resolver: &dyn IncludeProjectDirectoryResolver,
306    entries: &mut [Option<IncludeProjectDirectoryEntry>],
307    diagnostics: &mut Vec<Diagnostic>,
308) {
309    let parent_effective_directory = entries[parent_node_index]
310        .as_ref()
311        .and_then(IncludeProjectDirectoryEntry::effective_directory)
312        .map(Path::to_path_buf);
313    for (edge_index, edge) in resolution.edges.iter().enumerate() {
314        if edge.parent_node_index != parent_node_index || edge.cycle {
315            continue;
316        }
317        let child_node_index = edge.child_node_index;
318        let request = &resolution.requests[edge.request_index];
319        let declaration = request.project_directory();
320        let child = &resolution.nodes[child_node_index];
321        let child_first_document_directory = first_document_directory(child);
322        let entry = match declaration {
323            None => IncludeProjectDirectoryEntry {
324                node_index: child_node_index,
325                identity: child.identity().clone(),
326                status: IncludeProjectDirectoryStatus::Defaulted,
327                effective_directory: child_first_document_directory,
328                edge_index: Some(edge_index),
329                request_index: Some(edge.request_index),
330                declaration: None,
331            },
332            Some(declaration) => {
333                let context = IncludeProjectDirectoryRequest {
334                    edge_index,
335                    request_index: edge.request_index,
336                    parent_node_index,
337                    child_node_index,
338                    parent_identity: &edge.parent,
339                    child_identity: &edge.child,
340                    parent_effective_directory: parent_effective_directory.as_deref(),
341                    declaration,
342                    child_first_document_directory: child_first_document_directory.as_deref(),
343                };
344                match resolver.resolve_project_directory(&context) {
345                    Ok(IncludeProjectDirectoryResolution::Resolved(directory)) => IncludeProjectDirectoryEntry {
346                        node_index: child_node_index,
347                        identity: child.identity().clone(),
348                        status: IncludeProjectDirectoryStatus::Resolved,
349                        effective_directory: Some(directory),
350                        edge_index: Some(edge_index),
351                        request_index: Some(edge.request_index),
352                        declaration: Some(declaration.clone()),
353                    },
354                    Ok(IncludeProjectDirectoryResolution::Deferred) => IncludeProjectDirectoryEntry {
355                        node_index: child_node_index,
356                        identity: child.identity().clone(),
357                        status: IncludeProjectDirectoryStatus::Deferred,
358                        effective_directory: None,
359                        edge_index: Some(edge_index),
360                        request_index: Some(edge.request_index),
361                        declaration: Some(declaration.clone()),
362                    },
363                    Err(IncludeProjectDirectoryResolveError::Unresolved) => {
364                        diagnostics.push(project_directory_unresolved_diagnostic(declaration.span()));
365                        IncludeProjectDirectoryEntry {
366                            node_index: child_node_index,
367                            identity: child.identity().clone(),
368                            status: IncludeProjectDirectoryStatus::Unresolved,
369                            effective_directory: None,
370                            edge_index: Some(edge_index),
371                            request_index: Some(edge.request_index),
372                            declaration: Some(declaration.clone()),
373                        }
374                    }
375                }
376            }
377        };
378        entries[child_node_index] = Some(entry);
379        plan_children(child_node_index, resolution, resolver, entries, diagnostics);
380    }
381}
382
383fn first_document_directory(node: &super::IncludeNode) -> Option<PathBuf> {
384    node.inputs()
385        .documents()
386        .first()
387        .map(|document| document.origin().directory().to_path_buf())
388}
389
390fn project_directory_unresolved_diagnostic(span: SourceSpan) -> Diagnostic {
391    Diagnostic::new(
392        INCLUDE_PROJECT_DIRECTORY_UNRESOLVED,
393        Severity::Error,
394        "included project directory could not be resolved",
395    )
396    .with_label(DiagnosticLabel::primary(span, "project directory declaration"))
397}