Skip to main content

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