Skip to main content

bynk_check/
symbols.rs

1use std::collections::{BTreeMap, HashMap};
2use std::path::PathBuf;
3use std::sync::Arc;
4
5use crate::checker::CapabilityInfo;
6use crate::index::{IndexBuilder, ProjectIndex, RefSink, SiteRef, SymbolKind};
7use crate::project_model::UnitInfo;
8use crate::resolver::{self, MethodTable as ResolverMethodTable};
9use bynk_project::{ParsedFile, UnitKind};
10use bynk_syntax::ast::{
11    ActorDecl, AgentDecl, BaseType, Block, CapRef, CapabilityDecl, CommonsItem, EventDecl,
12    ExportKind, Expr, ExprId, ExprKind, FnDecl, FnName, HandlerKind, Ident, Param, ProviderDecl,
13    ServiceDecl, ServiceProtocol, Trivia, TypeBody, TypeDecl, TypeRef, Visibility,
14};
15use bynk_syntax::error::CompileError;
16use bynk_syntax::span::Span;
17
18/// v0.25 (ADR 0053): walk every parsed file's top-level declarations into
19/// the def table (synthetic first-party units and test files excluded —
20/// neither declares user-editable symbols), then qualify and attach the
21/// recorded edges. Methods register as owners only (attribution), not as
22/// symbols — they are deferred along with fields and op names.
23pub fn assemble_index(
24    parsed: &[ParsedFile],
25    unit_uses: &HashMap<String, Vec<String>>,
26    unit_consumes: &HashMap<String, Vec<String>>,
27    refs: RefSink,
28) -> ProjectIndex {
29    let mut builder = IndexBuilder::default();
30    let mut uses = unit_uses.clone();
31    uses.extend(refs.extra_uses);
32    builder.set_uses(uses);
33    builder.set_consumes(unit_consumes.clone());
34    for pf in parsed {
35        if matches!(pf.kind(), UnitKind::Test | UnitKind::Integration) {
36            continue;
37        }
38        let unit = pf.unit().name().joined();
39        // v0.28 (ADR 0057): synthetic first-party units stay out of
40        // `symbols` (their defs point at files not on disk — the v0.25
41        // rule), but their declarations register for the second
42        // qualification pass so references to them colour as tokens.
43        if pf.is_synthetic() {
44            for item in pf.items() {
45                let (kind, name, modifiers) = match item {
46                    CommonsItem::Type(t) => (
47                        SymbolKind::Type,
48                        &t.name.name,
49                        symbol_modifiers(&unit, Some(t)),
50                    ),
51                    // Events track, slice 0 (spine #936): an `event` indexes
52                    // as an ordinary `Type` symbol — it *is* one, a record,
53                    // registered via `EventDecl::as_type_decl`. A dedicated
54                    // `SymbolKind::Event` (its own hover/completion icon) is
55                    // a follow-on, not a slice-0 blocker.
56                    CommonsItem::Event(e) => (
57                        SymbolKind::Type,
58                        &e.name.name,
59                        symbol_modifiers(&unit, None),
60                    ),
61                    CommonsItem::Fn(f) => match &f.name {
62                        FnName::Free(id) => {
63                            (SymbolKind::Fn, &id.name, symbol_modifiers(&unit, None))
64                        }
65                        FnName::Method { .. } => continue,
66                    },
67                    CommonsItem::Capability(c) => (
68                        SymbolKind::Capability,
69                        &c.name.name,
70                        symbol_modifiers(&unit, None),
71                    ),
72                    CommonsItem::Service(s) => (
73                        SymbolKind::Service,
74                        &s.name.name,
75                        symbol_modifiers(&unit, None),
76                    ),
77                    CommonsItem::Agent(a) => (
78                        SymbolKind::Agent,
79                        &a.name.name,
80                        symbol_modifiers(&unit, None),
81                    ),
82                    CommonsItem::Provider(p) => (
83                        SymbolKind::Provider,
84                        &p.provider_name.name,
85                        symbol_modifiers(&unit, None),
86                    ),
87                    CommonsItem::Actor(a) => (
88                        SymbolKind::Actor,
89                        &a.name.name,
90                        symbol_modifiers(&unit, None),
91                    ),
92                    CommonsItem::Messages(m) => {
93                        (SymbolKind::Messages, &m.tag, symbol_modifiers(&unit, None))
94                    }
95                };
96                builder.add_first_party_def(&unit, kind, name, modifiers);
97            }
98            continue;
99        }
100        let site = |id: &Ident| SiteRef {
101            path: pf.identity_path(),
102            span: id.span,
103        };
104        for item in pf.items() {
105            match item {
106                CommonsItem::Type(t) => {
107                    builder.add_def(
108                        &unit,
109                        SymbolKind::Type,
110                        &t.name.name,
111                        site(&t.name),
112                        symbol_modifiers(&unit, Some(t)),
113                    );
114                    // v0.129 (#259): record a refined/opaque type's builtin base
115                    // for the refinement-family codelens. A plain alias
116                    // (`type Age = Int`) counts — it parses as `Refined { …, base }`
117                    // with no `where`, still declared over the base.
118                    if let TypeBody::Refined { base, .. } | TypeBody::Opaque { base, .. } = &t.body
119                    {
120                        builder.add_refinement(&unit, &t.name.name, *base);
121                    }
122                    // v0.36 (ADR 0069, slice 2): record fields are first-class
123                    // symbols keyed by the compound `"Type.field"` name.
124                    if let TypeBody::Record(r) = &t.body {
125                        for field in &r.fields {
126                            builder.add_def(
127                                &unit,
128                                SymbolKind::Field,
129                                &format!("{}.{}", t.name.name, field.name.name),
130                                site(&field.name),
131                                symbol_modifiers(&unit, None),
132                            );
133                        }
134                    }
135                }
136                // Events track, slice 0 (spine #936): an `event` indexes
137                // exactly like a `Type` whose body is a record — same
138                // `SymbolKind::Type`/`SymbolKind::Field` reuse as the
139                // synthetic-unit arm above, so hover/go-to-def/rename work
140                // on an event and its fields without a new symbol kind.
141                CommonsItem::Event(e) => {
142                    builder.add_def(
143                        &unit,
144                        SymbolKind::Type,
145                        &e.name.name,
146                        site(&e.name),
147                        symbol_modifiers(&unit, None),
148                    );
149                    for field in &e.body.fields {
150                        builder.add_def(
151                            &unit,
152                            SymbolKind::Field,
153                            &format!("{}.{}", e.name.name, field.name.name),
154                            site(&field.name),
155                            symbol_modifiers(&unit, None),
156                        );
157                    }
158                }
159                CommonsItem::Fn(f) => match &f.name {
160                    FnName::Free(id) => {
161                        builder.add_def(
162                            &unit,
163                            SymbolKind::Fn,
164                            &id.name,
165                            site(id),
166                            symbol_modifiers(&unit, None),
167                        );
168                    }
169                    FnName::Method { .. } => {
170                        // v0.36 (ADR 0069): a method is a first-class symbol
171                        // keyed by the compound `"Type.method"` name, and (as
172                        // before) an attribution owner for call-hierarchy.
173                        builder.add_owner(&unit, &f.name.display(), &pf.identity_path());
174                        builder.add_def(
175                            &unit,
176                            SymbolKind::Method,
177                            &f.name.display(),
178                            site(f.name.ident()),
179                            symbol_modifiers(&unit, None),
180                        );
181                    }
182                },
183                CommonsItem::Capability(c) => {
184                    builder.add_def(
185                        &unit,
186                        SymbolKind::Capability,
187                        &c.name.name,
188                        site(&c.name),
189                        symbol_modifiers(&unit, None),
190                    );
191                    // v0.36 (ADR 0069, slice 2): capability operations are
192                    // first-class symbols keyed by the compound `"Cap.op"` name.
193                    for op in &c.ops {
194                        builder.add_def(
195                            &unit,
196                            SymbolKind::CapabilityOp,
197                            &format!("{}.{}", c.name.name, op.name.name),
198                            site(&op.name),
199                            symbol_modifiers(&unit, None),
200                        );
201                    }
202                }
203                CommonsItem::Service(s) => {
204                    builder.add_def(
205                        &unit,
206                        SymbolKind::Service,
207                        &s.name.name,
208                        site(&s.name),
209                        symbol_modifiers(&unit, None),
210                    );
211                }
212                CommonsItem::Agent(a) => {
213                    builder.add_def(
214                        &unit,
215                        SymbolKind::Agent,
216                        &a.name.name,
217                        site(&a.name),
218                        symbol_modifiers(&unit, None),
219                    );
220                    // #304: an agent handler is a first-class symbol keyed by
221                    // the compound `"Agent.handler"` name, mirroring the
222                    // v0.36 (ADR 0069) method/field/op convention. Service
223                    // handlers have no per-handler name (`method_name` is
224                    // always `None`), so this is naturally agent-only.
225                    for h in &a.handlers {
226                        if let Some(name) = &h.method_name {
227                            builder.add_def(
228                                &unit,
229                                SymbolKind::Handler,
230                                &format!("{}.{}", a.name.name, name.name),
231                                site(name),
232                                symbol_modifiers(&unit, None),
233                            );
234                        }
235                    }
236                }
237                CommonsItem::Provider(p) => {
238                    builder.add_def(
239                        &unit,
240                        SymbolKind::Provider,
241                        &p.provider_name.name,
242                        site(&p.provider_name),
243                        symbol_modifiers(&unit, None),
244                    );
245                }
246                CommonsItem::Actor(a) => {
247                    builder.add_def(
248                        &unit,
249                        SymbolKind::Actor,
250                        &a.name.name,
251                        site(&a.name),
252                        symbol_modifiers(&unit, None),
253                    );
254                }
255                CommonsItem::Messages(m) => {
256                    // The tag is a string literal, not an `Ident`, so build the
257                    // `SiteRef` from its span directly rather than via `site`.
258                    builder.add_def(
259                        &unit,
260                        SymbolKind::Messages,
261                        &m.tag,
262                        SiteRef {
263                            path: pf.identity_path(),
264                            span: m.tag_span,
265                        },
266                        symbol_modifiers(&unit, None),
267                    );
268                }
269            }
270        }
271    }
272    builder.build(refs.edges)
273}
274
275/// v0.28 (ADR 0057): a symbol's semantic-token modifiers from its
276/// declaration. `refined` only when a refinement is present — `type X = Int`
277/// is `Refined { refinement: None }`, a plain alias, and carries neither;
278/// `opaque` is orthogonal (an `opaque … where` type carries both).
279/// `platform_native` when the declaring unit is a platform adapter.
280fn symbol_modifiers(unit: &str, type_decl: Option<&TypeDecl>) -> crate::index::SymbolModifiers {
281    let (refined, opaque) = match type_decl.map(|t| &t.body) {
282        Some(TypeBody::Refined { refinement, .. }) => (refinement.is_some(), false),
283        Some(TypeBody::Opaque { refinement, .. }) => (refinement.is_some(), true),
284        _ => (false, false),
285    };
286    crate::index::SymbolModifiers {
287        refined,
288        opaque,
289        platform_native: crate::firstparty::platform_of(unit).is_some(),
290    }
291}
292
293/// Combined symbol tables for a single logical commons or context.
294#[derive(Clone, Default)]
295pub struct UnitTable {
296    #[allow(dead_code)]
297    pub kind: Option<UnitKind>,
298    pub types: HashMap<String, Arc<TypeDecl>>,
299    pub fns: HashMap<String, Arc<FnDecl>>,
300    pub methods: HashMap<String, ResolverMethodTable>,
301    /// Per-context capabilities (v0.5). Empty for commons.
302    pub capabilities: HashMap<String, CapabilityDecl>,
303    /// Per-context providers (v0.5). One provider per capability in v0.5.
304    /// Key: capability name. Value: provider declaration.
305    pub providers: HashMap<String, ProviderDecl>,
306    /// Per-context services (v0.5). Empty for commons.
307    pub services: HashMap<String, ServiceDecl>,
308    /// Per-context agents (v0.5). Empty for commons.
309    pub agents: HashMap<String, AgentDecl>,
310    /// v0.45: actors — boundary contracts consumed by handler `by` clauses.
311    pub actors: HashMap<String, ActorDecl>,
312    /// v0.15: capability names this context offers to consumers via
313    /// `exports capability { … }`. Empty for commons.
314    pub exported_capabilities: std::collections::HashSet<String>,
315    /// Events track, slice 0 (spine #936): `event` declarations. Each also
316    /// registers into `types` (via `EventDecl::as_type_decl`) so ordinary
317    /// type-reference/exports/consumes/construction machinery treats it like
318    /// any other record type; this table is the separate "is `name`
319    /// specifically an event" answer — owner-only emission and the
320    /// `from Events(E)`/`Events.emit[E]` "must name a declared event, not
321    /// just any type" checks key off it. Empty for commons/adapters
322    /// (`bynk.event.outside_context` rejects it there).
323    pub events: HashMap<String, EventDecl>,
324}
325
326/// #696: each table-construction diagnostic is attributed to the project-relative
327/// `identity_path` of the file whose item produced it. Every error-producing loop
328/// below iterates `for &i in indices`, so it shadows a local `errors` vec and
329/// drains it into `out`, tagged with `parsed[i].identity_path()`, at the end of each
330/// file's pass — leaving the many inner `errors.push(…)` sites untouched.
331pub fn build_unit_table(
332    _name: &str,
333    kind: UnitKind,
334    indices: &[usize],
335    parsed: &[ParsedFile],
336    out: &mut Vec<(PathBuf, CompileError)>,
337) -> UnitTable {
338    let mut table = UnitTable {
339        kind: Some(kind),
340        ..UnitTable::default()
341    };
342    for &i in indices {
343        let mut errors: Vec<CompileError> = Vec::new();
344        for item in parsed[i].items() {
345            // Events track, slice 0 (spine #936): an `event` registers into
346            // `types` exactly like a `type` (via `EventDecl::as_type_decl`,
347            // so name-conflict detection against ordinary types is a single
348            // check regardless of declaration order within the file) and
349            // additionally into `events`, the separate "is this specifically
350            // an event" table.
351            if let CommonsItem::Event(e) = item
352                && kind != UnitKind::Context
353            {
354                errors.push(CompileError::new(
355                    "bynk.event.outside_context",
356                    e.span,
357                    "`event` declarations are only allowed inside a context",
358                ));
359                continue;
360            }
361            let as_type: Option<(&Ident, TypeDecl, bool)> = match item {
362                CommonsItem::Type(t) => Some((&t.name, t.clone(), false)),
363                CommonsItem::Event(e) => Some((&e.name, e.as_type_decl(), true)),
364                _ => None,
365            };
366            if let Some((name, decl, is_event)) = as_type {
367                if let Some(prev) = table.types.get(&name.name) {
368                    errors.push(
369                        CompileError::new(
370                            "bynk.resolve.duplicate_type",
371                            name.span,
372                            format!("type `{}` is already declared", name.name),
373                        )
374                        .with_label(prev.name.span, "previously declared here"),
375                    );
376                } else {
377                    table.methods.entry(name.name.clone()).or_default();
378                    if is_event {
379                        let CommonsItem::Event(e) = item else {
380                            unreachable!("is_event only set for CommonsItem::Event")
381                        };
382                        table.events.insert(name.name.clone(), e.clone());
383                    }
384                    table.types.insert(name.name.clone(), Arc::new(decl));
385                }
386            }
387        }
388        out.extend(errors.into_iter().map(|e| (parsed[i].identity_path(), e)));
389    }
390    // v0.15: collect the names a context exports as capabilities.
391    // v0.17: adapters export capabilities too.
392    for &i in indices {
393        {
394            for clause in parsed[i].exports() {
395                if matches!(clause.kind, ExportKind::Capability) {
396                    for n in &clause.names {
397                        table.exported_capabilities.insert(n.name.clone());
398                    }
399                }
400            }
401        }
402    }
403    // v0.5: collect capabilities, providers, services, agents.
404    for &i in indices {
405        let mut errors: Vec<CompileError> = Vec::new();
406        for item in parsed[i].items() {
407            match item {
408                CommonsItem::Capability(c) => {
409                    if kind != UnitKind::Context && kind != UnitKind::Adapter {
410                        errors.push(CompileError::new(
411                            "bynk.capability.outside_context",
412                            c.span,
413                            "`capability` declarations are only allowed inside a context or adapter",
414                        ));
415                        continue;
416                    }
417                    if let Some(prev) = table.capabilities.get(&c.name.name) {
418                        errors.push(
419                            CompileError::new(
420                                "bynk.resolve.duplicate_capability",
421                                c.name.span,
422                                format!("capability `{}` is already declared", c.name.name),
423                            )
424                            .with_label(prev.name.span, "previously declared here"),
425                        );
426                    } else {
427                        table.capabilities.insert(c.name.name.clone(), c.clone());
428                    }
429                }
430                CommonsItem::Provider(p) => {
431                    match kind {
432                        UnitKind::Context => {
433                            // v0.17: a bodiless (external) provider is only legal
434                            // inside an adapter.
435                            if p.external {
436                                errors.push(CompileError::new(
437                                    "bynk.context.external_provider",
438                                    p.span,
439                                    "an external (bodiless) provider is only allowed inside an `adapter` — a context provider must have a Bynk body",
440                                ));
441                                continue;
442                            }
443                        }
444                        UnitKind::Adapter => {
445                            // v0.17: an adapter provider must be external — its
446                            // implementation comes from the binding.
447                            if !p.external {
448                                errors.push(CompileError::new(
449                                    "bynk.adapter.provider_has_body",
450                                    p.span,
451                                    "a provider inside an `adapter` must be external (no body) — its implementation is supplied by the binding",
452                                ));
453                                continue;
454                            }
455                        }
456                        _ => {
457                            errors.push(CompileError::new(
458                                "bynk.provider.outside_context",
459                                p.span,
460                                "`provides` declarations are only allowed inside a context or adapter",
461                            ));
462                            continue;
463                        }
464                    }
465                    if let Some(prev) = table.providers.get(&p.capability.name) {
466                        errors.push(
467                            CompileError::new(
468                                "bynk.resolve.duplicate_provider",
469                                p.span,
470                                format!(
471                                    "capability `{}` already has a provider in this context",
472                                    p.capability.name
473                                ),
474                            )
475                            .with_label(prev.span, "previously provided here"),
476                        );
477                    } else {
478                        table.providers.insert(p.capability.name.clone(), p.clone());
479                    }
480                }
481                CommonsItem::Service(s) => {
482                    if kind == UnitKind::Adapter {
483                        errors.push(CompileError::new(
484                            "bynk.adapter.disallowed_item",
485                            s.span,
486                            "an `adapter` may not declare a `service` — adapters contain only capabilities, boundary types, external providers, and helpers",
487                        ));
488                        continue;
489                    }
490                    if kind != UnitKind::Context {
491                        errors.push(CompileError::new(
492                            "bynk.service.outside_context",
493                            s.span,
494                            "`service` declarations are only allowed inside a context, not a commons",
495                        ));
496                        continue;
497                    }
498                    if let Some(prev) = table.services.get(&s.name.name) {
499                        errors.push(
500                            CompileError::new(
501                                "bynk.resolve.duplicate_service",
502                                s.name.span,
503                                format!("service `{}` is already declared", s.name.name),
504                            )
505                            .with_label(prev.name.span, "previously declared here"),
506                        );
507                    } else {
508                        table.services.insert(s.name.name.clone(), s.clone());
509                    }
510                }
511                CommonsItem::Agent(a) => {
512                    if kind == UnitKind::Adapter {
513                        errors.push(CompileError::new(
514                            "bynk.adapter.disallowed_item",
515                            a.span,
516                            "an `adapter` may not declare an `agent` — adapters contain only capabilities, boundary types, external providers, and helpers",
517                        ));
518                        continue;
519                    }
520                    if kind != UnitKind::Context {
521                        errors.push(CompileError::new(
522                            "bynk.agent.outside_context",
523                            a.span,
524                            "`agent` declarations are only allowed inside a context, not a commons",
525                        ));
526                        continue;
527                    }
528                    if let Some(prev) = table.agents.get(&a.name.name) {
529                        errors.push(
530                            CompileError::new(
531                                "bynk.resolve.duplicate_agent",
532                                a.name.span,
533                                format!("agent `{}` is already declared", a.name.name),
534                            )
535                            .with_label(prev.name.span, "previously declared here"),
536                        );
537                    } else {
538                        table.agents.insert(a.name.name.clone(), a.clone());
539                    }
540                }
541                CommonsItem::Actor(a) => {
542                    if kind == UnitKind::Adapter {
543                        errors.push(CompileError::new(
544                            "bynk.adapter.disallowed_item",
545                            a.span,
546                            "an `adapter` may not declare an `actor` — adapters contain only capabilities, boundary types, external providers, and helpers",
547                        ));
548                        continue;
549                    }
550                    if let Some(prev) = table.actors.get(&a.name.name) {
551                        errors.push(
552                            CompileError::new(
553                                "bynk.resolve.duplicate_actor",
554                                a.name.span,
555                                format!("actor `{}` is already declared", a.name.name),
556                            )
557                            .with_label(prev.name.span, "previously declared here"),
558                        );
559                    } else {
560                        table.actors.insert(a.name.name.clone(), a.clone());
561                    }
562                }
563                _ => {}
564            }
565        }
566        out.extend(errors.into_iter().map(|e| (parsed[i].identity_path(), e)));
567    }
568    for &i in indices {
569        let mut errors: Vec<CompileError> = Vec::new();
570        for item in parsed[i].items() {
571            let CommonsItem::Fn(f) = item else { continue };
572            match &f.name {
573                FnName::Free(id) => {
574                    if let Some(prev) = table.fns.get(&id.name) {
575                        errors.push(
576                            CompileError::new(
577                                "bynk.resolve.duplicate_fn",
578                                id.span,
579                                format!("function `{}` is already declared", id.name),
580                            )
581                            .with_label(prev.name.ident().span, "previously declared here"),
582                        );
583                    } else if let Some(prev) = table.types.get(&id.name) {
584                        errors.push(
585                            CompileError::new(
586                                "bynk.resolve.name_conflict",
587                                id.span,
588                                format!(
589                                    "function `{}` conflicts with a type of the same name",
590                                    id.name
591                                ),
592                            )
593                            .with_label(prev.name.span, "type declared here"),
594                        );
595                    } else {
596                        table.fns.insert(id.name.clone(), Arc::new(f.clone()));
597                    }
598                }
599                FnName::Method {
600                    type_name,
601                    method_name,
602                } => {
603                    if !table.types.contains_key(&type_name.name) {
604                        errors.push(
605                            CompileError::new(
606                                "bynk.resolve.method_unknown_type",
607                                type_name.span,
608                                format!(
609                                    "method `{}.{}` attached to an unknown type `{}`",
610                                    type_name.name, method_name.name, type_name.name
611                                ),
612                            )
613                            .with_note(
614                                "methods can only be declared on types defined in the same commons or context (across all of its files)",
615                            ),
616                        );
617                        continue;
618                    }
619                    let mt = table.methods.entry(type_name.name.clone()).or_default();
620                    let bucket = if f.has_self {
621                        &mut mt.instance
622                    } else {
623                        &mut mt.statics
624                    };
625                    if let Some(prev) = bucket.get(&method_name.name) {
626                        errors.push(
627                            CompileError::new(
628                                "bynk.resolve.duplicate_method",
629                                method_name.span,
630                                format!(
631                                    "method `{}.{}` is already declared",
632                                    type_name.name, method_name.name
633                                ),
634                            )
635                            .with_label(prev.name.ident().span, "previously declared here"),
636                        );
637                    } else {
638                        bucket.insert(method_name.name.clone(), Arc::new(f.clone()));
639                    }
640                }
641            }
642        }
643        out.extend(errors.into_iter().map(|e| (parsed[i].identity_path(), e)));
644    }
645    // message-bundles slice 1 (#859): a commons declaring at least one
646    // `messages` block also gets a synthetic `render(tag: LocaleTag, msg:
647    // Message) -> String` in its own local function table — not just emitted
648    // TS. Without this, a Bynk-source `render(...)` call has no local
649    // declaration to resolve to and silently falls through to `bynk.locale`'s
650    // *imported* `render` (same signature, wrong — bundle-free — behaviour):
651    // resolution would "type-check" while quietly calling the wrong function.
652    // Registering it here, in the same local `table.fns` a real `CommonsItem::Fn`
653    // would populate, makes ordinary lexical precedence (local beats
654    // `uses`-imported, `compose_unit_symbols`) and call-site type-checking
655    // (`fns.get(name)`, never touching `.body`) work with no changes anywhere
656    // else. The body is a placeholder — nothing ever type-checks it, since
657    // body-checking walks `commons.items` (real AST items) directly, and this
658    // entry is never added there.
659    if kind == UnitKind::Commons
660        && let Some(m) = indices.iter().find_map(|&i| {
661            parsed[i].items().iter().find_map(|item| match item {
662                CommonsItem::Messages(m) => Some(m),
663                _ => None,
664            })
665        })
666    {
667        if let Some(prev) = table.fns.get("render") {
668            out.push((
669                parsed[indices[0]].identity_path(),
670                CompileError::new(
671                    "bynk.resolve.duplicate_fn",
672                    m.span,
673                    "function `render` is already declared",
674                )
675                .with_label(prev.name.ident().span, "previously declared here")
676                .with_note(
677                    "a `messages` block in this commons implicitly declares its own \
678                     `render(tag, msg) -> String` — name it something else",
679                ),
680            ));
681        } else {
682            table
683                .fns
684                .insert("render".to_string(), Arc::new(synthetic_render_fn()));
685        }
686    }
687    table
688}
689
690/// The synthetic `FnDecl` [`build_unit_table`] registers for a messages-bearing
691/// commons. Its body is never checked (see the call site's comment) — it
692/// exists only so `Param`/`TypeRef`/`FnDecl` construction has somewhere to put
693/// a syntactically valid placeholder.
694fn synthetic_render_fn() -> FnDecl {
695    let span = Span::default();
696    FnDecl {
697        type_params: Vec::new(),
698        name: FnName::Free(Ident {
699            name: "render".to_string(),
700            span,
701        }),
702        params: vec![
703            Param {
704                name: Ident {
705                    name: "tag".to_string(),
706                    span,
707                },
708                type_ref: TypeRef::Named(Ident {
709                    name: "LocaleTag".to_string(),
710                    span,
711                }),
712                span,
713            },
714            Param {
715                name: Ident {
716                    name: "msg".to_string(),
717                    span,
718                },
719                type_ref: TypeRef::Named(Ident {
720                    name: "Message".to_string(),
721                    span,
722                }),
723                span,
724            },
725        ],
726        return_type: TypeRef::Base(BaseType::String, span),
727        requires: Vec::new(),
728        ensures: Vec::new(),
729        body: Block {
730            statements: Vec::new(),
731            tail: Box::new(Expr {
732                id: ExprId::SYNTHETIC,
733                kind: ExprKind::StrLit(String::new()),
734                span,
735            }),
736            span,
737            tail_leading_comments: Vec::new(),
738            implicit_tail: false,
739        },
740        has_self: false,
741        documentation: None,
742        span,
743        trivia: Trivia::default(),
744    }
745}
746
747/// For each name declared in the unit (type, fn, method), record which
748/// source file declared it. Used by the emitter to render relative imports.
749#[derive(Clone)]
750pub struct FileDeclIndex {
751    pub types: HashMap<String, PathBuf>,
752    pub fns: HashMap<String, PathBuf>,
753    pub methods: HashMap<String, HashMap<String, PathBuf>>,
754}
755
756/// **Tree-relative, deliberately.** This is an *emit* structure, not an index:
757/// `record_name_ref` compares these paths against `ctx.source_path`
758/// (`emitter.rs`), which is the file's `include`-root-relative path. Keying it
759/// by `identity_path` (ADR 0198) makes `path != &ctx.source_path` always true
760/// for a split project, so a name declared in the *same* file is emitted as a
761/// sibling import of itself — the module then cannot load, and a workers
762/// runtime test hangs rather than fails. See ADR 0201 (E).
763pub fn build_file_decl_index(indices: &[usize], parsed: &[ParsedFile]) -> FileDeclIndex {
764    let mut idx = FileDeclIndex {
765        types: HashMap::new(),
766        fns: HashMap::new(),
767        methods: HashMap::new(),
768    };
769    for &i in indices {
770        let path = parsed[i].source_path();
771        for item in parsed[i].items() {
772            match item {
773                CommonsItem::Type(t) => {
774                    idx.types
775                        .entry(t.name.name.clone())
776                        .or_insert_with(|| path.clone());
777                }
778                // Events track, slice 0 (spine #936): an `event` name shares
779                // the `types` file index — it registers into the same
780                // `types` symbol table as an ordinary `type` everywhere else
781                // in this module.
782                CommonsItem::Event(e) => {
783                    idx.types
784                        .entry(e.name.name.clone())
785                        .or_insert_with(|| path.clone());
786                }
787                CommonsItem::Fn(f) => match &f.name {
788                    FnName::Free(id) => {
789                        idx.fns
790                            .entry(id.name.clone())
791                            .or_insert_with(|| path.clone());
792                    }
793                    FnName::Method {
794                        type_name,
795                        method_name,
796                    } => {
797                        idx.methods
798                            .entry(type_name.name.clone())
799                            .or_default()
800                            .entry(method_name.name.clone())
801                            .or_insert_with(|| path.clone());
802                    }
803                },
804                CommonsItem::Capability(_)
805                | CommonsItem::Provider(_)
806                | CommonsItem::Service(_)
807                | CommonsItem::Agent(_)
808                | CommonsItem::Actor(_)
809                // `messages` bundles aren't cross-file-imported by name in
810                // slice 1 (no multi-file bundle merge yet).
811                | CommonsItem::Messages(_) => {}
812            }
813        }
814    }
815    idx
816}
817
818/// #696: returns the `parsed` index of the owning file alongside the `uses`
819/// clause span, so the caller can attribute the diagnostic to that file.
820pub fn uses_span_of(
821    parsed: &[ParsedFile],
822    indices: &[usize],
823    target: &str,
824) -> Option<(usize, Span)> {
825    for &i in indices {
826        for u in parsed[i].uses() {
827            if u.target.joined() == target {
828                return Some((i, u.span));
829            }
830        }
831    }
832    None
833}
834
835/// Build the [`resolver::CrossContextInfo`] for a given consuming context.
836/// Used by both the resolver/checker (per-file processing) and the emitter
837/// (composition root + boundary casts).
838pub fn build_cross_context_info(
839    name: &str,
840    unit_consumes: &HashMap<String, Vec<String>>,
841    unit_consumes_aliases: &HashMap<String, HashMap<String, String>>,
842    unit_uses: &HashMap<String, Vec<String>>,
843    unit_tables: &HashMap<String, UnitTable>,
844) -> resolver::CrossContextInfo {
845    let consumed_contexts: Vec<String> = unit_consumes.get(name).cloned().unwrap_or_default();
846    let aliases: HashMap<String, String> =
847        unit_consumes_aliases.get(name).cloned().unwrap_or_default();
848    let mut consumed_services: HashMap<String, HashMap<String, resolver::CrossContextService>> =
849        HashMap::new();
850    let mut consumed_types: HashMap<String, HashMap<String, Arc<TypeDecl>>> = HashMap::new();
851    let mut consumed_capabilities: HashMap<
852        String,
853        HashMap<String, resolver::CrossContextCapability>,
854    > = HashMap::new();
855    // Events track, slice 0 (spine #936): each consumed context's own event
856    // names, so a subscriber's `from Events(E)` can be checked against a
857    // foreign owner too — mirrors `discover_event_subscribers` below, which
858    // already resolves ownership this same way for wiring.
859    let mut consumed_event_names: HashMap<String, std::collections::HashSet<String>> =
860        HashMap::new();
861    for t in &consumed_contexts {
862        let other_types_combined = combined_types_for(t, unit_tables, unit_uses);
863        consumed_types.insert(t.clone(), other_types_combined.clone());
864        let Some(other_table) = unit_tables.get(t) else {
865            continue;
866        };
867        consumed_event_names.insert(t.clone(), other_table.events.keys().cloned().collect());
868        let mut svcs: HashMap<String, resolver::CrossContextService> = HashMap::new();
869        for (sname, sdecl) in &other_table.services {
870            if let Some(svc) = resolver::cross_context_service_for(sname, sdecl) {
871                svcs.insert(sname.clone(), svc);
872            }
873        }
874        consumed_services.insert(t.clone(), svcs);
875
876        // v0.15: gather the consumed context's exported capabilities, each
877        // paired with the provider that implements it.
878        let mut caps: HashMap<String, resolver::CrossContextCapability> = HashMap::new();
879        for cap_name in &other_table.exported_capabilities {
880            let Some(decl) = other_table.capabilities.get(cap_name) else {
881                continue;
882            };
883            let Some(provider) = other_table.providers.get(cap_name) else {
884                continue;
885            };
886            let ops = decl
887                .ops
888                .iter()
889                .map(|op| resolver::CrossContextCapabilityOp {
890                    name: op.name.name.clone(),
891                    type_params: op.type_params.iter().map(|p| p.name.name.clone()).collect(),
892                    params: op
893                        .params
894                        .iter()
895                        .map(|p| (p.name.name.clone(), p.type_ref.clone()))
896                        .collect(),
897                    return_type: op.return_type.clone(),
898                })
899                .collect();
900            caps.insert(
901                cap_name.clone(),
902                resolver::CrossContextCapability {
903                    name: cap_name.clone(),
904                    ops,
905                    provider_name: provider.provider_name.name.clone(),
906                    provider_given: provider
907                        .given
908                        .iter()
909                        .filter(|c| !c.is_cross_context())
910                        .map(|c| c.key().to_string())
911                        .collect(),
912                    span: decl.span,
913                },
914            );
915        }
916        consumed_capabilities.insert(t.clone(), caps);
917    }
918    resolver::CrossContextInfo {
919        self_context: Some(name.to_string()),
920        consumed_contexts,
921        aliases,
922        consumed_services,
923        consumed_types,
924        consumed_capabilities,
925        // Set by the caller from the unit's `consumes U { … }` clauses.
926        flattened_caps: HashMap::new(),
927        consumed_event_names,
928    }
929}
930
931/// Events track, slice 0 (spine #936): project-wide "who subscribes to what"
932/// — for every `service ... from Events(E) { on event ... }` anywhere in the
933/// project, resolve `E` to its *owning* context (the one whose `events` table
934/// actually declares it — bare-name resolution, since `TypeRef` has no dotted
935/// form) and group subscribers under `(owning_context, event_type_name)`. No
936/// prior art to reuse: cross-context wiring elsewhere is driven by an
937/// explicit author-written `consumes` clause, resolved eagerly in
938/// `phase_resolve_consumes`; this is the first wiring driven by an *implicit*
939/// relationship (a bare event-type name shared between a publisher's `event`
940/// declaration and a subscriber's `from Events(E)` header) — the same
941/// ownership resolution [`build_cross_context_info`]'s own `consumed_event_names`
942/// performs above, for the same reason (#936, events track slice 0).
943pub fn discover_event_subscribers(
944    unit_tables: &HashMap<String, UnitTable>,
945    unit_consumes: &HashMap<String, Vec<String>>,
946) -> BTreeMap<(String, String), Vec<(String, String)>> {
947    let mut out: BTreeMap<(String, String), Vec<(String, String)>> = BTreeMap::new();
948    for (ctx_name, table) in unit_tables {
949        for (svc_name, svc) in &table.services {
950            let ServiceProtocol::Events { event_type, .. } = &svc.protocol else {
951                continue;
952            };
953            let TypeRef::Named(id) = event_type else {
954                continue;
955            };
956            let name = &id.name;
957            let owner = if table.events.contains_key(name) {
958                Some(ctx_name.clone())
959            } else {
960                unit_consumes.get(ctx_name).and_then(|consumed| {
961                    consumed
962                        .iter()
963                        .find(|c| {
964                            unit_tables
965                                .get(c.as_str())
966                                .is_some_and(|t| t.events.contains_key(name))
967                        })
968                        .cloned()
969                })
970            };
971            if let Some(owner) = owner {
972                out.entry((owner, name.clone()))
973                    .or_default()
974                    .push((ctx_name.clone(), svc_name.clone()));
975            }
976        }
977    }
978    // `unit_tables`/`table.services` are `HashMap`s, so the pushes above race
979    // across builds — a multi-subscriber event's dispatch order (and so the
980    // emitted `__eventsDispatch` closure's `await sub1; await sub2;`
981    // sequence) would otherwise vary build to build with no source change.
982    for subs in out.values_mut() {
983        subs.sort();
984    }
985    out
986}
987
988/// P6.x (#1137): one context's own cron expressions (`on cron "expr"`) and
989/// queue names (`from queue("name")`), sorted and deduped — the two `wrangler.toml`
990/// binding lists a context's own compose entry needs. `v0.44`: one queue binding
991/// per service, on the `from queue(...)` header.
992pub fn cron_and_queue_triggers(table: &UnitTable) -> (Vec<String>, Vec<String>) {
993    let mut crons: Vec<String> = Vec::new();
994    let mut queues: Vec<String> = Vec::new();
995    for service in table.services.values() {
996        for handler in &service.handlers {
997            if let HandlerKind::Cron { expr } = &handler.kind {
998                crons.push(expr.clone());
999            }
1000        }
1001        if let ServiceProtocol::Queue { name } = &service.protocol {
1002            queues.push(name.clone());
1003        }
1004    }
1005    crons.sort();
1006    crons.dedup();
1007    queues.sort();
1008    queues.dedup();
1009    (crons, queues)
1010}
1011
1012/// v0.15: validate one `given` capability reference. A bare reference must name
1013/// a capability declared in this context; a cross-context reference (`given
1014/// B.Cap`) must name a capability the consumed context exports. Returns the
1015/// local [`CapabilityInfo`] to add to the in-scope map for bare references;
1016/// cross-context references return `None` (their calls are type-checked via
1017/// `consumed_capabilities` at the call site) but are still validated here.
1018/// v0.25: record a clause-position capability reference (`provides Cap`,
1019/// bare `given Cap`), qualifying a flattened bare name to its providing
1020/// unit. The span is the name segment only.
1021pub fn record_capability_clause_ref(
1022    name: &Ident,
1023    cross_context: &resolver::CrossContextInfo,
1024    refs: &mut RefSink,
1025) {
1026    record_capability_clause_ref_inner(name, cross_context, refs, false);
1027}
1028
1029/// v0.35 (ADR 0068): the `Cap` of a `provides Cap = Provider` clause — a
1030/// capability reference *and* an implementation edge (the ambient owner is the
1031/// provider). Flagged so assembly can tell it apart from the provider's own
1032/// `given` deps, which are capability refs owned by the same provider.
1033pub fn record_provides_clause_ref(
1034    name: &Ident,
1035    cross_context: &resolver::CrossContextInfo,
1036    refs: &mut RefSink,
1037) {
1038    record_capability_clause_ref_inner(name, cross_context, refs, true);
1039}
1040
1041fn record_capability_clause_ref_inner(
1042    name: &Ident,
1043    cross_context: &resolver::CrossContextInfo,
1044    refs: &mut RefSink,
1045    provides: bool,
1046) {
1047    let unit = cross_context.flattened_caps.get(&name.name);
1048    if provides {
1049        refs.record_provides(name.span, &name.name, unit.map(String::as_str));
1050    } else if let Some(unit) = unit {
1051        refs.record_in_unit(name.span, SymbolKind::Capability, &name.name, unit);
1052    } else {
1053        refs.record(name.span, SymbolKind::Capability, &name.name);
1054    }
1055}
1056
1057pub fn resolve_given_cap_ref(
1058    cap_ref: &CapRef,
1059    capability_info_map: &HashMap<String, CapabilityInfo>,
1060    cross_context: &resolver::CrossContextInfo,
1061    errors: &mut Vec<CompileError>,
1062    refs: &mut RefSink,
1063) -> Option<CapabilityInfo> {
1064    let Some(prefix) = cap_ref.prefix() else {
1065        // Local capability.
1066        match capability_info_map.get(cap_ref.key()) {
1067            Some(info) => {
1068                record_capability_clause_ref(&cap_ref.name, cross_context, refs);
1069                return Some(info.clone());
1070            }
1071            None => {
1072                errors.push(CompileError::new(
1073                    "bynk.given.unknown_capability",
1074                    cap_ref.span,
1075                    format!(
1076                        "capability `{}` is not declared in this context",
1077                        cap_ref.key()
1078                    ),
1079                ));
1080                return None;
1081            }
1082        }
1083    };
1084    // Cross-context capability (`given B.Cap` / `given Alias.Cap`).
1085    let Some(ctx_name) = cross_context.resolve_prefix(&prefix) else {
1086        errors.push(
1087            CompileError::new(
1088                "bynk.resolve.unconsumed_context",
1089                cap_ref.span,
1090                format!(
1091                    "`given {}.{}` refers to a context that this context does not `consumes`",
1092                    prefix,
1093                    cap_ref.key()
1094                ),
1095            )
1096            .with_note(
1097                "add a `consumes` clause for the providing context (optionally with an alias) at the top of this context",
1098            ),
1099        );
1100        return None;
1101    };
1102    let exports_it = cross_context
1103        .consumed_capabilities
1104        .get(&ctx_name)
1105        .is_some_and(|m| m.contains_key(cap_ref.key()));
1106    if exports_it {
1107        // v0.25: dotted `given B.Cap` — the name segment, in the consumed
1108        // unit's namespace.
1109        refs.record_in_unit(
1110            cap_ref.name.span,
1111            SymbolKind::Capability,
1112            cap_ref.key(),
1113            &ctx_name,
1114        );
1115    }
1116    if !exports_it {
1117        errors.push(
1118            CompileError::new(
1119                "bynk.given.cross_context_unknown_capability",
1120                cap_ref.span,
1121                format!(
1122                    "context `{}` does not export a capability named `{}`",
1123                    ctx_name,
1124                    cap_ref.key()
1125                ),
1126            )
1127            .with_note(
1128                "the providing context must list the capability in an `exports capability { … }` clause",
1129            ),
1130        );
1131    }
1132    None
1133}
1134
1135/// Build the combined type table for `unit`: its own types merged with the
1136/// types of every commons it `uses`. Used by cross-context resolution so we
1137/// can resolve a consumed context's service signatures against that context's
1138/// own view of types (v0.6 §4.5).
1139/// v0.177 (#643): the callee's own type namespace — its local declarations plus
1140/// the commons types it `uses`.
1141///
1142/// Shared deliberately. The **caller** reaches this table through
1143/// `consumed_types[callee]` and the **callee** builds it for itself; both must
1144/// canonicalise the callee's contract from the *same* table or their hashes
1145/// diverge and every call 409s. Routing both through one function makes that
1146/// agreement structural rather than a thing to keep in step by hand.
1147pub fn combined_types_for(
1148    unit: &str,
1149    unit_tables: &HashMap<String, UnitTable>,
1150    unit_uses: &HashMap<String, Vec<String>>,
1151) -> HashMap<String, Arc<TypeDecl>> {
1152    let mut out: HashMap<String, Arc<TypeDecl>> = HashMap::new();
1153    if let Some(table) = unit_tables.get(unit) {
1154        for (n, d) in &table.types {
1155            out.insert(n.clone(), d.clone());
1156        }
1157    }
1158    if let Some(targets) = unit_uses.get(unit) {
1159        for t in targets {
1160            if let Some(used) = unit_tables.get(t) {
1161                for (n, d) in &used.types {
1162                    out.entry(n.clone()).or_insert_with(|| d.clone());
1163                }
1164            }
1165        }
1166    }
1167    out
1168}
1169
1170/// P6.18: a `uses`-imported type's own combined visible types (one level,
1171/// matching [`combined_types_for`]'s identical shape) — the narrow resolution
1172/// scope a signature-lowering pass needs for that unit's own attached
1173/// methods. Reimplemented against [`UnitInfo`] rather than calling
1174/// `combined_types_for` directly: that function takes the flat, project-wide
1175/// `unit_tables`/`unit_uses` maps a per-unit emission prologue doesn't thread
1176/// this deep (only the coarser, already-merged `unit_info` reaches there) —
1177/// rebuilding those two maps from `unit_info` on every call would be needless
1178/// O(units) cloning for a per-unit prologue already called once per emitted
1179/// unit.
1180pub fn combined_types_for_unit_info(
1181    unit: &str,
1182    unit_info: &BTreeMap<String, UnitInfo>,
1183) -> HashMap<String, Arc<TypeDecl>> {
1184    let mut out: HashMap<String, Arc<TypeDecl>> = HashMap::new();
1185    let Some(info) = unit_info.get(unit) else {
1186        return out;
1187    };
1188    for (n, d) in &info.table.types {
1189        out.insert(n.clone(), d.clone());
1190    }
1191    for t in &info.uses {
1192        if let Some(used) = unit_info.get(t) {
1193            for (n, d) in &used.table.types {
1194                out.entry(n.clone()).or_insert_with(|| d.clone());
1195            }
1196        }
1197    }
1198    out
1199}
1200
1201/// Locale capability track, slice 2 (#882): the message bundle a context's
1202/// `Locale.current()` negotiates against, auto-detected from the context's
1203/// *direct* `uses` (one level, not transitive — see [`combined_types_for`]
1204/// just above, the precedent for this rule). `None`/`One`/`Many` drive three
1205/// different behaviours: unchanged fixed-default `Locale`, real negotiation
1206/// wiring, or (when the context also consumes `Locale`)
1207/// `bynk.messages.multiple_message_bundles` — see `check_locale_bundle_ambiguity`
1208/// (`bynk-emit/src/project/validate.rs`) and the per-Worker composition loop
1209/// (`bynk-emit/src/project.rs`).
1210// `pub`, not `pub(crate)`: `MessageBundleInfo` appears in `emit_worker_compose`'s
1211// public signature (`bynk-emit/src/emitter/workers.rs`), which must expose
1212// types at least as visible as itself (matching `UnitTable`'s own `pub`).
1213pub enum ContextMessageBundle {
1214    /// No directly-`uses`d commons declares a `messages` block.
1215    None,
1216    /// Exactly one — the negotiable case.
1217    One(MessageBundleInfo),
1218    /// Two or more (each commons's own qualified name, for the diagnostic).
1219    Many(Vec<String>),
1220}
1221
1222pub struct MessageBundleInfo {
1223    /// The commons's qualified unit name (e.g. `"app.msgs"`).
1224    pub commons: String,
1225    /// Project-relative path of the file carrying the `@reference` block —
1226    /// the import target for `messagesLocales`/`messagesReferenceLocale`.
1227    /// (A bundle genuinely split across multiple files, per the track doc's
1228    /// own §4.1, is not correctly merged by `emit_messages_bundle` today —
1229    /// each file emits independently, `bynk-emit/src/project.rs`'s per-file
1230    /// `emit_items` loop — this detection mirrors that same file-scoped
1231    /// reality rather than a wider, currently-unimplemented merge.)
1232    pub source_path: PathBuf,
1233}
1234
1235/// Walks `ctx`'s own direct `uses` list for commons declaring a `messages`
1236/// bundle with exactly one `@reference` block (a bundle missing or
1237/// duplicating its own reference is already diagnosed by
1238/// `check_messages_bundles` — this function simply doesn't count it as
1239/// "found", rather than compounding an already-reported error).
1240pub fn detect_context_message_bundle(
1241    ctx: &str,
1242    unit_uses: &HashMap<String, Vec<String>>,
1243    groups: &BTreeMap<String, Vec<usize>>,
1244    kinds: &BTreeMap<String, UnitKind>,
1245    parsed: &[ParsedFile],
1246) -> ContextMessageBundle {
1247    let mut found: Vec<MessageBundleInfo> = Vec::new();
1248    for target in unit_uses.get(ctx).into_iter().flatten() {
1249        if kinds.get(target) != Some(&UnitKind::Commons) {
1250            continue;
1251        }
1252        let Some(indices) = groups.get(target) else {
1253            continue;
1254        };
1255        for &i in indices {
1256            let has_reference = parsed[i].items().iter().any(|item| {
1257                matches!(item, CommonsItem::Messages(m) if m.annotations.iter().any(|a| a.name.name == "reference"))
1258            });
1259            if has_reference {
1260                found.push(MessageBundleInfo {
1261                    commons: target.clone(),
1262                    source_path: parsed[i].source_path(),
1263                });
1264                break;
1265            }
1266        }
1267    }
1268    match found.len() {
1269        0 => ContextMessageBundle::None,
1270        1 => ContextMessageBundle::One(found.pop().expect("len == 1")),
1271        _ => ContextMessageBundle::Many(found.into_iter().map(|b| b.commons).collect()),
1272    }
1273}
1274
1275#[cfg(test)]
1276mod detect_context_message_bundle_tests {
1277    use super::*;
1278    use bynk_syntax::ast::{
1279        Annotation, Commons, CommonsForm, Context, MessagesDecl, QualifiedName, SourceUnit,
1280        UsesDecl,
1281    };
1282
1283    fn ident(name: &str) -> Ident {
1284        Ident {
1285            name: name.to_string(),
1286            span: Span::default(),
1287        }
1288    }
1289
1290    fn qualified(name: &str) -> QualifiedName {
1291        QualifiedName {
1292            parts: name.split('.').map(ident).collect(),
1293            span: Span::default(),
1294        }
1295    }
1296
1297    /// A commons `ParsedFile` declaring one `messages <tag>` block, its
1298    /// `@reference` annotation present or not. `source_path` is derived from
1299    /// `name` so each test bundle gets a distinct, recognisable import
1300    /// target — real content doesn't matter, only that a path exists.
1301    fn commons_with_messages(name: &str, tag: &str, is_reference: bool) -> ParsedFile {
1302        let annotations = if is_reference {
1303            vec![Annotation {
1304                name: ident("reference"),
1305                args: Vec::new(),
1306                span: Span::default(),
1307            }]
1308        } else {
1309            Vec::new()
1310        };
1311        let messages = MessagesDecl {
1312            tag: tag.to_string(),
1313            tag_span: Span::default(),
1314            annotations,
1315            entries: Vec::new(),
1316            documentation: None,
1317            span: Span::default(),
1318            trivia: Trivia::default(),
1319        };
1320        ParsedFile::new(
1321            PathBuf::from(format!("{}.bynk", name.replace('.', "/"))),
1322            PathBuf::from(format!("src/{}.bynk", name.replace('.', "/"))),
1323            None,
1324            String::new(),
1325            SourceUnit::Commons(Commons {
1326                name: qualified(name),
1327                items: vec![CommonsItem::Messages(messages)],
1328                uses: Vec::new(),
1329                documentation: None,
1330                form: CommonsForm::Brace,
1331                span: Span::default(),
1332                trivia: Trivia::default(),
1333                trailing_comments: Vec::new(),
1334            }),
1335            UnitKind::Commons,
1336            false,
1337        )
1338    }
1339
1340    /// A minimal context `ParsedFile` with no items of its own — only its
1341    /// `uses` list matters for this function.
1342    fn context_using(name: &str, targets: &[&str]) -> ParsedFile {
1343        ParsedFile::new(
1344            PathBuf::from(format!("{}.bynk", name.replace('.', "/"))),
1345            PathBuf::from(format!("src/{}.bynk", name.replace('.', "/"))),
1346            None,
1347            String::new(),
1348            SourceUnit::Context(Context {
1349                name: qualified(name),
1350                uses: targets
1351                    .iter()
1352                    .map(|t| UsesDecl {
1353                        target: qualified(t),
1354                        span: Span::default(),
1355                        trivia: Trivia::default(),
1356                    })
1357                    .collect(),
1358                consumes: Vec::new(),
1359                exports: Vec::new(),
1360                items: Vec::new(),
1361                documentation: None,
1362                form: CommonsForm::Brace,
1363                span: Span::default(),
1364                trivia: Trivia::default(),
1365                trailing_comments: Vec::new(),
1366            }),
1367            UnitKind::Context,
1368            false,
1369        )
1370    }
1371
1372    /// The four tables `detect_context_message_bundle`'s real callers
1373    /// already build — bundled here so [`scenario`] doesn't need a
1374    /// clippy-unfriendly four-tuple return type.
1375    struct Scenario {
1376        parsed: Vec<ParsedFile>,
1377        groups: BTreeMap<String, Vec<usize>>,
1378        kinds: BTreeMap<String, UnitKind>,
1379        unit_uses: HashMap<String, Vec<String>>,
1380    }
1381
1382    /// Assembles a [`Scenario`] from a context plus its bundle files.
1383    fn scenario(ctx_name: &str, ctx_uses: &[&str], bundles: Vec<(&str, ParsedFile)>) -> Scenario {
1384        let mut parsed = vec![context_using(ctx_name, ctx_uses)];
1385        let mut groups: BTreeMap<String, Vec<usize>> = BTreeMap::new();
1386        let mut kinds: BTreeMap<String, UnitKind> = BTreeMap::new();
1387        groups.insert(ctx_name.to_string(), vec![0]);
1388        kinds.insert(ctx_name.to_string(), UnitKind::Context);
1389        for (name, pf) in bundles {
1390            let idx = parsed.len();
1391            parsed.push(pf);
1392            groups.entry(name.to_string()).or_default().push(idx);
1393            kinds.insert(name.to_string(), UnitKind::Commons);
1394        }
1395        let mut unit_uses: HashMap<String, Vec<String>> = HashMap::new();
1396        unit_uses.insert(
1397            ctx_name.to_string(),
1398            ctx_uses.iter().map(|s| s.to_string()).collect(),
1399        );
1400        Scenario {
1401            parsed,
1402            groups,
1403            kinds,
1404            unit_uses,
1405        }
1406    }
1407
1408    #[test]
1409    fn zero_bundles_when_uses_reaches_no_messages_commons() {
1410        let Scenario {
1411            parsed,
1412            groups,
1413            kinds,
1414            unit_uses,
1415        } = scenario("app.web", &["app.other"], vec![]);
1416        assert!(matches!(
1417            detect_context_message_bundle("app.web", &unit_uses, &groups, &kinds, &parsed),
1418            ContextMessageBundle::None
1419        ));
1420    }
1421
1422    #[test]
1423    fn zero_bundles_when_uses_is_empty() {
1424        let Scenario {
1425            parsed,
1426            groups,
1427            kinds,
1428            unit_uses,
1429        } = scenario("app.web", &[], vec![]);
1430        assert!(matches!(
1431            detect_context_message_bundle("app.web", &unit_uses, &groups, &kinds, &parsed),
1432            ContextMessageBundle::None
1433        ));
1434    }
1435
1436    #[test]
1437    fn one_bundle_is_found_by_its_reference_block() {
1438        let bundle = commons_with_messages("app.msgs", "en", true);
1439        let Scenario {
1440            parsed,
1441            groups,
1442            kinds,
1443            unit_uses,
1444        } = scenario("app.web", &["app.msgs"], vec![("app.msgs", bundle)]);
1445        let found = detect_context_message_bundle("app.web", &unit_uses, &groups, &kinds, &parsed);
1446        let ContextMessageBundle::One(info) = found else {
1447            panic!("expected exactly one bundle");
1448        };
1449        assert_eq!(info.commons, "app.msgs");
1450        assert_eq!(info.source_path, PathBuf::from("app/msgs.bynk"));
1451    }
1452
1453    #[test]
1454    fn a_bundle_missing_its_reference_block_is_not_counted() {
1455        // Not a `@reference` block — already diagnosed elsewhere
1456        // (`bynk.messages.missing_reference`); this function simply doesn't
1457        // count it, rather than compounding an already-reported error.
1458        let bundle = commons_with_messages("app.msgs", "en", false);
1459        let Scenario {
1460            parsed,
1461            groups,
1462            kinds,
1463            unit_uses,
1464        } = scenario("app.web", &["app.msgs"], vec![("app.msgs", bundle)]);
1465        assert!(matches!(
1466            detect_context_message_bundle("app.web", &unit_uses, &groups, &kinds, &parsed),
1467            ContextMessageBundle::None
1468        ));
1469    }
1470
1471    #[test]
1472    fn two_bundles_report_both_commons_names() {
1473        let a = commons_with_messages("app.msgs_a", "en", true);
1474        let b = commons_with_messages("app.msgs_b", "en", true);
1475        let Scenario {
1476            parsed,
1477            groups,
1478            kinds,
1479            unit_uses,
1480        } = scenario(
1481            "app.web",
1482            &["app.msgs_a", "app.msgs_b"],
1483            vec![("app.msgs_a", a), ("app.msgs_b", b)],
1484        );
1485        let found = detect_context_message_bundle("app.web", &unit_uses, &groups, &kinds, &parsed);
1486        let ContextMessageBundle::Many(names) = found else {
1487            panic!("expected two bundles");
1488        };
1489        let mut names = names;
1490        names.sort();
1491        assert_eq!(
1492            names,
1493            vec!["app.msgs_a".to_string(), "app.msgs_b".to_string()]
1494        );
1495    }
1496
1497    #[test]
1498    fn a_commons_reached_only_transitively_is_not_counted() {
1499        // `app.web` uses `app.mid`, which itself uses the bundle — `uses` is
1500        // one level, not transitive (message-bundles' own established rule),
1501        // so this must still report `None`.
1502        let bundle = commons_with_messages("app.msgs", "en", true);
1503        let mut mid = context_using("app.mid", &["app.msgs"]);
1504        // `app.mid` needs to be a commons for this scenario to be legal, but
1505        // `context_using` builds a Context — for this narrow test only the
1506        // `uses` *resolution* (does app.web's own direct list reach the
1507        // bundle) matters, and app.web's own list never names `app.msgs`
1508        // directly, so the unit kind of the intermediate is irrelevant.
1509        mid.set_kind(UnitKind::Commons);
1510        let Scenario {
1511            mut parsed,
1512            mut groups,
1513            mut kinds,
1514            unit_uses,
1515        } = scenario("app.web", &["app.mid"], vec![("app.msgs", bundle)]);
1516        let mid_idx = parsed.len();
1517        parsed.push(mid);
1518        groups.insert("app.mid".to_string(), vec![mid_idx]);
1519        kinds.insert("app.mid".to_string(), UnitKind::Commons);
1520        assert!(matches!(
1521            detect_context_message_bundle("app.web", &unit_uses, &groups, &kinds, &parsed),
1522            ContextMessageBundle::None
1523        ));
1524    }
1525}
1526
1527/// #696: returns the `parsed` index of the owning file alongside the `consumes`
1528/// clause span, so the caller can attribute the diagnostic to that file.
1529pub fn consumes_span_of(
1530    parsed: &[ParsedFile],
1531    indices: &[usize],
1532    target: &str,
1533) -> Option<(usize, Span)> {
1534    for &i in indices {
1535        for c in parsed[i].consumes() {
1536            if c.target.joined() == target {
1537                return Some((i, c.span));
1538            }
1539        }
1540    }
1541    None
1542}
1543
1544/// #696: returns the `parsed` index of the owning file alongside the alias span,
1545/// so the caller can attribute the diagnostic to that file.
1546pub fn parsed_alias_span(
1547    parsed: &[ParsedFile],
1548    indices: &[usize],
1549    alias: &str,
1550) -> Option<(usize, Span)> {
1551    for &i in indices {
1552        for c in parsed[i].consumes() {
1553            if let Some(a) = &c.alias
1554                && a.name == alias
1555            {
1556                return Some((i, a.span));
1557            }
1558        }
1559    }
1560    None
1561}
1562
1563/// A type imported into a context via `consumes`. Carries enough metadata for
1564/// the checker and emitter to enforce / express visibility.
1565#[derive(Debug, Clone)]
1566pub struct ConsumedType {
1567    pub owning_context: String,
1568    pub visibility: Visibility,
1569}