bynk_project/discovery.rs
1use std::collections::{HashMap, HashSet};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use bynk_syntax::ast::{
6 AdapterDecl, Case, Commons, CommonsItem, ConsumesDecl, ExportsDecl, SourceUnit, SuiteDecl,
7 TestTier, Trivia, TypeRef, UsesDecl,
8};
9use bynk_syntax::error::CompileError;
10use bynk_syntax::span::Span;
11
12use crate::roots::{Roots, UnitKind};
13
14/// v0.118: a case's *effective* tier — its own `as <tier>`, else the suite
15/// default, else `unit`.
16pub fn case_effective_tier(case: &Case, suite: &SuiteDecl) -> TestTier {
17 case.tier.or(suite.tier).unwrap_or(TestTier::Unit)
18}
19
20/// v0.118: whether a suite's *effective* tier is `system` — the suite default
21/// is `system`, or any case opts up to `system`. Such a suite is emitted via
22/// the wired cross-Worker (`Integration`) machinery; otherwise it stays
23/// in-process (`Test`).
24pub fn suite_effective_tier_is_system(suite: &SuiteDecl) -> bool {
25 suite.tier == Some(TestTier::System)
26 || suite.cases.iter().any(|c| c.tier == Some(TestTier::System))
27}
28
29/// Read a source file from the overlay (keyed by canonicalised absolute
30/// path; falls back to the literal path so a not-yet-created overlay entry
31/// still matches). Every caller into this module now supplies a complete
32/// overlay — content-ownership track (#1086) slice 5 removed the disk-read
33/// fallback this used to have on a miss, so an incomplete overlay is a real
34/// `NotFound` error here, not a silent disk read.
35///
36/// Finding #55/#65: tries the literal path first, `canonicalize()` only on a
37/// miss — an in-memory/wasm project's synthetic overlay keys never exist on
38/// disk, so `canonicalize()` was a guaranteed-failing syscall on every read of
39/// every such file, for no benefit (the literal-path lookup below already
40/// finds the same entry).
41pub fn read_source(path: &Path, overlay: &HashMap<PathBuf, String>) -> std::io::Result<String> {
42 if let Some(text) = overlay.get(path) {
43 return Ok(text.clone());
44 }
45 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
46 if let Some(text) = overlay.get(&canonical) {
47 return Ok(text.clone());
48 }
49 Err(std::io::Error::new(
50 std::io::ErrorKind::NotFound,
51 format!("no overlay entry for `{}`", path.display()),
52 ))
53}
54
55/// An adapter's `.binding.ts` module: overlay-first (an open, unsaved
56/// binding buffer), else a real disk read. Content-ownership track (#1086)
57/// scope note, found under slice 5's implementation: unlike a project's
58/// `.bynk` sources — enumerable ahead of time by extension, and this
59/// track's actual charter — a binding module's *path* is only known once
60/// its declaring adapter has been parsed (`adapter … { binding: "…" }`), so
61/// no discovery walk (`bynk-testkit`, `bynk-driver::discovery`) can
62/// pre-populate it into a sources map the way `.bynk` files are. Keeping a
63/// disk-read fallback here — the CLI's real production path has always
64/// worked exactly this way, `#1077`/`#1081` notwithstanding — is a
65/// deliberate, narrow carve-out, not a straggler.
66pub fn read_adapter_binding(
67 path: &Path,
68 overlay: &HashMap<PathBuf, String>,
69) -> std::io::Result<String> {
70 if let Some(text) = overlay.get(path) {
71 return Ok(text.clone());
72 }
73 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
74 if let Some(text) = overlay.get(&canonical) {
75 return Ok(text.clone());
76 }
77 fs::read_to_string(path)
78}
79
80/// A parsed `.bynk` file: its source, AST, and the two path forms it needs.
81///
82/// Slice 0: `source_path` and `identity_path` are **different things**, and
83/// conflating them is what made a two-root project's file identity ambiguous.
84/// They coincide for a single-root project, which is why one field sufficed
85/// until `include` could hold two entries.
86///
87/// P4.0 (#1113, [DECISION B]): fields are crate-private now that `ParsedFile`
88/// lives in `bynk-project` — `bynk-emit`'s `symbols`/`validate` read them
89/// through the accessors below instead of the direct field pokes a
90/// same-crate `pub(crate)` allowed before the move.
91#[derive(Clone)]
92pub struct ParsedFile {
93 /// The path **relative to the `include` root that contains this file** —
94 /// the form unit validation requires. `src/todos.bynk` under the `src`
95 /// root is `todos.bynk`, which is what lets it declare `context todos`
96 /// ([`crate::paths::unit_path_matches`], via
97 /// [`crate::consistency::check_path_name_alignment`]). Prefixing this
98 /// would make every unit in every project fail alignment.
99 pub(crate) source_path: PathBuf,
100 /// Slice 0: the path **relative to the project root** — this file's
101 /// identity, unique across `include` roots. `src/todos.bynk` and
102 /// `tests/todos.bynk` share a `source_path` (`todos.bynk`) but differ
103 /// here. Everything that *keys* a file — the analysed snapshots, the
104 /// diagnostic attribution — uses this; nothing that *validates a unit's
105 /// name* may.
106 ///
107 /// Equal to `source_path` for a single-root project (`Roots::Single`
108 /// resolves to one tree with an empty prefix), so single-root behaviour
109 /// is unchanged by construction.
110 pub(crate) identity_path: PathBuf,
111 /// v0.72: the absolute path the compiler read this file from, used as the
112 /// source-map `sources` entry so an editor's breakpoint (set on the real
113 /// `.bynk` file) resolves to the same path the debugger loads. `None` for
114 /// toolchain-injected synthetic units, which have no on-disk source.
115 pub(crate) abs_path: Option<PathBuf>,
116 pub(crate) source: String,
117 pub(crate) unit: SourceUnit,
118 pub(crate) kind: UnitKind,
119 /// v0.17: true for toolchain-injected units (the `bynk` surface) — exempt
120 /// from the reserved-namespace and missing-binding checks.
121 pub(crate) synthetic: bool,
122}
123
124impl ParsedFile {
125 /// Construct directly — used by `bynk-emit`'s first-party synthetic-unit
126 /// injection (`firstparty_parsed`), which builds a `ParsedFile` for a
127 /// toolchain-supplied source (`bynk.bynk`, `bynk.cloudflare`, …) that
128 /// never went through [`parse_sources`]'s discovery-driven path.
129 pub fn synthetic(
130 identity_path: PathBuf,
131 source_path: PathBuf,
132 source: String,
133 unit: SourceUnit,
134 kind: UnitKind,
135 ) -> Self {
136 ParsedFile {
137 source_path,
138 identity_path,
139 abs_path: None,
140 source,
141 unit,
142 kind,
143 synthetic: true,
144 }
145 }
146
147 /// General constructor — `bynk-emit`'s own tests use this to build a
148 /// hand-rolled `ParsedFile` fixture (a specific `source_path`/
149 /// `identity_path` pair, a non-synthetic unit) that neither
150 /// [`Self::synthetic`] (forces `source_path == identity_path`,
151 /// `synthetic: true`) nor [`parse_sources`] (needs a real token stream)
152 /// fits.
153 #[allow(clippy::too_many_arguments)]
154 pub fn new(
155 source_path: PathBuf,
156 identity_path: PathBuf,
157 abs_path: Option<PathBuf>,
158 source: String,
159 unit: SourceUnit,
160 kind: UnitKind,
161 synthetic: bool,
162 ) -> Self {
163 ParsedFile {
164 source_path,
165 identity_path,
166 abs_path,
167 source,
168 unit,
169 kind,
170 synthetic,
171 }
172 }
173
174 /// The path **relative to the `include` root that contains this file**.
175 pub fn source_path(&self) -> PathBuf {
176 self.source_path.clone()
177 }
178
179 /// The path **relative to the project root** — this file's identity,
180 /// unique across `include` roots. See the field's own doc for why this
181 /// and [`Self::source_path`] must not be conflated.
182 pub fn identity_path(&self) -> PathBuf {
183 self.identity_path.clone()
184 }
185
186 /// The absolute path this file was read from, when it has one — `None`
187 /// for toolchain-injected synthetic units.
188 pub fn abs_path(&self) -> Option<PathBuf> {
189 self.abs_path.clone()
190 }
191
192 pub fn kind(&self) -> UnitKind {
193 self.kind
194 }
195
196 /// Override the discovered kind — `bynk-emit`'s own tests use this to
197 /// build a scenario's intermediate unit as a commons regardless of what
198 /// AST shape (`context_using`, …) constructed it, without needing a
199 /// second builder per kind.
200 pub fn set_kind(&mut self, kind: UnitKind) {
201 self.kind = kind;
202 }
203
204 /// True for toolchain-injected units (the `bynk` surface).
205 pub fn is_synthetic(&self) -> bool {
206 self.synthetic
207 }
208
209 pub fn unit(&self) -> &SourceUnit {
210 &self.unit
211 }
212
213 /// Mutable access to the parsed unit — `bynk-emit`'s
214 /// `normalize_service_defaults` (service `by`/`given` default injection)
215 /// is the one caller that rewrites a unit's items in place, ahead of
216 /// grouping/checking.
217 pub fn unit_mut(&mut self) -> &mut SourceUnit {
218 &mut self.unit
219 }
220
221 /// The raw source text this file was parsed from.
222 pub fn source(&self) -> &str {
223 &self.source
224 }
225
226 /// v0.72: the source-map `sources` entry for this file — the absolute path
227 /// the compiler read it from (forward slashes), so an editor breakpoint set
228 /// on the real `.bynk` resolves to the same path the debugger loads. A
229 /// project-relative name would resolve against the emitted `.ts`'s directory,
230 /// which is the wrong place. Synthetic units (no on-disk source) fall back to
231 /// their relative path.
232 pub fn map_source_name(&self) -> String {
233 self.abs_path
234 .as_deref()
235 .unwrap_or(self.source_path.as_path())
236 .to_string_lossy()
237 .replace('\\', "/")
238 }
239
240 pub fn items(&self) -> &Vec<CommonsItem> {
241 match &self.unit {
242 SourceUnit::Commons(c) => &c.items,
243 SourceUnit::Context(c) => &c.items,
244 SourceUnit::Adapter(a) => &a.items,
245 SourceUnit::Suite(_) => {
246 // Tests don't contribute CommonsItem items; the production
247 // pipeline never asks them to. Return a singleton empty vec.
248 static EMPTY: std::sync::OnceLock<Vec<CommonsItem>> = std::sync::OnceLock::new();
249 EMPTY.get_or_init(Vec::new)
250 }
251 }
252 }
253
254 /// P6.x (#1137): does this file declare a `messages { … }` block? The one
255 /// predicate a `messages`-bundle emitter needs — whether to inject the
256 /// `bynk.locale` `render` fallback import — without the caller having to
257 /// walk [`items`](Self::items) and match [`CommonsItem::Messages`] itself.
258 pub fn declares_messages(&self) -> bool {
259 self.items()
260 .iter()
261 .any(|it| matches!(it, CommonsItem::Messages(_)))
262 }
263
264 pub fn uses(&self) -> &Vec<UsesDecl> {
265 match &self.unit {
266 SourceUnit::Commons(c) => &c.uses,
267 SourceUnit::Context(c) => &c.uses,
268 SourceUnit::Adapter(a) => &a.uses,
269 SourceUnit::Suite(t) => &t.uses,
270 }
271 }
272
273 pub fn consumes(&self) -> &[ConsumesDecl] {
274 match &self.unit {
275 SourceUnit::Commons(_) => &[],
276 SourceUnit::Context(c) => &c.consumes,
277 // v0.18: adapter-to-adapter capability dependencies (spec §4.5).
278 SourceUnit::Adapter(a) => &a.consumes,
279 // An integration test's participant edges are resolved separately
280 // (the harness root consumes every participant); it has no
281 // `consumes` of its own.
282 SourceUnit::Suite(_) => &[],
283 }
284 }
285
286 /// `exports` clauses, for the unit kinds that have them (contexts and
287 /// adapters). Empty for commons/tests.
288 pub fn exports(&self) -> &[ExportsDecl] {
289 match &self.unit {
290 SourceUnit::Context(c) => &c.exports,
291 SourceUnit::Adapter(a) => &a.exports,
292 _ => &[],
293 }
294 }
295
296 pub fn adapter(&self) -> Option<&AdapterDecl> {
297 match &self.unit {
298 SourceUnit::Adapter(a) => Some(a),
299 _ => None,
300 }
301 }
302
303 pub fn test(&self) -> Option<&SuiteDecl> {
304 match &self.unit {
305 SourceUnit::Suite(t) => Some(t),
306 _ => None,
307 }
308 }
309
310 /// v0.119 (ADR 0155): the agent names this file's own `for all run:
311 /// History[Agent]` properties drive — `emit_agent` gates the exported
312 /// `__bynkDriveHistory_<Agent>` driver on membership. Empty for a
313 /// non-suite file, or a suite with no such property.
314 pub fn history_target_agent_names(&self) -> impl Iterator<Item = &str> {
315 self.test()
316 .into_iter()
317 .flat_map(|t| &t.properties)
318 .flat_map(|prop| &prop.forall.bindings)
319 .filter_map(|b| match &b.type_ref {
320 TypeRef::History(inner, _) => match inner.as_ref() {
321 TypeRef::Named(id) => Some(id.name.as_str()),
322 _ => None,
323 },
324 _ => None,
325 })
326 }
327
328 /// v0.118: a suite whose *effective* tier is `system` is emitted through
329 /// the wired cross-Worker machinery (the retired standalone `integration`
330 /// path, now re-driven from tiers). Returns the underlying [`SuiteDecl`]
331 /// when this file is such a suite.
332 pub fn integration(&self) -> Option<&SuiteDecl> {
333 match &self.unit {
334 SourceUnit::Suite(t) if suite_effective_tier_is_system(t) => Some(t),
335 _ => None,
336 }
337 }
338
339 /// Build a synthetic Commons AST node carrying the given items, so the
340 /// existing resolver/checker pipeline can be driven uniformly.
341 pub fn as_synthetic_commons(&self, items: Vec<CommonsItem>) -> Commons {
342 let (name, uses, documentation, form, span) = match &self.unit {
343 SourceUnit::Commons(c) => (
344 c.name.clone(),
345 c.uses.clone(),
346 c.documentation.clone(),
347 c.form,
348 c.span,
349 ),
350 SourceUnit::Context(c) => (
351 c.name.clone(),
352 c.uses.clone(),
353 c.documentation.clone(),
354 c.form,
355 c.span,
356 ),
357 SourceUnit::Suite(t) => (
358 t.target.clone(),
359 t.uses.clone(),
360 t.documentation.clone(),
361 t.form,
362 t.span,
363 ),
364 SourceUnit::Adapter(a) => (
365 a.name.clone(),
366 a.uses.clone(),
367 a.documentation.clone(),
368 a.form,
369 a.span,
370 ),
371 };
372 Commons {
373 name,
374 items,
375 uses,
376 documentation,
377 form,
378 span,
379 trivia: Trivia::default(),
380 trailing_comments: Vec::new(),
381 }
382 }
383}
384
385/// Parse already-read source text into a [`ParsedFile`]. The read happens
386/// at the call site (v0.24): the pipeline owns the text for snapshots and
387/// per-file error attribution, and the overlay supplies unsaved buffers.
388/// Slice 0: `prefix` is this tree's project-root-relative `include` prefix
389/// (`src`, `tests`, …), empty for a single-root project. It builds each file's
390/// `identity_path`; `source_path` stays relative to `root` (the tree), which is
391/// what unit validation reads. See [`ParsedFile`].
392///
393/// P8.4 (#1515): the actual tokenize+parse now runs through
394/// [`crate::parse_cache::cached_parse`], keyed on the file's own absolute
395/// path — durable across separate calls to this function, not just within
396/// one, so a `FileId` (and the `ExprId`s a file's own expressions carry) no
397/// longer resets every time a fresh analysis pass parses an unchanged file.
398/// `next_expr_id`/`next_file_id` are no longer threaded in from the caller
399/// for this reason — the cache owns both counters now, durably.
400pub fn parse_sources(
401 root: &Path,
402 prefix: &Path,
403 path: &Path,
404 source: String,
405) -> Result<(Vec<ParsedFile>, Vec<CompileError>), Vec<CompileError>> {
406 // v0.72: cache (and identify a `ParsedFile`) by the *absolute* path —
407 // `path` is relative when the compiler was invoked with a relative input
408 // (`bynkc test .`), and a relative cache key would let the same file seen
409 // through two differently-rooted calls collide, or worse, miss. Falls
410 // back to `path` itself on the rare `std::path::absolute` failure, same
411 // as the pre-existing `abs_path` field it also feeds.
412 let abs_path = std::path::absolute(path).ok();
413 let cache_key: &Path = abs_path.as_deref().unwrap_or(path);
414 // v0.113: a file may declare more than one top-level unit — an *atomic*
415 // file holding `commons`/`context` alongside a `suite` (DECISION S). Each
416 // unit becomes its own `ParsedFile` sharing the file's source and path, so
417 // the downstream grouping partitions *declarations* by kind: the source
418 // units flow to the build, the suites to `bynkc test` only.
419 // ADR 0117: a warning-severity parse diagnostic (an orphan doc block)
420 // must not hard-fail discovery — the parsed units flow to the build and
421 // the warnings ride out to the caller's severity-aware sink.
422 let (_file_id, result) = crate::parse_cache::cached_parse(cache_key, &source);
423 let (units, warnings) = result.map_err(|errors| (*errors).clone())?;
424 let files = parsed_files(root, prefix, path, abs_path, &source, units.iter().cloned());
425 Ok((files, (*warnings).clone()))
426}
427
428/// #1710: what a file the strict parse rejects still yields, from its
429/// recovering parse: a [`ParsedFile`] per unit it recovered, the names of the
430/// declarations recovery had to skip (references to them are known names, not
431/// unknown ones; #1663's Decision B), and the recovering parse's diagnostics.
432/// `files` is empty when no unit survived (a broken header); the diagnostics
433/// still come from this recovery, with real spans. These files are for
434/// diagnostics only: the strict parse's error still stands, so nothing they
435/// belong to is ever emitted.
436pub struct RecoveredSources {
437 pub files: Vec<ParsedFile>,
438 pub broken_decl_names: Vec<String>,
439 pub errors: Vec<CompileError>,
440}
441
442/// #1710: [`parse_sources`] for a file whose strict parse failed, from the
443/// recovering parse the parse cache keeps beside that failure. `None` only
444/// when there is none: the source didn't lex.
445pub fn parse_sources_recovering(
446 root: &Path,
447 prefix: &Path,
448 path: &Path,
449 source: String,
450) -> Option<RecoveredSources> {
451 let abs_path = std::path::absolute(path).ok();
452 let cache_key: &Path = abs_path.as_deref().unwrap_or(path);
453 let recovered = crate::parse_cache::cached_recovery(cache_key, &source)?;
454 let files = parsed_files(
455 root,
456 prefix,
457 path,
458 abs_path,
459 &source,
460 recovered.units.iter().cloned(),
461 );
462 Some(RecoveredSources {
463 files,
464 broken_decl_names: recovered.broken_decl_names.clone(),
465 errors: recovered.errors.clone(),
466 })
467}
468
469/// One [`ParsedFile`] per unit of a file (a file may declare several, v0.113),
470/// each classified by kind and sharing the file's source and paths.
471fn parsed_files(
472 root: &Path,
473 prefix: &Path,
474 path: &Path,
475 abs_path: Option<PathBuf>,
476 source: &str,
477 units: impl Iterator<Item = SourceUnit>,
478) -> Vec<ParsedFile> {
479 let rel = path.strip_prefix(root).unwrap_or(path).to_path_buf();
480 units
481 .map(|unit| {
482 let kind = match &unit {
483 SourceUnit::Commons(_) => UnitKind::Commons,
484 SourceUnit::Context(_) => UnitKind::Context,
485 // v0.118: a suite whose effective tier is `system` is emitted
486 // through the wired cross-Worker machinery (classified as
487 // `Integration`); unit/integration-tier suites stay in-process.
488 SourceUnit::Suite(t) if suite_effective_tier_is_system(t) => UnitKind::Integration,
489 SourceUnit::Suite(_) => UnitKind::Test,
490 SourceUnit::Adapter(_) => UnitKind::Adapter,
491 };
492 ParsedFile {
493 abs_path: abs_path.clone(),
494 identity_path: prefix.join(&rel),
495 source_path: rel.clone(),
496 source: source.to_string(),
497 unit,
498 kind,
499 synthetic: false,
500 }
501 })
502 .collect()
503}
504
505pub fn discover_bynk_files(
506 root: &Path,
507 excludes: &[PathBuf],
508) -> Result<Vec<PathBuf>, CompileError> {
509 if !root.exists() {
510 return Err(CompileError::new(
511 "bynk.project.no_root",
512 Span::default(),
513 format!("project root does not exist: {}", root.display()),
514 ));
515 }
516 // v0.113: skip excluded subtrees (author `exclude` + the tool's own caches)
517 // and hidden directories, so an `include` root at the project root does not
518 // sweep up generated, vendored, or dot-directory `.bynk`.
519 let is_excluded = |dir: &Path| {
520 excludes.iter().any(|ex| dir == ex || dir.starts_with(ex))
521 || dir
522 .file_name()
523 .and_then(|n| n.to_str())
524 .is_some_and(|n| n.starts_with('.') && n != ".")
525 };
526 let mut out = Vec::new();
527 let mut stack = vec![root.to_path_buf()];
528 while let Some(dir) = stack.pop() {
529 let rd = match fs::read_dir(&dir) {
530 Ok(r) => r,
531 Err(e) => {
532 return Err(CompileError::new(
533 "bynk.project.read_failed",
534 Span::default(),
535 format!("could not read directory `{}`: {e}", dir.display()),
536 ));
537 }
538 };
539 for entry in rd.flatten() {
540 let p = entry.path();
541 if p.is_dir() {
542 if !is_excluded(&p) {
543 stack.push(p);
544 }
545 } else if p.extension().and_then(|e| e.to_str()) == Some("bynk") {
546 out.push(p);
547 }
548 }
549 }
550 out.sort();
551 Ok(out)
552}
553
554/// Slice A: the `.bynk` files these roots contain — the **same walk**
555/// `compile_project` performs, honouring `exclude` and the tool's own `out`/
556/// `node_modules` caches.
557///
558/// P4.2 (#1122, Decision B): moved here from `bynk-emit/src/project.rs` — its
559/// body called only `bynk-project`-local functions already, with no
560/// `bynk-emit`-specific state. `bynk-emit` re-exports it at its existing
561/// `bynk_emit::project::discover_project_files` path so `read_disk_sources`
562/// and `bynk-testkit` need no edit; `bynk-ide` calls this path directly.
563pub fn discover_project_files(roots: &Roots) -> Vec<PathBuf> {
564 let trees = roots.trees();
565 let excludes = roots.excludes();
566 let mut out = Vec::new();
567 for (root, _prefix) in &trees {
568 // Every tree past the first is optional — a project may simply have
569 // no such subtree (R3.9, #1113: every `include` entry is walked, not
570 // just the first two). `unwrap_or_default` already treats a missing
571 // root the same as "no files here" for every tree, first included —
572 // no need to `root.exists()` before calling `discover_bynk_files`
573 // (itself a `fs::read_dir`) just to decide whether to call it: that
574 // would cost a redundant `stat()` per tree for the same answer.
575 out.extend(discover_bynk_files(root, &excludes).unwrap_or_default());
576 }
577 out.sort();
578 out.dedup();
579 out
580}
581
582pub fn check_file_directory_conflicts(
583 root: &Path,
584 files: &[PathBuf],
585) -> Result<(), Vec<CompileError>> {
586 let mut errors: Vec<CompileError> = Vec::new();
587 let mut bynk_files: HashSet<PathBuf> = HashSet::new();
588 let mut dirs_with_bynk: HashSet<PathBuf> = HashSet::new();
589 for p in files {
590 let rel = p.strip_prefix(root).unwrap_or(p);
591 bynk_files.insert(rel.to_path_buf());
592 if let Some(parent) = rel.parent() {
593 dirs_with_bynk.insert(parent.to_path_buf());
594 }
595 }
596 for f in &bynk_files {
597 let stem = f.with_extension("");
598 if dirs_with_bynk.contains(&stem) {
599 errors.push(
600 CompileError::new(
601 "bynk.project.file_and_directory",
602 Span::default(),
603 format!(
604 "commons at `{}` is ambiguous: both `{}` and `{}/` exist with `.bynk` content",
605 f.with_extension("").display(),
606 f.display(),
607 stem.display()
608 ),
609 )
610 .with_note(
611 "a commons can be a single `.bynk` file OR a directory of `.bynk` files, not both",
612 ),
613 );
614 }
615 }
616 if errors.is_empty() {
617 Ok(())
618 } else {
619 Err(errors)
620 }
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626
627 /// #1662: discovery reports a missing project root as a diagnostic, which
628 /// a library host (the LSP, a multi-root build) surfaces as-is; the CLI
629 /// checks the path itself first, so only this test reaches the code.
630 #[test]
631 fn a_missing_root_is_reported_as_no_root() {
632 let root = std::env::temp_dir().join(format!(
633 "bynk-discovery-no-root-{}-does-not-exist",
634 std::process::id()
635 ));
636 let err =
637 discover_bynk_files(&root, &[]).expect_err("a missing root must not discover files");
638 assert_eq!(err.category, "bynk.project.no_root");
639 }
640
641 /// Finding #55/#65: a synthetic path that never exists on disk (the
642 /// in-memory/wasm case) must still resolve via the overlay's literal-path
643 /// entry — `canonicalize()` on such a path always fails, so the fix tries
644 /// the literal path first rather than paying for that failing syscall on
645 /// every read.
646 #[test]
647 fn read_source_finds_a_synthetic_overlay_path_that_does_not_exist_on_disk() {
648 let path = PathBuf::from("./__bynk_in_memory__/t.bynk");
649 let mut overlay = HashMap::new();
650 overlay.insert(path.clone(), "context t\n".to_string());
651 let got = read_source(&path, &overlay).expect("the overlay entry must be found");
652 assert_eq!(got, "context t\n");
653 }
654
655 /// Content-ownership track (#1086) slice 5: a real on-disk file with no
656 /// overlay entry must now error, never silently fall back to reading it
657 /// off disk — the disk-read fallback this test guards the absence of was
658 /// deleted in this slice; every caller supplies a complete overlay.
659 #[test]
660 fn read_source_errors_on_a_real_file_with_no_overlay_entry_rather_than_reading_disk() {
661 let dir = std::env::temp_dir().join(format!(
662 "bynk-emit-discovery-fallback-test-{}",
663 std::process::id()
664 ));
665 std::fs::create_dir_all(&dir).expect("create test dir");
666 let path = dir.join("t.bynk");
667 std::fs::write(&path, "context t\n").expect("write real file");
668 let got = read_source(&path, &HashMap::new());
669 std::fs::remove_dir_all(&dir).ok();
670 assert!(
671 got.is_err(),
672 "a real file with no overlay entry must not be silently read from disk"
673 );
674 assert_eq!(got.unwrap_err().kind(), std::io::ErrorKind::NotFound);
675 }
676}