bynk_ide/lib.rs
1//! Bynk's IDE/LSP analysis surface.
2//!
3//! The non-bailing diagnostics the language server consumes — single-file
4//! ([`diagnose`]) and whole-project ([`diagnose_project_with`], with
5//! [`diagnose_project`] the single-tree convenience) — plus the result
6//! types ([`Diagnostic`], [`FileDiagnostics`], [`ProjectDiagnostics`]). These
7//! are *queries* over the captured tables produced during analysis (the binding
8//! index, inlay hints, expression types, locals — all in `bynk-check`); the
9//! project analysis itself ([`bynk_check::analysis::analyse_project`]) is the
10//! non-bailing counterpart to `compile_project`.
11//!
12//! Extracted from `bynkc` as slice 5 of the crate-decomposition track over
13//! `bynk-syntax` + `bynk-check` + `bynk-emit` (P4.2, #1122: this crate no
14//! longer depends on `bynk-emit` at all — it reaches `bynk-check` and
15//! `bynk-project` directly). Behaviour is unchanged; the
16//! language server (`bynk-lsp`) depends on this crate directly instead of the
17//! whole `bynkc` compiler crate, and `bynkc` re-exports these items so its own
18//! tests and public API are unchanged.
19
20use std::collections::HashMap;
21use std::path::{Path, PathBuf};
22
23use bynk_check::{checker, expr_types, hints, index, locals, requirements, resolver};
24use bynk_syntax::error::{CompileError, Severity};
25use bynk_syntax::{ast, lexer, parser};
26
27/// #855: same rationale as [`ContextSequenceInfo`] above — a plain
28/// re-export, since `ContextBoundaryInfo`'s fields are already
29/// IDE-appropriate as-is.
30pub use bynk_check::analysis::ContextBoundaryInfo;
31/// #846: re-exported rather than left as a raw `bynk_check::analysis` path —
32/// `bynk-lsp` links `bynk-ide`/`bynk-check`/`bynk-syntax` directly and
33/// deliberately does not depend on `bynk-emit` (the whole-compiler crate);
34/// see `bynk-lsp/Cargo.toml`'s dependency comment. Unlike `Roots`/
35/// `AnalysisRoots` (which the IDE layer re-shapes because the raw type
36/// carries build-only concerns), `ContextSequenceInfo`'s fields are already
37/// IDE-appropriate as-is, so this is a plain re-export rather than a lowering.
38pub use bynk_check::analysis::ContextSequenceInfo;
39pub use bynk_project::LabelFile;
40/// #2081: how a caller hands [`diagnose_project_with`] the project's
41/// `bynk.schema.lock` (ADR 0321's type, shared with the build).
42pub use bynk_project::SchemaLock;
43/// #2207: a workspace root groups projects and isn't one itself, so the
44/// language server never analyses it as a project.
45pub use bynk_project::is_workspace_root;
46
47pub mod agent_effects;
48/// #2220: the agent state model lives in `bynk-check`, shared with the
49/// checker; re-exported here so existing paths keep working.
50pub use bynk_check::agent_state;
51pub mod architecture;
52pub mod completion;
53pub mod documentation;
54pub mod locals_nav;
55pub mod packages;
56pub mod sequence;
57pub mod signature_help;
58pub mod symbols;
59pub mod wire_contract;
60
61/// One diagnostic produced from a recovery-mode compile of a single file.
62#[derive(Debug, Clone)]
63pub struct Diagnostic {
64 pub error: CompileError,
65 pub severity: Severity,
66 /// The file each of `error.labels` lies in, index for index, as
67 /// [`bynk_project::AttributedError::label_files`] says — a label in a
68 /// sibling file belongs to that file's URI, not this diagnostic's. Empty
69 /// (every label in this file) for a single-file diagnosis.
70 pub label_files: Vec<LabelFile>,
71}
72
73impl Diagnostic {
74 /// The file `error.labels[index]` lies in.
75 pub fn label_file(&self, index: usize) -> &LabelFile {
76 self.label_files.get(index).unwrap_or(&LabelFile::Primary)
77 }
78}
79
80/// Best-effort single-file compilation that always returns diagnostics.
81///
82/// Used by the LSP server: lex → parse-with-recovery → resolve → check, with
83/// each phase accumulating its diagnostics. The returned `SourceUnit` is
84/// `Some` whenever the parser produced one (which is true for any file with a
85/// recognisable header, even if individual items failed). Resolve and check
86/// run only when both the lexer and parser produced a unit; their errors are
87/// added to the same diagnostic list.
88///
89/// The TypeScript output is intentionally not produced here — the LSP only
90/// needs diagnostics; the CLI uses `compile` / `compile_project`.
91pub fn diagnose(source: &str) -> Vec<Diagnostic> {
92 let mut diagnostics = Vec::new();
93 let tokens = match lexer::tokenize(source) {
94 Ok(t) => t,
95 Err(e) => {
96 diagnostics.push(Diagnostic {
97 severity: Severity::for_error(&e),
98 error: e,
99 label_files: Vec::new(),
100 });
101 return diagnostics;
102 }
103 };
104 let parser::Recovered {
105 units,
106 errors: parse_errors,
107 broken_decl_names,
108 } = parser::parse_units_recovering(&tokens, source);
109 let unit_opt = units.into_iter().next();
110 for e in parse_errors {
111 diagnostics.push(Diagnostic {
112 severity: Severity::for_error(&e),
113 error: e,
114 label_files: Vec::new(),
115 });
116 }
117 let Some(unit) = unit_opt else {
118 return diagnostics;
119 };
120 // Resolution and checking are only well-defined for self-contained
121 // commons units in single-file mode — contexts go through compile_project
122 // which has the cross-file machinery. Match the same restriction here.
123 if let ast::SourceUnit::Commons(c) = unit {
124 let push = |diagnostics: &mut Vec<Diagnostic>, errs: Vec<CompileError>| {
125 diagnostics.extend(errs.into_iter().map(|e| Diagnostic {
126 severity: Severity::for_error(&e),
127 error: e,
128 label_files: Vec::new(),
129 }));
130 };
131 // #1663 (Decision A): a resolve error no longer stops the checker. It
132 // still checks every declaration; its diagnostics in a declaration the
133 // resolver rejected are echoes and are dropped.
134 let item_spans: Vec<_> = c.items.iter().map(|i| i.span()).collect();
135 let (resolved, resolve_errors) = resolver::resolve_recovering(c);
136 if resolve_errors.is_empty()
137 && let Err(errs) = resolver::resolve_file(&resolved)
138 {
139 push(&mut diagnostics, errs);
140 }
141 // #1663 (Decision B): an unknown name that is a declaration the parser
142 // skipped is not reported again; it still rejects its declaration.
143 let (shown, _hidden) =
144 resolver::split_broken_decl_echoes(resolve_errors.clone(), &broken_decl_names);
145 push(&mut diagnostics, shown);
146 // ADR 0117: a clean check may still carry non-failing warnings
147 // (`Ok` now), so surface those too — not only the `Err` path.
148 // #1710: the checker reports some echoes of a skipped declaration
149 // under its own codes (a method, a capability, an actor), so its
150 // diagnostics are split the same way.
151 let unechoed = |errs| resolver::split_broken_decl_echoes(errs, &broken_decl_names).0;
152 match checker::check(resolved) {
153 Ok(typed) => push(&mut diagnostics, unechoed(typed.warnings)),
154 Err(errs) => push(
155 &mut diagnostics,
156 unechoed(resolver::without_resolve_echoes(
157 errs,
158 &resolve_errors,
159 &item_spans,
160 )),
161 ),
162 }
163 }
164 diagnostics
165}
166
167/// Per-file diagnostics from a whole-project analysis.
168/// v0.24 (ADR 0052): `text` is the **analysed snapshot** — positions must
169/// convert against it, not a newer buffer (the analyse→publish window is real).
170pub struct FileDiagnostics {
171 /// Project-root-relative source path.
172 pub source_path: PathBuf,
173 /// The exact text that was analysed (overlay or disk).
174 pub text: String,
175 pub diagnostics: Vec<Diagnostic>,
176}
177
178/// v0.24: the result of [`diagnose_project`]. Every discovered file appears
179/// in `files` — clean files with an empty list — so a consumer can clear
180/// stale diagnostics. `unattributed` holds project-level diagnostics with
181/// no single owning file (group/cycle/directory validations).
182pub struct ProjectDiagnostics {
183 pub files: Vec<FileDiagnostics>,
184 pub unattributed: Vec<Diagnostic>,
185 /// v0.25 (ADR 0053): the project-wide binding index — every in-scope
186 /// symbol's definition and reference sites, spans against the analysed
187 /// snapshots in `files`.
188 pub index: index::ProjectIndex,
189 /// v0.27 (ADR 0056): per-file inferred-type inlay hints — `(binding-name
190 /// span, label)`, span-ordered, spans against the analysed snapshots.
191 pub hints: hints::FileHints,
192 /// v0.30.2 (ADR 0063): per-file expression types — `(expr span, Ty)`,
193 /// for `.`-member completion's receiver typing. A file with errors still
194 /// carries the best-effort partial types of its other expressions
195 /// (ADR 0094, which lifted ADR 0063's clean-file ceiling).
196 pub expr_types: expr_types::FileExprTypes,
197 /// T3.6b (R4.1): the intern table `expr_types`' `TyId`s resolve against —
198 /// one per analysis, shared across every unit it checked.
199 pub ty_intern: std::sync::Arc<bynk_check::checker::Types>,
200 /// v0.31 (ADR 0064): per-file local bindings with scope ranges, for the
201 /// scope-at-offset query backing locals completion + navigation.
202 pub locals: locals::FileLocals,
203 /// v0.99: per-file capability-requirement ledger — every capability-consuming
204 /// site with its provenance, driving the ghost `given` inlay hint and hover.
205 pub requirements: requirements::FileRequirements,
206 /// Slice 6b (ADR 0095): qualified unit name → its project source file(s),
207 /// in discovery order — the unit→file map backing document links and
208 /// consumed-context navigation. Synthetic units excluded; empty on a bail.
209 pub unit_sources: HashMap<String, Vec<PathBuf>>,
210 /// #846: qualified context/adapter unit name → the cross-context/agent
211 /// tables the sequence-diagram query classifies handler calls against.
212 /// See `bynk_check::analysis::ProjectAnalysis::sequence_info`.
213 pub sequence_info: HashMap<String, ContextSequenceInfo>,
214 /// #855: qualified context/adapter unit name → the combined type table
215 /// and service/agent tables the wire-contract peek resolves a handler's
216 /// boundary shape and cross-context hash against.
217 /// See `bynk_check::analysis::ProjectAnalysis::boundary_info`.
218 pub boundary_info: HashMap<String, ContextBoundaryInfo>,
219 /// #848: qualified unit name → its doc-comment intra-doc-link search
220 /// order — itself first, then its `uses` targets, then its `consumes`
221 /// targets. See `bynk_check::analysis::ProjectAnalysis::doc_scope`.
222 pub doc_scope: HashMap<String, Vec<String>>,
223}
224
225/// Slice A: which trees a project's analysis walks.
226///
227/// `bynk-ide` owns this rather than re-exporting `bynk_project::Roots`:
228/// this crate is the IDE-facing published surface, and `Roots` carries
229/// `tests_prefix` semantics an IDE caller has no business knowing. The lowering
230/// is a few lines and it is the seam where the LSP's needs and the compiler's
231/// can diverge later without a break.
232#[derive(Debug, Clone)]
233pub enum AnalysisRoots {
234 /// One tree, walked as a single root, with no manifest consulted — the
235 /// pre-slice-A behaviour and what [`diagnose_project`] still means.
236 SingleTree(PathBuf),
237 /// A manifest-backed project rooted here: `bynk.toml`'s `[paths]
238 /// include`/`exclude` decide the trees, exactly as `bynkc` reads them.
239 /// Mirrors `bynk-driver`'s `project_options` — the compiler's own choice.
240 Project(PathBuf),
241}
242
243impl AnalysisRoots {
244 /// Content-ownership track (#1086) slice 2: `overlay` threads into
245 /// `try_read_project_paths_with` (already overlay-aware, already `pub`)
246 /// instead of the disk-only `read_project_paths`, so an unsaved edit to
247 /// `bynk.toml` itself — not just to the `.bynk` sources it names — is
248 /// visible to the `Project` variant's manifest read. Mirrors `343b2482`'s
249 /// CLI-side fix (`bynk-driver`'s `project_options`) on the LSP side.
250 ///
251 /// `discover_files` (below) threads `overlay` straight through now (slice
252 /// 5 correction, below) — both it and `diagnose_project_with` need the
253 /// same real-or-overlaid `bynk.toml` content to resolve the same roots.
254 ///
255 /// Content-ownership track (#1086) slice 5 correction (found only under
256 /// implementation): reading `bynk.toml` through `overlay` alone used to
257 /// still reach the real on-disk manifest on a miss, because `bynk-emit`'s
258 /// `read_source` fell back to a real disk read. Slice 5 deleted that
259 /// fallback — but R2.3 forbids this crate from touching the filesystem
260 /// itself to restore it here, so it does **not** grow a fallback of its
261 /// own; every caller (`bynk-lsp`'s `sweep_project_content`,
262 /// `bynk-testkit::read_project_sources`) now reads `bynk.toml` itself,
263 /// above the driver boundary, and includes it in the `overlay`/content
264 /// map it hands in — the same "process edge constructs `Sources`" R2.3
265 /// already requires of every `.bynk` file. `lower` stays exactly what its
266 /// slice-2 doc above describes: caller's overlay, nothing else.
267 fn lower(&self, overlay: &HashMap<PathBuf, String>) -> bynk_project::Roots {
268 match self {
269 AnalysisRoots::SingleTree(root) => bynk_project::Roots::Single(root.clone()),
270 AnalysisRoots::Project(root) => bynk_project::Roots::Split {
271 project_root: root.clone(),
272 paths: bynk_project::try_read_project_paths_with(root, overlay)
273 .unwrap_or_else(|_| bynk_project::ProjectPaths::conventional(root)),
274 },
275 }
276 }
277
278 /// The project root every analysed `source_path` is relative to. For
279 /// `SingleTree` that is the tree itself (identity ≡ tree-relative, ADR
280 /// 0198).
281 pub fn project_root(&self) -> &Path {
282 match self {
283 AnalysisRoots::SingleTree(r) | AnalysisRoots::Project(r) => r,
284 }
285 }
286}
287
288/// Slice A: the `.bynk` files these roots contain — the same discovery
289/// `compile_project` performs, `exclude` and the `out`/`node_modules` caches
290/// honoured. For enumerating a project's units without analysing it.
291///
292/// `overlay` is consulted only for `bynk.toml` itself (via `lower`) — an
293/// `AnalysisRoots::Project`'s `[paths] include`/`exclude` decide what gets
294/// walked, so the caller must include a real-or-overlaid manifest entry for
295/// a non-conventional layout to enumerate correctly (content-ownership
296/// track (#1086) slice 5: this crate stays disk-free per R2.3, so it cannot
297/// fall back to reading `bynk.toml` itself on a miss).
298pub fn discover_files(roots: &AnalysisRoots, overlay: &HashMap<PathBuf, String>) -> Vec<PathBuf> {
299 bynk_project::discover_project_files(&roots.lower(overlay))
300}
301
302/// #302: the qualified name a file moved from `old_rel` to `new_rel` should
303/// now declare, preserving whichever single-file/multi-file arrangement
304/// `old_rel` used to satisfy against `old_name` — for the LSP's
305/// `workspace/willRenameFiles` handler.
306pub fn renamed_unit_name(old_rel: &Path, old_name: &str, new_rel: &Path) -> Option<String> {
307 bynk_project::renamed_unit_name(old_rel, old_name, new_rel)
308}
309
310/// v0.24 (ADR 0052): non-bailing, overlay-aware, file-attributed project
311/// diagnostics — the LSP analysis entry point, distinct from
312/// `compile_project` (which bails and emits). `overlay` maps
313/// canonicalised absolute paths to buffer text layered over disk reads.
314///
315/// Slice A: this is the **single-tree convenience** over
316/// [`diagnose_project_with`] — it walks `root` as one tree and consults no
317/// manifest, which is what every caller handing in a fixture root already
318/// means. A manifest-backed project wants
319/// `diagnose_project_with(&AnalysisRoots::Project(root), …)`.
320///
321/// #2081: it passes [`SchemaLock::Off`], so it never reconciles events
322/// against `bynk.schema.lock`. Every caller is a test or a test kit; the
323/// language server calls [`diagnose_project_with`] with the lock it read.
324pub fn diagnose_project(root: &Path, overlay: &HashMap<PathBuf, String>) -> ProjectDiagnostics {
325 diagnose_project_with(
326 &AnalysisRoots::SingleTree(root.to_path_buf()),
327 overlay,
328 &SchemaLock::Off,
329 )
330}
331
332/// Slice A: project diagnostics over manifest-resolved roots — the LSP analyses
333/// exactly the files `bynkc` compiles, from the same manifest, through the same
334/// discovery.
335///
336/// Every path in the result is **project-relative** (ADR 0198), so a file is
337/// named uniquely across `include` roots.
338///
339/// `schema_lock` (#2081) is the project's `bynk.schema.lock`, read by the
340/// caller (this crate reads no disk): under [`SchemaLock::On`] events are
341/// reconciled against it, read-only, so a non-additive change is reported as
342/// `bynk check` reports it; [`SchemaLock::Off`] skips that.
343pub fn diagnose_project_with(
344 roots: &AnalysisRoots,
345 overlay: &HashMap<PathBuf, String>,
346 schema_lock: &SchemaLock,
347) -> ProjectDiagnostics {
348 // #43: destructured (not `analysis.field`-by-field) so that a field added
349 // to `ProjectAnalysis` without a matching update *here* is a compile
350 // error (an unmentioned field in a struct pattern), not a silently
351 // stale `ProjectDiagnostics` — the fields below are one-for-one
352 // identical between the two types precisely because this site can't
353 // forget one.
354 let bynk_check::analysis::ProjectAnalysis {
355 snapshots,
356 errors,
357 index,
358 hints,
359 expr_types,
360 ty_intern,
361 locals,
362 requirements,
363 unit_sources,
364 sequence_info,
365 boundary_info,
366 doc_scope,
367 } = bynk_check::analysis::analyse_project(&roots.lower(overlay), overlay, schema_lock);
368 let mut by_file: HashMap<PathBuf, Vec<Diagnostic>> = HashMap::new();
369 let mut unattributed = Vec::new();
370 for ae in errors {
371 let d = Diagnostic {
372 severity: Severity::for_error(&ae.error),
373 error: ae.error,
374 label_files: ae.label_files,
375 };
376 match ae.source_path {
377 Some(p) => by_file.entry(p).or_default().push(d),
378 None => unattributed.push(d),
379 }
380 }
381 let files = snapshots
382 .into_iter()
383 .map(|(source_path, text)| FileDiagnostics {
384 diagnostics: by_file.remove(&source_path).unwrap_or_default(),
385 source_path,
386 text,
387 })
388 .collect();
389 // Anything attributed to a path without a snapshot (defensive — should
390 // not happen) still surfaces rather than vanishing.
391 for (_, ds) in by_file {
392 unattributed.extend(ds);
393 }
394 ProjectDiagnostics {
395 files,
396 unattributed,
397 index,
398 hints,
399 requirements,
400 expr_types,
401 ty_intern,
402 locals,
403 unit_sources,
404 sequence_info,
405 boundary_info,
406 doc_scope,
407 }
408}
409
410#[cfg(test)]
411mod testkit {
412 //! In-process test helpers for this crate's own inline `#[cfg(test)]`
413 //! modules (content-ownership track, #1086, slice 4).
414 //!
415 //! This crate cannot depend on the cross-crate `bynk-testkit` (that crate
416 //! depends on `bynk-ide`, so a dev-dependency back onto it would cycle —
417 //! Cargo would instantiate two separate copies of this very crate, making
418 //! `crate::AnalysisRoots` and `bynk_ide::AnalysisRoots` distinct,
419 //! non-interchangeable types; found the hard way in slice 3, not
420 //! foreseen). So this mirrors `bynk-testkit::read_project_sources`
421 //! directly against `crate::discover_files`, the same production
422 //! discovery both use.
423 //!
424 //! An inline module, not `bynk-ide/src/testkit.rs` as a separate
425 //! `#[cfg(test)]`-gated file: `fs_below_driver`'s probe
426 //! (`xtask/src/greenfield_status.rs`) only recognises an inline
427 //! `#[cfg(test)] mod name { … }` block as test-scope — a whole file gated
428 //! by its *declaration* (`#[cfg(test)] mod testkit;` in a different file)
429 //! isn't a pattern it looks for, so a separate file's `std::fs` calls read
430 //! as production usage and moved `fs_below_driver`'s `bynk-ide` count
431 //! from 0 back to 1 (found by running `cargo xtask greenfield-status`
432 //! after the first version of this module, not anticipated).
433
434 use std::collections::HashMap;
435 use std::path::{Path, PathBuf};
436
437 /// A complete `(path, content)` map for `roots` — the direct replacement
438 /// for `diagnose_project(&root, &HashMap::new())`'s reliance on
439 /// `bynk-emit`'s disk fallback. Keyed by the literal discovered path, not
440 /// canonicalised — matching `bynk-testkit`'s own convention, itself
441 /// matching `bynk-driver`'s production `sources_for_roots` (canonicalising
442 /// broke a project-consistency check the hard way in slice 3 — see
443 /// `bynk-testkit/src/lib.rs`'s doc).
444 pub(crate) fn read_project_sources(roots: &crate::AnalysisRoots) -> HashMap<PathBuf, String> {
445 // Only ever called with `SingleTree` (below), which consults no
446 // manifest — an empty overlay is correct, not a stand-in fallback.
447 crate::discover_files(roots, &HashMap::new())
448 .into_iter()
449 .filter_map(|p| {
450 let content = std::fs::read_to_string(&p).ok()?;
451 Some((p, content))
452 })
453 .collect()
454 }
455
456 /// `diagnose_project(root, &HashMap::new())`, with a complete sources map
457 /// instead of relying on `bynk-emit`'s disk fallback to fill it in.
458 pub(crate) fn diagnose_project(root: &Path) -> crate::ProjectDiagnostics {
459 let sources = read_project_sources(&crate::AnalysisRoots::SingleTree(root.to_path_buf()));
460 crate::diagnose_project(root, &sources)
461 }
462}