bynk_driver/discovery.rs
1//! Walking a directory tree for `.bynk` files and reading them — the driver's
2//! side of `CompileOptions.sources` (#1077, R2.3/T0.7 residue).
3//!
4//! The real CLI entry points (`project_options`/`try_project_options` below)
5//! now walk and read every file up front, via this module, and hand the
6//! result to `CompileOptions::sources`.
7//!
8//! #1077 review: this module's own original doc claimed the CLI path was
9//! already fully independent of `bynk-emit`'s disk reads — false as written.
10//! `project_options`/`try_project_options` still read `bynk.toml` itself via
11//! `read_project_paths`/`try_read_project_paths`, which reach
12//! `discovery::read_source`'s overlay-miss fallback (`fs::read_to_string`)
13//! with an always-empty overlay — one real disk read inside `bynk-emit` on
14//! every real CLI invocation, unnoticed because `fs_below_driver` scans
15//! `bynk-emit`'s own text, not who calls into it. Both entry points now read
16//! `bynk.toml` themselves (`manifest_overlay`) and hand it to
17//! `try_read_project_paths_with`'s overlay instead, so that read genuinely
18//! moves above `bynk-emit` **when `bynk.toml` exists**. A conventional
19//! project with no manifest at all still reaches `try_read_project_paths_with`
20//! with an empty overlay, which still tries (and fails to find) `bynk.toml`
21//! via `read_source`'s own `fs::read_to_string` before falling back to
22//! `ProjectPaths::conventional` — a real, if harmless (the read fails and is
23//! discarded), disk touch still inside `bynk-emit` for that case.
24//!
25//! `bynk-emit`'s own on-disk discovery (`project::discover_bynk_files`) and
26//! `read_source`'s overlay-miss fallback are **not** removed, and can't be
27//! yet: `analyse_project_with` (the LSP's own analysis path — deleted at
28//! #1541; the equivalent hardcoding now lives in
29//! `bynk_check::analysis::analyse_project`) hardcodes `discovered: None` and
30//! passes an overlay that deliberately covers only open editor buffers — it
31//! depends on that exact fallback to see every other project file, including its own
32//! `bynk.toml` read (`AnalysisRoots::lower`, `bynk-ide/src/lib.rs`, has the
33//! same gap this module just closed for the CLI, unclosed). `#1079`'s issue
34//! text scopes only `bynk-ide`'s `completion.rs`/`symbols.rs` — it does not
35//! mention `analyse_project_with` or the `diagnose_project(&root,
36//! &HashMap::new())` pattern that is `bynk-ide`'s (and `bynkc`'s test suite's)
37//! dominant way of exercising this whole analysis path (100+ call sites
38//! spanning `bynk-ide`'s own inline test modules, `bynk-lsp/tests`, and
39//! `bynkc/tests`) — every one depends on `read_source`'s fallback today.
40//! Closing `discovery.rs` for good needs all of that migrated first, which is
41//! bigger than either #1077 or #1079's issue text currently describes; #1077
42//! stays open pending that combined, design-reviewed effort. `fs_below_driver`
43//! does not move for `bynk-emit` from this module alone.
44
45use std::collections::HashMap;
46use std::path::{Path, PathBuf};
47
48use bynk_emit::project::Roots;
49
50/// An I/O failure while walking a project tree for `.bynk` files, or reading
51/// one found there.
52///
53/// #1081 review: `read_bynk_tree` used to `panic!` on any such failure, on the
54/// premise that every caller was a test fixture. That stopped being true the
55/// moment `project_options`/`try_project_options` (below) became its
56/// production callers — a `bynk.toml` with an `include` root that does not
57/// exist yet (or any other unreadable directory) is a normal, recoverable
58/// input on the live CLI path, not a broken fixture, and `try_project_options`
59/// already has a `Result` error channel for exactly this kind of thing.
60#[derive(Debug)]
61pub struct DiscoveryError {
62 path: PathBuf,
63 source: std::io::Error,
64}
65
66impl std::fmt::Display for DiscoveryError {
67 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68 write!(
69 f,
70 "could not read project files under `{}`: {}",
71 self.path.display(),
72 self.source
73 )
74 }
75}
76
77impl std::error::Error for DiscoveryError {
78 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
79 Some(&self.source)
80 }
81}
82
83/// #1883, slice 1b: the project's `assets/` folder, listed
84/// ([`bynk_emit::project::assets::AssetFolder::on_disk`]) for
85/// `CompileOptions::asset_files`. No file's bytes are read here: the build
86/// reads only the files a declaration names. A missing folder is empty; an
87/// unlistable one is a [`DiscoveryError`] naming the folder.
88pub fn list_asset_folder(
89 project_root: &Path,
90) -> Result<bynk_emit::project::assets::AssetFolder, DiscoveryError> {
91 bynk_emit::project::assets::AssetFolder::on_disk(project_root).map_err(|source| {
92 DiscoveryError {
93 path: project_root.join(bynk_emit::project::assets::PROJECT_ASSETS_DIR),
94 source,
95 }
96 })
97}
98
99/// #2387, slice 2: the project's `embeds/` folder, listed as
100/// [`list_asset_folder`] lists `assets/`, for `CompileOptions::embed_files`.
101pub fn list_embed_folder(
102 project_root: &Path,
103) -> Result<bynk_emit::project::assets::EmbedFolder, DiscoveryError> {
104 bynk_emit::project::assets::AssetFolder::embeds_on_disk(project_root).map_err(|source| {
105 DiscoveryError {
106 path: project_root.join(bynk_emit::project::assets::PROJECT_EMBEDS_DIR),
107 source,
108 }
109 })
110}
111
112/// Walk `root` for every `.bynk` file (skipping `excludes` and hidden
113/// directories, mirroring `bynk-emit`'s former `discover_bynk_files` exactly)
114/// and read each one's content. Keys are the same literal (non-canonicalised)
115/// path shape a plain recursive walk produces — `CompileOptions.sources`'s own
116/// contract, and what `read_source`'s overlay lookup tries first.
117pub fn read_bynk_tree(
118 root: &Path,
119 excludes: &[PathBuf],
120) -> Result<HashMap<PathBuf, String>, DiscoveryError> {
121 let mut out = HashMap::new();
122 let is_excluded = |dir: &Path| {
123 excludes.iter().any(|ex| dir == ex || dir.starts_with(ex))
124 || dir
125 .file_name()
126 .and_then(|n| n.to_str())
127 .is_some_and(|n| n.starts_with('.') && n != ".")
128 };
129 let to_err = |path: &Path, source: std::io::Error| DiscoveryError {
130 path: path.to_path_buf(),
131 source,
132 };
133 let mut stack = vec![root.to_path_buf()];
134 while let Some(dir) = stack.pop() {
135 let rd = std::fs::read_dir(&dir).map_err(|e| to_err(&dir, e))?;
136 for entry in rd {
137 let entry = entry.map_err(|e| to_err(&dir, e))?;
138 let p = entry.path();
139 if p.is_dir() {
140 if !is_excluded(&p) {
141 stack.push(p);
142 }
143 } else if p.extension().and_then(|e| e.to_str()) == Some("bynk") {
144 let text = std::fs::read_to_string(&p).map_err(|e| to_err(&p, e))?;
145 out.insert(p, text);
146 }
147 }
148 }
149 Ok(out)
150}
151
152/// [`read_bynk_tree`] with no excludes — the common case for a single-root
153/// project (`CompileOptions::single`, whose own `Roots::excludes()` is always
154/// empty) and for test fixtures, which rarely declare an `exclude` list.
155pub fn read_bynk_tree_single(root: &Path) -> Result<HashMap<PathBuf, String>, DiscoveryError> {
156 read_bynk_tree(root, &[])
157}
158
159/// [`read_bynk_tree`] merged across every one of a split project's `include`
160/// roots, matching `Roots::trees`' shape — the primary root's `paths.exclude`
161/// (plus the tool's own `out`/`node_modules` caches) applies to all of them.
162///
163/// R3.9 (#1113): walks every tree `Roots::trees` resolves to, not a hardcoded
164/// primary/secondary pair. `trees[0]` is mandatory (a missing directory is a
165/// real [`DiscoveryError`], same as any other unreadable directory); every
166/// later tree is optional — a conventional `src`-only project simply has no
167/// `tests/` at all — so only its absence is tolerated. Tried via
168/// `read_bynk_tree` itself (a `fs::read_dir`) and its `NotFound` caught for
169/// an optional tree, rather than a `root.exists()` pre-check, which would
170/// cost a redundant `stat()` per optional tree for the same answer.
171///
172/// Only a `NotFound` reported *for the root itself* is tolerated — matched by
173/// `e.path == *root`, true only for the walk's very first `fs::read_dir`
174/// call. A `NotFound` for anything deeper (a dangling symlink, a
175/// subdirectory removed mid-walk) means the tree exists and had real files;
176/// swallowing that would silently discard everything `read_bynk_tree` had
177/// already collected for it, with no diagnostic — a review-caught defect in
178/// an earlier cut of this fix, which matched on the error's `io::ErrorKind`
179/// alone regardless of which path it named.
180pub fn read_bynk_tree_split(
181 trees: &[PathBuf],
182 excludes: &[PathBuf],
183) -> Result<HashMap<PathBuf, String>, DiscoveryError> {
184 let mut out = HashMap::new();
185 for (i, root) in trees.iter().enumerate() {
186 match read_bynk_tree(root, excludes) {
187 Ok(files) => out.extend(files),
188 Err(e)
189 if i > 0 && &e.path == root && e.source.kind() == std::io::ErrorKind::NotFound => {}
190 Err(e) => return Err(e),
191 }
192 }
193 Ok(out)
194}
195
196/// The complete `CompileOptions.sources` map for a project rooted at `roots`.
197///
198/// #1081 review: this used to re-implement `Roots::resolve`/`Roots::excludes`
199/// verbatim (down to the hardcoded `out`/`node_modules` cache list) because
200/// those were private to `bynk-emit`. Now that they're `pub` (moved to
201/// `bynk-project`, #1113), this calls them directly — one definition of
202/// "which files are in this project", shared by the CLI's own walk and
203/// `compile_project`'s in-memory partitioning, so the two can no longer drift.
204pub fn sources_for_roots(roots: &Roots) -> Result<HashMap<PathBuf, String>, DiscoveryError> {
205 let mut trees: Vec<PathBuf> = roots.trees().into_iter().map(|(root, _)| root).collect();
206 // #2223: and each path dependency's trees, computed as `compile_project`
207 // computes them, so its partitioning finds every dependency file a home.
208 if let Roots::Split { project_root, .. } = roots {
209 trees.extend(
210 bynk_emit::project::dependency_trees(project_root, &HashMap::new())
211 .into_iter()
212 .map(|(root, _)| root),
213 );
214 }
215 read_bynk_tree_split(&trees, &roots.excludes())
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use bynk_emit::project::{ProjectPaths, try_read_project_paths};
222 use std::fs;
223
224 /// A throwaway on-disk directory tree, removed on drop (including on
225 /// panic) — mirrors `bynk-driver/tests/project_diagnostics.rs`'s own
226 /// `Scratch` helper.
227 struct Scratch(PathBuf);
228 impl Drop for Scratch {
229 fn drop(&mut self) {
230 let _ = fs::remove_dir_all(&self.0);
231 }
232 }
233
234 fn scratch(tag: &str, files: &[(&str, &str)]) -> Scratch {
235 let dir = std::env::temp_dir().join(format!(
236 "bynk_driver_discovery_{tag}_{}_{:?}",
237 std::process::id(),
238 std::thread::current().id()
239 ));
240 let _ = fs::remove_dir_all(&dir);
241 for (rel, body) in files {
242 let p = dir.join(rel);
243 fs::create_dir_all(p.parent().unwrap()).unwrap();
244 fs::write(&p, body).unwrap();
245 }
246 Scratch(dir)
247 }
248
249 #[test]
250 fn walks_nested_files_and_skips_hidden_directories() {
251 let root = scratch(
252 "hidden",
253 &[
254 ("a.bynk", "context a\n"),
255 ("nested/b.bynk", "context b\n"),
256 (".git/c.bynk", "context c\n"),
257 ],
258 );
259 let found = read_bynk_tree(&root.0, &[]).expect("a real tree must not error");
260 assert_eq!(
261 found.len(),
262 2,
263 "found: {:?}",
264 found.keys().collect::<Vec<_>>()
265 );
266 assert!(found.contains_key(&root.0.join("a.bynk")));
267 assert!(found.contains_key(&root.0.join("nested/b.bynk")));
268 }
269
270 #[test]
271 fn excludes_skip_the_named_subtree() {
272 let root = scratch(
273 "excludes",
274 &[("a.bynk", "context a\n"), ("out/b.bynk", "context b\n")],
275 );
276 let found = read_bynk_tree(&root.0, &[root.0.join("out")]).expect("must not error");
277 assert_eq!(found.len(), 1);
278 assert!(found.contains_key(&root.0.join("a.bynk")));
279 }
280
281 #[test]
282 fn an_empty_tree_is_not_an_error() {
283 let root = scratch("empty", &[(".keep", "")]);
284 let found = read_bynk_tree(&root.0, &[]).expect("an empty tree is a valid, empty result");
285 assert!(found.is_empty());
286 }
287
288 #[test]
289 fn a_missing_root_is_a_discovery_error_not_a_panic() {
290 let missing = std::env::temp_dir().join(format!(
291 "bynk_driver_discovery_missing_{}_{:?}",
292 std::process::id(),
293 std::thread::current().id()
294 ));
295 let _ = fs::remove_dir_all(&missing);
296 let err = read_bynk_tree(&missing, &[]).expect_err("a nonexistent root must not panic");
297 assert!(err.to_string().contains(&missing.display().to_string()));
298 }
299
300 #[test]
301 fn split_secondary_root_is_optional_but_primary_is_not() {
302 // `tests/` need not exist for a `src`-only project.
303 let root = scratch("split_no_secondary", &[("src/a.bynk", "context a\n")]);
304 let found = read_bynk_tree_split(&[root.0.join("src"), root.0.join("tests")], &[])
305 .expect("a missing secondary root is tolerated");
306 assert_eq!(found.len(), 1);
307
308 // A missing *primary* root is a real error, matching the review's
309 // "missing include root panics instead of erroring" finding.
310 let missing_primary = root.0.join("does-not-exist");
311 read_bynk_tree_split(&[missing_primary, root.0.join("tests")], &[])
312 .expect_err("a missing primary root must error, not panic");
313 }
314
315 /// Regression (code review of the #1114 fix itself): an earlier cut of
316 /// the optional-tree `NotFound` tolerance matched on `io::ErrorKind`
317 /// alone, regardless of *which path* the error named — so a `NotFound`
318 /// anywhere inside an *existing* optional tree's walk (a dangling
319 /// symlink, a subdirectory removed mid-walk) was silently treated the
320 /// same as the tree itself being absent, discarding every real file
321 /// `read_bynk_tree` had already collected for it with no diagnostic.
322 /// Only a `NotFound` for the root itself should be tolerated.
323 #[cfg(unix)]
324 #[test]
325 fn a_dangling_symlink_deep_in_an_optional_tree_is_a_real_error_not_a_silent_drop() {
326 let root = scratch(
327 "split_dangling_symlink",
328 &[("src/a.bynk", "context a\n"), ("tests/b.bynk", "suite a\n")],
329 );
330 // The tree itself (`tests/`) exists and has a real `.bynk` file
331 // already collected by the time the walk reaches this dangling
332 // entry — `NotFound` here must not look like "no such optional
333 // tree."
334 std::os::unix::fs::symlink(
335 root.0.join("tests/does-not-exist.bynk"),
336 root.0.join("tests/dangling.bynk"),
337 )
338 .expect("symlink creation must succeed on unix");
339 let err = read_bynk_tree_split(&[root.0.join("src"), root.0.join("tests")], &[])
340 .expect_err("a NotFound deep in an existing optional tree's walk must still error");
341 assert!(
342 err.to_string().contains("dangling.bynk"),
343 "the error must name the file that actually failed: {err}"
344 );
345 }
346
347 /// R3.9 (#1113): three or more `include` roots are all merged — not just
348 /// the first two — exercised through `sources_for_roots` the way
349 /// `project_options` calls it — the file set a real `bynk.toml` project
350 /// resolves to.
351 #[test]
352 fn sources_for_roots_merges_three_or_more_include_roots() {
353 let root = scratch(
354 "many_includes",
355 &[
356 (
357 "bynk.toml",
358 "[project]\nname = \"x\"\n\n[paths]\ninclude = [\"src\", \"tests\", \"examples\"]\n",
359 ),
360 ("src/a.bynk", "context a\n"),
361 ("tests/a.bynk", "suite a\n"),
362 ("examples/e.bynk", "context e\n"),
363 ],
364 );
365 let paths: ProjectPaths =
366 try_read_project_paths(&root.0).expect("well-formed fixture manifest");
367 let roots = Roots::Split {
368 project_root: root.0.clone(),
369 paths,
370 };
371 let found = sources_for_roots(&roots).expect("a well-formed project must not error");
372 assert_eq!(
373 found.len(),
374 3,
375 "found: {:?}",
376 found.keys().collect::<Vec<_>>()
377 );
378 assert!(found.contains_key(&root.0.join("src/a.bynk")));
379 assert!(found.contains_key(&root.0.join("tests/a.bynk")));
380 assert!(found.contains_key(&root.0.join("examples/e.bynk")));
381 }
382
383 /// The `include.len()` == 0/1/2 branches, exercised through
384 /// `sources_for_roots` the way `project_options` calls it — the file set
385 /// a real `bynk.toml` project resolves to.
386 #[test]
387 fn sources_for_roots_matches_a_conventional_split_project() {
388 let root = scratch(
389 "conventional",
390 &[
391 ("bynk.toml", "[project]\nname = \"x\"\n"),
392 ("src/a.bynk", "context a\n"),
393 ("tests/a.bynk", "suite a\n"),
394 ],
395 );
396 let paths: ProjectPaths =
397 try_read_project_paths(&root.0).expect("well-formed fixture manifest");
398 let roots = Roots::Split {
399 project_root: root.0.clone(),
400 paths,
401 };
402 let found = sources_for_roots(&roots).expect("a well-formed project must not error");
403 assert_eq!(found.len(), 2);
404 assert!(found.contains_key(&root.0.join("src/a.bynk")));
405 assert!(found.contains_key(&root.0.join("tests/a.bynk")));
406 }
407
408 #[test]
409 fn sources_for_roots_on_a_flat_include_root_excludes_its_own_caches() {
410 // `include = ["."]` (`ProjectPaths::conventional`'s flat fallback):
411 // one root, and `node_modules`/`out` must still be swept out of it.
412 let root = scratch(
413 "flat",
414 &[
415 ("bynk.toml", "[project]\nname = \"x\"\n"),
416 ("a.bynk", "context a\n"),
417 ("node_modules/dep.bynk", "context dep\n"),
418 ("out/built.bynk", "context built\n"),
419 ],
420 );
421 let paths = ProjectPaths {
422 include: vec![PathBuf::from(".")],
423 exclude: Vec::new(),
424 };
425 let roots = Roots::Split {
426 project_root: root.0.clone(),
427 paths,
428 };
429 let found = sources_for_roots(&roots).expect("must not error");
430 assert_eq!(
431 found.len(),
432 1,
433 "found: {:?}",
434 found.keys().collect::<Vec<_>>()
435 );
436 assert!(found.contains_key(&root.0.join("a.bynk")));
437 }
438}