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