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