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}