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