Skip to main content

bynk_check/
check_pipeline.rs

1//! The shared per-unit/per-file resolve-check core, used by both
2//! `bynk-emit`'s `check_unit_files` (`Mode::Build` and `Mode::Analyse`) and
3//! this crate's own [`crate::analysis::analyse_project`].
4//!
5//! P4.1 (#1115), the same `extract, don't duplicate` move as
6//! [`crate::project_model`]: `check_unit_files`'s per-file body is identical
7//! for both modes except for four `record_analyse_types` call sites (the
8//! error-path exits) and the final "Analyse mode always stops here, Build
9//! mode falls through to `certify`+`emit_unit`" branch. This module owns
10//! everything up to (not including) that branch — [`check_file_core`]
11//! returns `Some(TypedCommons)` only on the fully-clean, non-blocked path, so
12//! a caller that wants to emit knows exactly when it may. The four
13//! error-path recordings are unconditional here now (previously gated on
14//! `mode == Mode::Analyse`) — behaviour-preserving for the `Mode::Build`
15//! caller, which never took that branch anyway (`mode == Mode::Analyse`
16//! gated it), and whose `exprs` sink `compile_project`'s `ProjectOutput`
17//! never exposes.
18//!
19//! What stayed in `bynk-emit`: `Mode` itself (meaningless here — this
20//! crate's own entry point has exactly one behaviour), `certify`+`emit_unit`
21//! (real emission), and the decision of *whether* to record the clean-path
22//! types (each caller does that itself with the `Some(TypedCommons)` this
23//! module hands back — `bynk-emit`'s `Mode::Build` caller skips it,
24//! `Mode::Analyse` and this crate's own entry point both call
25//! [`record_analyse_types`]).
26
27use std::collections::{BTreeMap, HashMap, HashSet};
28use std::path::Path;
29use std::sync::Arc;
30
31use crate::checker::{self, TypedCommons, Types};
32use crate::context_checks::{check_context_constraints, check_context_declarations};
33use crate::expr_types::ExprTypeSink;
34use crate::hints::HintSink;
35use crate::index::RefSink;
36use crate::locals::LocalsSink;
37use crate::project_model::{ErrorSink, UnitInfo};
38use crate::requirements::RequirementSink;
39use crate::resolver::{self, MethodTable as ResolverMethodTable, ResolvedCommons};
40use crate::symbols::{ConsumedType, UnitTable, build_cross_context_info, combined_types_for};
41use bynk_project::{ParsedFile, UnitKind};
42use bynk_syntax::ast::{CommonsItem, ExprId, FnName, TypeDecl};
43
44/// Record a file's (possibly partial) expression types into the Analyse-mode
45/// sink. Called at every per-file exit in the check loop so `.`-member
46/// completion and signature help get the receiver's type even when a later
47/// check phase errors for the file (ADR 0094). A no-op-shaped wrapper,
48/// factored out so the four error-path exits (now unconditional, see this
49/// module's own doc comment) and every clean-path caller share one call.
50pub fn record_analyse_types(
51    exprs: &mut ExprTypeSink,
52    source_path: &Path,
53    synthetic: bool,
54    types: &HashMap<ExprId, checker::TypedExpr>,
55) {
56    exprs.enter_file(source_path, synthetic);
57    exprs.record_file(types);
58}
59
60/// The four parallel per-project maps `build_cross_context_info`/
61/// `combined_types_for` need (their own general, map-based signature — see
62/// [`UnitCheckCtx`]'s own doc comment for why the per-file core materialises
63/// them from `unit_info` rather than changing that signature).
64type CrossContextViews = (
65    HashMap<String, UnitTable>,
66    HashMap<String, Vec<String>>,
67    HashMap<String, Vec<String>>,
68    HashMap<String, HashMap<String, String>>,
69);
70
71/// v0.29.4: `build_cross_context_info` (and its `combined_types_for` helper)
72/// is a general map-based function — the test-emission path calls it with
73/// *synthetic* harness maps, not `unit_info` — so it keeps its parallel-map
74/// signature. The per-file core only has `unit_info`, so this materialises
75/// the four views that one call needs, once per unit ahead of the file loop
76/// — but only for a context/adapter, `build_cross_context_info`'s only
77/// caller. `UnitTable` owns every declaration body in the unit, so for every
78/// other unit kind (including the seven injected first-party commons) this
79/// would otherwise be a whole-project deep clone, performed and discarded,
80/// once per unit.
81pub struct UnitCheckCtx {
82    cross_context_views: Option<CrossContextViews>,
83    /// #907: the exact set of type names `emit_context_rebrands` rebrands for
84    /// this unit — names brought in via `uses` of a *commons* specifically
85    /// (not a local declaration, and not a type surfaced via `consumes`,
86    /// which `imported_from_kind` tags `UnitKind::Context` in
87    /// `merge_consumed_exports` and which the emitter never rebrands).
88    pub uses_commons_type_names: HashSet<String>,
89}
90
91/// Build the per-unit prelude [`check_file_core`] shares across every file
92/// in the unit — see [`UnitCheckCtx`]'s own doc comment.
93pub fn prepare_unit_check_ctx(
94    kind: UnitKind,
95    unit_info: &BTreeMap<String, UnitInfo>,
96    combined_types: &HashMap<String, Arc<TypeDecl>>,
97    imported_from_kind: &HashMap<String, UnitKind>,
98) -> UnitCheckCtx {
99    let cross_context_views = if kind == UnitKind::Context || kind == UnitKind::Adapter {
100        let unit_tables: HashMap<String, UnitTable> = unit_info
101            .iter()
102            .map(|(n, i)| (n.clone(), i.table.clone()))
103            .collect();
104        let unit_uses: HashMap<String, Vec<String>> = unit_info
105            .iter()
106            .map(|(n, i)| (n.clone(), i.uses.clone()))
107            .collect();
108        let unit_consumes: HashMap<String, Vec<String>> = unit_info
109            .iter()
110            .map(|(n, i)| (n.clone(), i.consumes.clone()))
111            .collect();
112        let unit_consumes_aliases: HashMap<String, HashMap<String, String>> = unit_info
113            .iter()
114            .map(|(n, i)| (n.clone(), i.aliases.clone()))
115            .collect();
116        Some((unit_tables, unit_uses, unit_consumes, unit_consumes_aliases))
117    } else {
118        None
119    };
120    let uses_commons_type_names: HashSet<String> = imported_from_kind
121        .keys()
122        .filter(|n| {
123            crate::resolver::compute_is_uses_commons_type(imported_from_kind, combined_types, n)
124        })
125        .cloned()
126        .collect();
127    UnitCheckCtx {
128        cross_context_views,
129        uses_commons_type_names,
130    }
131}
132
133/// The clean-path output of [`check_file_core`]: the typed, fully-checked
134/// unit plus the per-file cross-context info that produced it — a
135/// `Mode::Build` caller needs both to reach `certify`+`emit_unit` (`emit_unit`
136/// takes `cross_context_for_file` as its own argument, so this avoids making
137/// the caller recompute it from `ctx`/`unit_info` a second time).
138pub struct FileCheckResult {
139    pub typed: TypedCommons,
140    pub cross_context: resolver::CrossContextInfo,
141}
142
143/// The shared resolve+check+context-checks core for one file, factored out
144/// of `check_unit_files` (see this module's own doc comment). Returns
145/// `Some(FileCheckResult)` only on the fully-clean, non-blocked path — the
146/// signal a `Mode::Build` caller uses to know it may proceed to
147/// `certify`+`emit_unit`. Every error/blocked exit records best-effort
148/// partial types unconditionally (see [`record_analyse_types`]) and returns
149/// `None`.
150#[allow(clippy::too_many_arguments)]
151pub fn check_file_core(
152    name: &str,
153    kind: UnitKind,
154    pf: &ParsedFile,
155    unit_info: &BTreeMap<String, UnitInfo>,
156    combined_types: &HashMap<String, Arc<TypeDecl>>,
157    combined_fns: &HashMap<String, Arc<bynk_syntax::ast::FnDecl>>,
158    combined_methods: &HashMap<String, ResolverMethodTable>,
159    local_names: &HashSet<String>,
160    local_methods_for_type: &HashMap<String, Vec<bynk_syntax::ast::FnDecl>>,
161    consumed_types: &HashMap<String, ConsumedType>,
162    imported_from: &HashMap<String, String>,
163    ctx: &UnitCheckCtx,
164    errors: &mut ErrorSink,
165    refs: &mut RefSink,
166    hints: &mut HintSink,
167    locals: &mut LocalsSink,
168    exprs: &mut ExprTypeSink,
169    requirements: &mut RequirementSink,
170    tys: &Arc<Types>,
171) -> Option<FileCheckResult> {
172    let mut emit_items: Vec<CommonsItem> = Vec::new();
173    let types_in_this_file: HashSet<String> = pf
174        .items()
175        .iter()
176        .filter_map(|it| match it {
177            CommonsItem::Type(t) => Some(t.name.name.clone()),
178            // Events track, slice 0 (spine #936): an `event` shares the
179            // `types` namespace, so a multi-file context's method dispatch
180            // treats its name the same as a `type`'s.
181            CommonsItem::Event(e) => Some(e.name.name.clone()),
182            _ => None,
183        })
184        .collect();
185    for item in pf.items() {
186        match item {
187            CommonsItem::Type(t) => {
188                emit_items.push(CommonsItem::Type(t.clone()));
189            }
190            CommonsItem::Fn(f) => match &f.name {
191                FnName::Free(_) => emit_items.push(CommonsItem::Fn(f.clone())),
192                FnName::Method { type_name, .. } => {
193                    if types_in_this_file.contains(&type_name.name) {
194                        emit_items.push(CommonsItem::Fn(f.clone()));
195                    }
196                }
197            },
198            CommonsItem::Capability(c) => {
199                emit_items.push(CommonsItem::Capability(c.clone()));
200            }
201            CommonsItem::Provider(p) => {
202                emit_items.push(CommonsItem::Provider(p.clone()));
203            }
204            CommonsItem::Service(s) => {
205                emit_items.push(CommonsItem::Service(s.clone()));
206            }
207            CommonsItem::Agent(a) => {
208                emit_items.push(CommonsItem::Agent(a.clone()));
209            }
210            CommonsItem::Actor(a) => {
211                // Actors emit no standalone TS, but are carried so the
212                // emitter can read their schemes for the verification seam.
213                emit_items.push(CommonsItem::Actor(a.clone()));
214            }
215            CommonsItem::Messages(m) => {
216                emit_items.push(CommonsItem::Messages(m.clone()));
217            }
218            CommonsItem::Event(e) => {
219                emit_items.push(CommonsItem::Event(e.clone()));
220            }
221        }
222    }
223    for type_name in &types_in_this_file {
224        if let Some(methods) = local_methods_for_type.get(type_name) {
225            for m in methods {
226                let already = emit_items.iter().any(|it| match it {
227                    CommonsItem::Fn(existing) => match &existing.name {
228                        FnName::Method {
229                            type_name: t,
230                            method_name: n,
231                        } => match &m.name {
232                            FnName::Method {
233                                type_name: t2,
234                                method_name: n2,
235                            } => t.name == t2.name && n.name == n2.name,
236                            _ => false,
237                        },
238                        _ => false,
239                    },
240                    _ => false,
241                });
242                if !already {
243                    emit_items.push(CommonsItem::Fn(m.clone()));
244                }
245            }
246        }
247    }
248
249    // Synthesize a "Commons-shaped" view of this file's items so we can
250    // drive the existing resolver/checker without duplication.
251    let synthetic_commons = pf.as_synthetic_commons(emit_items);
252
253    // Cross-context info (v0.6) for contexts: consumed contexts, aliases,
254    // services, and types. Computed once below; reused for the resolver,
255    // checker, and (in `bynk-emit`) the emitter. v0.18: adapters get it too,
256    // so an external provider's `given` resolves against the adapter's
257    // flattened consumed capabilities (spec §4.5).
258    let cross_context_for_file =
259        if let Some((unit_tables, unit_uses, unit_consumes, unit_consumes_aliases)) =
260            &ctx.cross_context_views
261        {
262            let mut cci = build_cross_context_info(
263                name,
264                unit_consumes,
265                unit_consumes_aliases,
266                unit_uses,
267                unit_tables,
268            );
269            cci.flattened_caps = unit_info[name].flattened.clone();
270            cci
271        } else {
272            resolver::CrossContextInfo::default()
273        };
274
275    // Events slice 3a (#972): this unit's own local + direct-`uses` types
276    // (deliberately narrower than `combined_types`, which also merges
277    // `consumes`) — the same view `emit_consumed_context_helpers` (#973)
278    // builds for a *subscriber* regenerating this unit's own event codecs
279    // cross-context. `check_context_declarations` uses it to validate an
280    // event field default is constructible in that narrower view, not just
281    // this unit's own wider one.
282    let subscriber_visible_types: HashMap<String, Arc<TypeDecl>> =
283        if let Some((unit_tables, unit_uses, _, _)) = &ctx.cross_context_views {
284            combined_types_for(name, unit_tables, unit_uses)
285        } else {
286            HashMap::new()
287        };
288
289    // `ResolvedCommons::new` derives `local_type_names`/`event_type_names`
290    // from this unit's own pre-merge table (`unit_info[name].table`), not
291    // `combined_types` (already local+uses+consumes merged) — same
292    // distinction the caller's `local_names` exists for. `Events.emit[E]`
293    // additionally needs "is this specifically an event" on top of
294    // owner-only emission (an ordinary local type must not pass as an emit
295    // target just because it's locally declared), hence the separate
296    // `events` table. Both are empty for a unit absent from `unit_info`.
297    let empty_types = HashMap::new();
298    let empty_events = HashMap::new();
299    let local_table = unit_info.get(name).map(|i| &i.table);
300    let local_types = local_table.map(|t| &t.types).unwrap_or(&empty_types);
301    let local_events = local_table.map(|t| &t.events).unwrap_or(&empty_events);
302
303    let resolved = ResolvedCommons::new(
304        synthetic_commons,
305        combined_types.clone(),
306        local_types,
307        combined_fns.clone(),
308        combined_methods.clone(),
309        HashMap::new(),
310        local_events,
311        cross_context_for_file.clone(),
312        // ADR 0116 D6: provenance for the `bynk.list` deprecation lint.
313        imported_from.clone(),
314        kind == UnitKind::Context,
315        ctx.uses_commons_type_names.clone(),
316    );
317    refs.enter_file(&pf.identity_path(), name, pf.is_synthetic());
318    // v0.27: synthetic and test/integration files record no hints — neither
319    // surfaces in an editor (the `assemble_index` rule).
320    hints.enter_file(
321        &pf.identity_path(),
322        pf.is_synthetic() || matches!(pf.kind(), UnitKind::Test | UnitKind::Integration),
323    );
324    // v0.31: locals serve completion/navigation in test files too — only
325    // synthetic (toolchain-injected) files are muted.
326    locals.enter_file(&pf.identity_path(), pf.is_synthetic());
327    // v0.99: capability requirements follow the inlay-hint muting rule —
328    // synthetic and test/integration files surface none in an editor.
329    requirements.enter_file(
330        &pf.identity_path(),
331        pf.is_synthetic() || matches!(pf.kind(), UnitKind::Test | UnitKind::Integration),
332    );
333    if let Err(errs) = resolver::resolve_file_record(&resolved, refs) {
334        errors.extend_for(Some(&pf.identity_path()), errs);
335        return None;
336    }
337    let rc = checker::check_record_in(resolved, tys, refs, hints, locals, requirements);
338    let typed = match rc.result {
339        Ok(t) => {
340            // v0.89 (ADR 0117): a unit that checks clean may still carry
341            // non-failing warnings — push them into the (severity-aware)
342            // sink, where they are classified as warnings and never gate.
343            if !t.warnings.is_empty() {
344                errors.extend_for(Some(&pf.identity_path()), t.warnings.clone());
345            }
346            t
347        }
348        Err(errs) => {
349            errors.extend_for(Some(&pf.identity_path()), errs);
350            // ADR 0094: surface the best-effort partial types the checker
351            // computed so `.`-member completion / signature help work on a
352            // buffer with an unrelated error. Unconditional now (this
353            // module's own doc comment) — a `Mode::Build` caller simply
354            // never reads the sink this lands in.
355            record_analyse_types(
356                exprs,
357                &pf.identity_path(),
358                pf.is_synthetic(),
359                &rc.partial_expr_types,
360            );
361            return None;
362        }
363    };
364
365    // Run the context-specific checks: forbidden construction, private-type
366    // references.
367    if kind == UnitKind::Context {
368        let context_check_errs =
369            check_context_constraints(&typed, consumed_types, local_names, tys);
370        if !context_check_errs.is_empty() {
371            errors.extend_for(Some(&pf.identity_path()), context_check_errs);
372            record_analyse_types(
373                exprs,
374                &pf.identity_path(),
375                pf.is_synthetic(),
376                &typed.expr_types,
377            );
378            return None;
379        }
380    }
381
382    // v0.5: check capability/provider/service/agent declarations. v0.18:
383    // adapters run these too — an external provider's `given` resolves
384    // through the same path as a bodied provider's (the service/agent
385    // checks are vacuous for adapters, which have none).
386    let mut typed = typed;
387    let unit_table_owned = unit_info.get(name).map(|i| i.table.clone());
388    if (kind == UnitKind::Context || kind == UnitKind::Adapter)
389        && let Some(table) = unit_table_owned.as_ref()
390    {
391        let decl_errs = check_context_declarations(
392            &mut typed,
393            table,
394            &cross_context_for_file,
395            kind == UnitKind::Context,
396            &ctx.uses_commons_type_names,
397            &subscriber_visible_types,
398            refs,
399            hints,
400            locals,
401            requirements,
402            tys,
403        );
404        if !decl_errs.is_empty() {
405            // ADR 0117: a warning-severity declaration diagnostic (e.g. the
406            // `@indexed` hygiene hints) must not block emission — only an
407            // error does. Partition first, then gate on error severity
408            // alone.
409            let blocks_emission = decl_errs.iter().any(|e| {
410                matches!(
411                    bynk_syntax::Severity::for_error(e),
412                    bynk_syntax::Severity::Error
413                )
414            });
415            errors.extend_for(Some(&pf.identity_path()), decl_errs);
416            if blocks_emission {
417                // ADR 0094: handler bodies are typed here — surface their
418                // best-effort types even when a declaration check (e.g. a
419                // service/agent wiring error) fails for the file.
420                record_analyse_types(
421                    exprs,
422                    &pf.identity_path(),
423                    pf.is_synthetic(),
424                    &typed.expr_types,
425                );
426                return None;
427            }
428            // Warnings only: the declarations are valid — fall through.
429        }
430    }
431
432    Some(FileCheckResult {
433        typed,
434        cross_context: cross_context_for_file,
435    })
436}