Skip to main content

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}