Skip to main content

brink_driver/
discover.rs

1//! BFS discovery of files reachable via INCLUDEs.
2
3use std::collections::HashSet;
4use std::io;
5
6use brink_db::{ProjectDb, resolve_include_path};
7use tracing::{debug, info};
8
9/// Errors from file discovery.
10#[derive(Debug, thiserror::Error)]
11pub enum DiscoverError {
12    /// File I/O error during discovery.
13    #[error("I/O error: {0}")]
14    Io(#[from] io::Error),
15    /// Circular INCLUDE dependency detected.
16    #[error("circular INCLUDE: {0}")]
17    CircularInclude(String),
18    /// A native discovery key is not root-relative (contains a `..`
19    /// segment). `native_module_path` treats `..` literally, so letting one
20    /// through would mint a bogus module (`../x.brink` → `story::..::x`) —
21    /// save-key-identity-critical (issue #1288 review note (a)). Every
22    /// current `SourceTree` (`RealFs`, `GitRev`, `InMemory`) already
23    /// produces root-relative, `..`-free keys; this guards against a future
24    /// implementation that doesn't.
25    #[error("source key `{0}` is not root-relative (contains `..`)")]
26    InvalidKey(String),
27    /// A key `discover_native` was handed does not have the `.brink`
28    /// extension. `discover_native` must only ever see native source (issue
29    /// #1371): `tree` is a `&dyn SourceTree`, and nothing at the type level
30    /// stops a caller from handing it an implementation scoped wider than
31    /// `.brink` alone — e.g. `brink_source_tree::InMemory`, the tree
32    /// `brink-web`'s `compile()` builds (`.ink`-keyed) and hands to
33    /// `brink_environment::Project::load`, not to native discovery — which
34    /// would let `.ink` text be parsed as brink source. Checked (like
35    /// [`InvalidKey`](Self::InvalidKey)) before
36    /// any file is loaded, so a violation rejects the whole discovery, not
37    /// just the offending key.
38    #[error("source key `{0}` is not a native `.brink` file")]
39    NonNativeKey(String),
40}
41
42/// Discover all files reachable via INCLUDEs from the entry point.
43///
44/// Performs BFS: reads each file, parses it via `db.set_file()`, then follows
45/// its INCLUDEs. After all files are loaded, rebuilds the include graph and
46/// checks for cycles.
47pub fn discover<F>(db: &mut ProjectDb, entry: &str, read_file: &mut F) -> Result<(), DiscoverError>
48where
49    F: FnMut(&str) -> Result<String, io::Error>,
50{
51    let mut queue: Vec<String> = vec![entry.to_string()];
52    let mut seen: HashSet<String> = HashSet::new();
53
54    while let Some(path) = queue.pop() {
55        if !seen.insert(path.clone()) {
56            continue;
57        }
58
59        let source = read_file(&path)?;
60        let file_id = db.set_file(&path, source);
61
62        // Discover INCLUDEs
63        if let Some(hir) = db.hir(file_id) {
64            for include in &hir.includes {
65                // A bare `INCLUDE` (no path) lowers to an `IncludeSite` with
66                // an empty `file_path` — the parser already flagged this as
67                // E037 ("expected file path"). Reading the empty path here
68                // would surface an `Io` error before that diagnostic ever
69                // reaches the user, so skip it and let discovery continue.
70                if include.file_path.is_empty() {
71                    debug!(from = path, "skipping empty INCLUDE path (E037)");
72                    continue;
73                }
74                let resolved = resolve_include_path(&path, &include.file_path);
75                if !seen.contains(&resolved) {
76                    debug!(from = path, include = resolved, "discovered INCLUDE");
77                    queue.push(resolved);
78                }
79            }
80        }
81    }
82
83    // Rebuild include graph now that all files are loaded
84    db.rebuild_include_graph();
85
86    // Detect circular includes
87    if let Some(cycle) = db.find_cycle() {
88        let names: Vec<_> = cycle.iter().filter_map(|id| db.file_path(*id)).collect();
89        return Err(DiscoverError::CircularInclude(names.join(" -> ")));
90    }
91
92    info!(files = seen.len(), "discovery complete");
93    Ok(())
94}
95
96#[cfg(test)]
97mod tests {
98    use brink_db::resolve_include_path;
99
100    #[test]
101    fn resolve_relative_include() {
102        assert_eq!(
103            resolve_include_path("src/main.ink", "utils.ink"),
104            "src/utils.ink"
105        );
106    }
107
108    #[test]
109    fn resolve_no_directory() {
110        assert_eq!(resolve_include_path("story.ink", "other.ink"), "other.ink");
111    }
112
113    #[test]
114    fn resolve_nested_directory() {
115        assert_eq!(
116            resolve_include_path("story.ink", "lib/helpers.ink"),
117            "lib/helpers.ink"
118        );
119    }
120
121    #[test]
122    fn resolve_parent_traversal_normalized() {
123        // `..` collapses to a clean key so upward includes resolve to real
124        // files (system-wide; see docs/decision-log.md).
125        assert_eq!(resolve_include_path("a/b/c.ink", "../d.ink"), "a/d.ink");
126    }
127
128    #[test]
129    fn resolve_deep_nesting() {
130        assert_eq!(resolve_include_path("a/b/c.ink", "d/e.ink"), "a/b/d/e.ink");
131    }
132
133    /// #1504(b), reachable form: an editor session that admits an
134    /// `INCLUDE` target before the entry file itself (`brink-lsp`'s
135    /// `load_file_from_disk`, which can walk-and-load a
136    /// sibling ahead of an explicit `did_open` on the entry) mints the
137    /// entry a different numeric `FileId` than [`super::discover`] does —
138    /// `discover` always seeds its BFS queue with the entry, so a
139    /// from-scratch compile always mints the entry `FileId(0)`. The
140    /// synthesized root terminus used to be keyed by that numeric id
141    /// (`attach_root_final_gather`), not by anything content-derived, so the
142    /// container-id set an editor-order load produced diverged from a real
143    /// compile of the identical tree — the ink-mode sibling of the
144    /// editor-vs-compile identity parity `discover_native.rs` already guards
145    /// for native. #1504 re-keyed the terminus on the owning file's *path*
146    /// (`hir::root_content_scope_path`), so the two REGISTRATION ORDERS now
147    /// agree; this runs as the regression test for that, narrowly.
148    ///
149    /// Narrowly, because this test holds the path spelling fixed
150    /// (`"entry.ink"`/`"sibling.ink"` in both orders) and varies only which
151    /// `FileId` gets assigned first. It does **not** cover the wider
152    /// spelling-parity gap flagged in review on #1693 and closed by #1696:
153    /// this test builds a `Driver` directly, bypassing
154    /// `brink-compiler/src/driver.rs`'s `prepare_driver` (which now
155    /// registers a root-relative-key qualifier via `ProjectDb::set_ink_root`)
156    /// and `brink-lsp`'s `register_native_root` (which now registers the
157    /// same session root under `set_ink_root` alongside `set_native_root`),
158    /// so a `Driver` used this directly still qualifies by the raw
159    /// registered path — see
160    /// `crates/brink-compiler/tests/issue_1504_root_content_identity.rs`'s
161    /// `root_content_ids_are_stable_across_entry_path_spellings`, which
162    /// covers the fixed behavior through `prepare_driver`, and
163    /// `docs/root-content-identity-findings.md`'s "Known limitation" section
164    /// for the full history.
165    #[test]
166    fn root_content_ids_agree_between_discover_and_editor_order() {
167        use std::collections::BTreeSet;
168        use std::collections::HashMap;
169
170        use brink_format::DefinitionId;
171
172        const ENTRY: &str = "INCLUDE sibling.ink\n* one\n* two\n- gathered\n";
173        const SIBLING: &str = "=== helper ===\nhelper text\n-> DONE\n";
174
175        fn container_ids(container: &brink_ir::lir::Container, out: &mut BTreeSet<DefinitionId>) {
176            out.insert(container.id);
177            for child in &container.children {
178                container_ids(child, out);
179            }
180        }
181
182        fn ids_via(db: &brink_db::ProjectDb) -> BTreeSet<DefinitionId> {
183            let mut out = BTreeSet::new();
184            let program = db
185                .lir_product()
186                .and_then(|p| p.program.as_ref())
187                .expect("lowering succeeds");
188            container_ids(&program.root, &mut out);
189            out
190        }
191
192        // (1) Compile order: `discover` seeds the BFS from the entry, so
193        // `entry.ink` mints `FileId(0)`.
194        let mut compiled = crate::Driver::new();
195        let files: HashMap<&str, &str> = [("entry.ink", ENTRY), ("sibling.ink", SIBLING)].into();
196        compiled
197            .discover("entry.ink", |path: &str| {
198                files
199                    .get(path)
200                    .map(|s| (*s).to_string())
201                    .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, path))
202            })
203            .expect("discovery succeeds");
204        compiled.db_mut().set_entry("entry.ink");
205
206        // (2) Editor order: the sibling is admitted first (e.g. a workspace
207        // walk, or an `INCLUDE` chased before the entry itself is opened) —
208        // `entry.ink` mints `FileId(1)` instead.
209        let mut edited = crate::Driver::new();
210        edited.db_mut().set_file("sibling.ink", SIBLING.to_string());
211        edited.db_mut().set_file("entry.ink", ENTRY.to_string());
212        edited.db_mut().set_entry("entry.ink");
213
214        assert_eq!(
215            ids_via(edited.db()),
216            ids_via(compiled.db()),
217            "editor-order file registration must mint the SAME root-content \
218             DefinitionIds a real `discover` compile of the same tree mints"
219        );
220    }
221}