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`]) — plus the result
5//! types ([`Diagnostic`], [`FileDiagnostics`], [`ProjectDiagnostics`]). These
6//! are *queries* over the captured tables produced during analysis (the binding
7//! index, inlay hints, expression types, locals — all in `bynk-check`); the
8//! project analysis itself ([`bynk_emit::project::analyse_project`]) is the
9//! non-bailing counterpart to `compile_project`.
10//!
11//! Extracted from `bynkc` as slice 5 of the crate-decomposition track over
12//! `bynk-syntax` + `bynk-check` + `bynk-emit`. Behaviour is unchanged; the
13//! language server (`bynk-lsp`) depends on this crate directly instead of the
14//! whole `bynkc` compiler crate, and `bynkc` re-exports these items so its own
15//! tests and public API are unchanged.
16
17use std::collections::HashMap;
18use std::path::{Path, PathBuf};
19
20use bynk_check::{checker, expr_types, hints, index, locals, requirements, resolver};
21use bynk_syntax::error::{CompileError, Severity};
22use bynk_syntax::{ast, lexer, parser};
23
24/// One diagnostic produced from a recovery-mode compile of a single file.
25#[derive(Debug, Clone)]
26pub struct Diagnostic {
27    pub error: CompileError,
28    pub severity: Severity,
29}
30
31/// Best-effort single-file compilation that always returns diagnostics.
32///
33/// Used by the LSP server: lex → parse-with-recovery → resolve → check, with
34/// each phase accumulating its diagnostics. The returned `SourceUnit` is
35/// `Some` whenever the parser produced one (which is true for any file with a
36/// recognisable header, even if individual items failed). Resolve and check
37/// run only when both the lexer and parser produced a unit; their errors are
38/// added to the same diagnostic list.
39///
40/// The TypeScript output is intentionally not produced here — the LSP only
41/// needs diagnostics; the CLI uses `compile` / `compile_project`.
42pub fn diagnose(source: &str) -> Vec<Diagnostic> {
43    let mut diagnostics = Vec::new();
44    let tokens = match lexer::tokenize(source) {
45        Ok(t) => t,
46        Err(e) => {
47            diagnostics.push(Diagnostic {
48                severity: Severity::for_error(&e),
49                error: e,
50            });
51            return diagnostics;
52        }
53    };
54    let (unit_opt, parse_errors) = parser::parse_unit_with_recovery(&tokens, source);
55    for e in parse_errors {
56        diagnostics.push(Diagnostic {
57            severity: Severity::for_error(&e),
58            error: e,
59        });
60    }
61    let Some(unit) = unit_opt else {
62        return diagnostics;
63    };
64    // Resolution and checking are only well-defined for self-contained
65    // commons units in single-file mode — contexts go through compile_project
66    // which has the cross-file machinery. Match the same restriction here.
67    if let ast::SourceUnit::Commons(c) = unit {
68        match resolver::resolve(c) {
69            Ok(resolved) => {
70                if let Err(errs) = resolver::resolve_file(&resolved) {
71                    for e in errs {
72                        diagnostics.push(Diagnostic {
73                            severity: Severity::for_error(&e),
74                            error: e,
75                        });
76                    }
77                }
78                // ADR 0117: a clean check may still carry non-failing warnings
79                // (`Ok` now), so surface those too — not only the `Err` path.
80                match checker::check(resolved) {
81                    Ok(typed) => {
82                        for e in typed.warnings {
83                            diagnostics.push(Diagnostic {
84                                severity: Severity::for_error(&e),
85                                error: e,
86                            });
87                        }
88                    }
89                    Err(errs) => {
90                        for e in errs {
91                            diagnostics.push(Diagnostic {
92                                severity: Severity::for_error(&e),
93                                error: e,
94                            });
95                        }
96                    }
97                }
98            }
99            Err(errs) => {
100                for e in errs {
101                    diagnostics.push(Diagnostic {
102                        severity: Severity::for_error(&e),
103                        error: e,
104                    });
105                }
106            }
107        }
108    }
109    diagnostics
110}
111
112/// Per-file diagnostics from a whole-project analysis.
113/// v0.24 (ADR 0052): `text` is the **analysed snapshot** — positions must
114/// convert against it, not a newer buffer (the analyse→publish window is real).
115pub struct FileDiagnostics {
116    /// Project-root-relative source path.
117    pub source_path: PathBuf,
118    /// The exact text that was analysed (overlay or disk).
119    pub text: String,
120    pub diagnostics: Vec<Diagnostic>,
121}
122
123/// v0.24: the result of [`diagnose_project`]. Every discovered file appears
124/// in `files` — clean files with an empty list — so a consumer can clear
125/// stale diagnostics. `unattributed` holds project-level diagnostics with
126/// no single owning file (group/cycle/directory validations).
127pub struct ProjectDiagnostics {
128    pub files: Vec<FileDiagnostics>,
129    pub unattributed: Vec<Diagnostic>,
130    /// v0.25 (ADR 0053): the project-wide binding index — every in-scope
131    /// symbol's definition and reference sites, spans against the analysed
132    /// snapshots in `files`.
133    pub index: index::ProjectIndex,
134    /// v0.27 (ADR 0056): per-file inferred-type inlay hints — `(binding-name
135    /// span, label)`, span-ordered, spans against the analysed snapshots.
136    pub hints: hints::FileHints,
137    /// v0.30.2 (ADR 0063): per-file expression types — `(expr span, Ty)`,
138    /// captured on the Ok path, for `.`-member completion's receiver typing.
139    /// Empty for files with errors (the clean-file ceiling).
140    pub expr_types: expr_types::FileExprTypes,
141    /// v0.31 (ADR 0064): per-file local bindings with scope ranges, for the
142    /// scope-at-offset query backing locals completion + navigation.
143    pub locals: locals::FileLocals,
144    /// v0.99: per-file capability-requirement ledger — every capability-consuming
145    /// site with its provenance, driving the ghost `given` inlay hint and hover.
146    pub requirements: requirements::FileRequirements,
147    /// Slice 6b (ADR 0095): qualified unit name → its project source file(s),
148    /// in discovery order — the unit→file map backing document links and
149    /// consumed-context navigation. Synthetic units excluded; empty on a bail.
150    pub unit_sources: HashMap<String, Vec<PathBuf>>,
151}
152
153/// v0.24 (ADR 0052): non-bailing, overlay-aware, file-attributed project
154/// diagnostics — the LSP analysis entry point, distinct from
155/// `compile_project` (which bails and emits). `overlay` maps
156/// canonicalised absolute paths to buffer text layered over disk reads.
157pub fn diagnose_project(root: &Path, overlay: &HashMap<PathBuf, String>) -> ProjectDiagnostics {
158    let analysis = bynk_emit::project::analyse_project(root, overlay);
159    let mut by_file: HashMap<PathBuf, Vec<Diagnostic>> = HashMap::new();
160    let mut unattributed = Vec::new();
161    for ae in analysis.errors {
162        let d = Diagnostic {
163            severity: Severity::for_error(&ae.error),
164            error: ae.error,
165        };
166        match ae.source_path {
167            Some(p) => by_file.entry(p).or_default().push(d),
168            None => unattributed.push(d),
169        }
170    }
171    let files = analysis
172        .snapshots
173        .into_iter()
174        .map(|(source_path, text)| FileDiagnostics {
175            diagnostics: by_file.remove(&source_path).unwrap_or_default(),
176            source_path,
177            text,
178        })
179        .collect();
180    // Anything attributed to a path without a snapshot (defensive — should
181    // not happen) still surfaces rather than vanishing.
182    for (_, ds) in by_file {
183        unattributed.extend(ds);
184    }
185    ProjectDiagnostics {
186        files,
187        unattributed,
188        index: analysis.index,
189        hints: analysis.hints,
190        requirements: analysis.requirements,
191        expr_types: analysis.expr_types,
192        locals: analysis.locals,
193        unit_sources: analysis.unit_sources,
194    }
195}