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        .iter()
122        .filter(|(n, k)| **k == UnitKind::Commons && combined_types.contains_key(n.as_str()))
123        .map(|(n, _)| n.clone())
124        .collect();
125    UnitCheckCtx {
126        cross_context_views,
127        uses_commons_type_names,
128    }
129}
130
131/// The clean-path output of [`check_file_core`]: the typed, fully-checked
132/// unit plus the per-file cross-context info that produced it — a
133/// `Mode::Build` caller needs both to reach `certify`+`emit_unit` (`emit_unit`
134/// takes `cross_context_for_file` as its own argument, so this avoids making
135/// the caller recompute it from `ctx`/`unit_info` a second time).
136pub struct FileCheckResult {
137    pub typed: TypedCommons,
138    pub cross_context: resolver::CrossContextInfo,
139}
140
141/// The shared resolve+check+context-checks core for one file, factored out
142/// of `check_unit_files` (see this module's own doc comment). Returns
143/// `Some(FileCheckResult)` only on the fully-clean, non-blocked path — the
144/// signal a `Mode::Build` caller uses to know it may proceed to
145/// `certify`+`emit_unit`. Every error/blocked exit records best-effort
146/// partial types unconditionally (see [`record_analyse_types`]) and returns
147/// `None`.
148#[allow(clippy::too_many_arguments)]
149pub fn check_file_core(
150    name: &str,
151    kind: UnitKind,
152    pf: &ParsedFile,
153    unit_info: &BTreeMap<String, UnitInfo>,
154    combined_types: &HashMap<String, Arc<TypeDecl>>,
155    combined_fns: &HashMap<String, Arc<bynk_syntax::ast::FnDecl>>,
156    combined_methods: &HashMap<String, ResolverMethodTable>,
157    local_names: &HashSet<String>,
158    local_methods_for_type: &HashMap<String, Vec<bynk_syntax::ast::FnDecl>>,
159    consumed_types: &HashMap<String, ConsumedType>,
160    imported_from: &HashMap<String, String>,
161    ctx: &UnitCheckCtx,
162    errors: &mut ErrorSink,
163    refs: &mut RefSink,
164    hints: &mut HintSink,
165    locals: &mut LocalsSink,
166    exprs: &mut ExprTypeSink,
167    requirements: &mut RequirementSink,
168    tys: &Arc<Types>,
169) -> Option<FileCheckResult> {
170    let mut emit_items: Vec<CommonsItem> = Vec::new();
171    let types_in_this_file: HashSet<String> = pf
172        .items()
173        .iter()
174        .filter_map(|it| match it {
175            CommonsItem::Type(t) => Some(t.name.name.clone()),
176            // Events track, slice 0 (spine #936): an `event` shares the
177            // `types` namespace, so a multi-file context's method dispatch
178            // treats its name the same as a `type`'s.
179            CommonsItem::Event(e) => Some(e.name.name.clone()),
180            _ => None,
181        })
182        .collect();
183    for item in pf.items() {
184        match item {
185            CommonsItem::Type(t) => {
186                emit_items.push(CommonsItem::Type(t.clone()));
187            }
188            CommonsItem::Fn(f) => match &f.name {
189                FnName::Free(_) => emit_items.push(CommonsItem::Fn(f.clone())),
190                FnName::Method { type_name, .. } => {
191                    if types_in_this_file.contains(&type_name.name) {
192                        emit_items.push(CommonsItem::Fn(f.clone()));
193                    }
194                }
195            },
196            CommonsItem::Capability(c) => {
197                emit_items.push(CommonsItem::Capability(c.clone()));
198            }
199            CommonsItem::Provider(p) => {
200                emit_items.push(CommonsItem::Provider(p.clone()));
201            }
202            CommonsItem::Service(s) => {
203                emit_items.push(CommonsItem::Service(s.clone()));
204            }
205            CommonsItem::Agent(a) => {
206                emit_items.push(CommonsItem::Agent(a.clone()));
207            }
208            CommonsItem::Actor(a) => {
209                // Actors emit no standalone TS, but are carried so the
210                // emitter can read their schemes for the verification seam.
211                emit_items.push(CommonsItem::Actor(a.clone()));
212            }
213            CommonsItem::Messages(m) => {
214                emit_items.push(CommonsItem::Messages(m.clone()));
215            }
216            CommonsItem::Event(e) => {
217                emit_items.push(CommonsItem::Event(e.clone()));
218            }
219        }
220    }
221    for type_name in &types_in_this_file {
222        if let Some(methods) = local_methods_for_type.get(type_name) {
223            for m in methods {
224                let already = emit_items.iter().any(|it| match it {
225                    CommonsItem::Fn(existing) => match &existing.name {
226                        FnName::Method {
227                            type_name: t,
228                            method_name: n,
229                        } => match &m.name {
230                            FnName::Method {
231                                type_name: t2,
232                                method_name: n2,
233                            } => t.name == t2.name && n.name == n2.name,
234                            _ => false,
235                        },
236                        _ => false,
237                    },
238                    _ => false,
239                });
240                if !already {
241                    emit_items.push(CommonsItem::Fn(m.clone()));
242                }
243            }
244        }
245    }
246
247    // Synthesize a "Commons-shaped" view of this file's items so we can
248    // drive the existing resolver/checker without duplication.
249    let synthetic_commons = pf.as_synthetic_commons(emit_items);
250
251    // Cross-context info (v0.6) for contexts: consumed contexts, aliases,
252    // services, and types. Computed once below; reused for the resolver,
253    // checker, and (in `bynk-emit`) the emitter. v0.18: adapters get it too,
254    // so an external provider's `given` resolves against the adapter's
255    // flattened consumed capabilities (spec §4.5).
256    let cross_context_for_file =
257        if let Some((unit_tables, unit_uses, unit_consumes, unit_consumes_aliases)) =
258            &ctx.cross_context_views
259        {
260            let mut cci = build_cross_context_info(
261                name,
262                unit_consumes,
263                unit_consumes_aliases,
264                unit_uses,
265                unit_tables,
266            );
267            cci.flattened_caps = unit_info[name].flattened.clone();
268            cci
269        } else {
270            resolver::CrossContextInfo::default()
271        };
272
273    // Events slice 3a (#972): this unit's own local + direct-`uses` types
274    // (deliberately narrower than `combined_types`, which also merges
275    // `consumes`) — the same view `emit_consumed_context_helpers` (#973)
276    // builds for a *subscriber* regenerating this unit's own event codecs
277    // cross-context. `check_context_declarations` uses it to validate an
278    // event field default is constructible in that narrower view, not just
279    // this unit's own wider one.
280    let subscriber_visible_types: HashMap<String, Arc<TypeDecl>> =
281        if let Some((unit_tables, unit_uses, _, _)) = &ctx.cross_context_views {
282            combined_types_for(name, unit_tables, unit_uses)
283        } else {
284            HashMap::new()
285        };
286
287    // `ResolvedCommons::new` derives `local_type_names`/`event_type_names`
288    // from this unit's own pre-merge table (`unit_info[name].table`), not
289    // `combined_types` (already local+uses+consumes merged) — same
290    // distinction the caller's `local_names` exists for. `Events.emit[E]`
291    // additionally needs "is this specifically an event" on top of
292    // owner-only emission (an ordinary local type must not pass as an emit
293    // target just because it's locally declared), hence the separate
294    // `events` table. Both are empty for a unit absent from `unit_info`.
295    let empty_types = HashMap::new();
296    let empty_events = HashMap::new();
297    let local_table = unit_info.get(name).map(|i| &i.table);
298    let local_types = local_table.map(|t| &t.types).unwrap_or(&empty_types);
299    let local_events = local_table.map(|t| &t.events).unwrap_or(&empty_events);
300
301    let resolved = ResolvedCommons::new(
302        synthetic_commons,
303        combined_types.clone(),
304        local_types,
305        combined_fns.clone(),
306        combined_methods.clone(),
307        HashMap::new(),
308        local_events,
309        cross_context_for_file.clone(),
310        // ADR 0116 D6: provenance for the `bynk.list` deprecation lint.
311        imported_from.clone(),
312        kind == UnitKind::Context,
313        ctx.uses_commons_type_names.clone(),
314    );
315    refs.enter_file(&pf.identity_path(), name, pf.is_synthetic());
316    // v0.27: synthetic and test/integration files record no hints — neither
317    // surfaces in an editor (the `assemble_index` rule).
318    hints.enter_file(
319        &pf.identity_path(),
320        pf.is_synthetic() || matches!(pf.kind(), UnitKind::Test | UnitKind::Integration),
321    );
322    // v0.31: locals serve completion/navigation in test files too — only
323    // synthetic (toolchain-injected) files are muted.
324    locals.enter_file(&pf.identity_path(), pf.is_synthetic());
325    // v0.99: capability requirements follow the inlay-hint muting rule —
326    // synthetic and test/integration files surface none in an editor.
327    requirements.enter_file(
328        &pf.identity_path(),
329        pf.is_synthetic() || matches!(pf.kind(), UnitKind::Test | UnitKind::Integration),
330    );
331    if let Err(errs) = resolver::resolve_file_record(&resolved, refs) {
332        errors.extend_for(Some(&pf.identity_path()), errs);
333        return None;
334    }
335    let rc = checker::check_record_in(resolved, tys, refs, hints, locals, requirements);
336    let typed = match rc.result {
337        Ok(t) => {
338            // v0.89 (ADR 0117): a unit that checks clean may still carry
339            // non-failing warnings — push them into the (severity-aware)
340            // sink, where they are classified as warnings and never gate.
341            if !t.warnings.is_empty() {
342                errors.extend_for(Some(&pf.identity_path()), t.warnings.clone());
343            }
344            t
345        }
346        Err(errs) => {
347            errors.extend_for(Some(&pf.identity_path()), errs);
348            // ADR 0094: surface the best-effort partial types the checker
349            // computed so `.`-member completion / signature help work on a
350            // buffer with an unrelated error. Unconditional now (this
351            // module's own doc comment) — a `Mode::Build` caller simply
352            // never reads the sink this lands in.
353            record_analyse_types(
354                exprs,
355                &pf.identity_path(),
356                pf.is_synthetic(),
357                &rc.partial_expr_types,
358            );
359            return None;
360        }
361    };
362
363    // Run the context-specific checks: forbidden construction, private-type
364    // references.
365    if kind == UnitKind::Context {
366        let context_check_errs =
367            check_context_constraints(&typed, consumed_types, local_names, tys);
368        if !context_check_errs.is_empty() {
369            errors.extend_for(Some(&pf.identity_path()), context_check_errs);
370            record_analyse_types(
371                exprs,
372                &pf.identity_path(),
373                pf.is_synthetic(),
374                &typed.expr_types,
375            );
376            return None;
377        }
378    }
379
380    // v0.5: check capability/provider/service/agent declarations. v0.18:
381    // adapters run these too — an external provider's `given` resolves
382    // through the same path as a bodied provider's (the service/agent
383    // checks are vacuous for adapters, which have none).
384    let mut typed = typed;
385    let unit_table_owned = unit_info.get(name).map(|i| i.table.clone());
386    if (kind == UnitKind::Context || kind == UnitKind::Adapter)
387        && let Some(table) = unit_table_owned.as_ref()
388    {
389        let decl_errs = check_context_declarations(
390            &mut typed,
391            table,
392            &cross_context_for_file,
393            kind == UnitKind::Context,
394            &ctx.uses_commons_type_names,
395            &subscriber_visible_types,
396            refs,
397            hints,
398            locals,
399            requirements,
400            tys,
401        );
402        if !decl_errs.is_empty() {
403            // ADR 0117: a warning-severity declaration diagnostic (e.g. the
404            // `@indexed` hygiene hints) must not block emission — only an
405            // error does. Partition first, then gate on error severity
406            // alone.
407            let blocks_emission = decl_errs.iter().any(|e| {
408                matches!(
409                    bynk_syntax::Severity::for_error(e),
410                    bynk_syntax::Severity::Error
411                )
412            });
413            errors.extend_for(Some(&pf.identity_path()), decl_errs);
414            if blocks_emission {
415                // ADR 0094: handler bodies are typed here — surface their
416                // best-effort types even when a declaration check (e.g. a
417                // service/agent wiring error) fails for the file.
418                record_analyse_types(
419                    exprs,
420                    &pf.identity_path(),
421                    pf.is_synthetic(),
422                    &typed.expr_types,
423                );
424                return None;
425            }
426            // Warnings only: the declarations are valid — fall through.
427        }
428    }
429
430    Some(FileCheckResult {
431        typed,
432        cross_context: cross_context_for_file,
433    })
434}