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