brink_db/queries/mod.rs
1//! Salsa inputs and tracked queries — the query-shaped compiler pipeline
2//! (scripting-substrate spec §4, phase 0 slice B).
3//!
4//! Layer 0 (inputs): [`SourceFile`] (path + text) and [`ProjectInput`] (the
5//! file set, the entry point, and the analysis options). Editor overlays are
6//! plain input writes — there is no separate overlay pathway.
7//!
8//! Layer 1 (per file): [`parse_query`], [`lowered_query`] (HIR + manifest +
9//! lowering diagnostics, exactly the composition the old `set_file` cached
10//! — now project-aware, issue #2289: it merges in the conventions module's
11//! cross-file claiming reach via [`external_claim_handlers_query`], reading
12//! the project-*independent* [`raw_lowered_query`] underneath), and its two
13//! exceptions that read `raw_lowered_query` directly ([`suppressions_query`]
14//! and [`external_claim_handlers_query`] itself — see either's own doc for
15//! why), plus the project-wide [`include_graph_query`].
16//!
17//! Layer 2 (project-wide names): [`symbol_index_query`],
18//! [`harvest_index_query`] (the project-db harvest obligation over cue
19//! payloads and markup span kinds, issue #2114 — a sibling merge over the
20//! same per-file [`lowered_query`] outputs, not a per-file query),
21//! [`harvest_completion_index_query`] (the harvest index's own early-cutoff
22//! completion projection, issue #2134 — the sibling of
23//! [`resolution_index_query`] below, dropping every site's `TextRange` down
24//! to bare name sets),
25//! [`resolution_index_query`] (the early-cutoff seam — see below),
26//! [`resolve_query`], [`signature_query`], and [`analysis_query`] — the
27//! latter now a thin assembler (issue #632 / FG-3) over
28//! [`resolutions_index_query`] (index + resolutions, no diagnostics),
29//! [`per_file_diagnostics_query`]/[`contributor_diagnostics_query`] (the
30//! per-file validate/dialect_gate/annotation-content split),
31//! [`whole_project_diagnostics_query`] (now a thin aggregator — issue #750
32//! decomposed the external-check family into [`inline_docs_query`] /
33//! [`external_meta_query`] / [`call_site_metas_query`] and the per-file
34//! [`value_meta_query`] / [`call_site_diagnostics_query`]; only the M-2
35//! modules pass and the strict typed-mode pass remain genuinely
36//! whole-project), and
37//! [`analysis_diagnostics_query`] (every diagnostic source, merged). See
38//! the "FG-3" section below for the full rationale.
39//!
40//! Layer 3 (lowering/codegen, whole-project in this slice): [`lir_query`],
41//! [`story_data_query`], and the per-file [`diagnostics_query`] — both now
42//! read the decomposed FG-3 queries directly rather than through the
43//! bundled [`analysis_query`].
44//!
45//! # The `resolution_index` cutoff seam (slice-A findings 1+2, tightened by #517)
46//!
47//! The full [`SymbolIndex`] carries a `TextRange` per symbol, so nearly any
48//! edit shifts ranges and defeats `Eq`-cutoff on the index — dependents of
49//! `symbol_index` would re-run on every keystroke. [`resolution_index_query`]
50//! sits between the index and reference resolution: it is the full index with
51//! locals (`Param`/`Temp`) dropped and ranges zeroed for every remaining
52//! (declaration) symbol.
53//!
54//! Locals were originally kept in the projection (with real ranges) because
55//! `lookup_local_in_scope`'s closest-preceding pick was the one place
56//! resolution read symbol ranges. That left a gap (finding 1): a body edit
57//! that adds/removes a `~ temp` anywhere in the project changes a local's
58//! *identity*, not just its range, so `resolution_index_query`'s own output
59//! still differed and every file's `resolve` memo still re-ran. #517 closes
60//! the gap by having `resolve_query` read the declaring file's own
61//! `manifest.locals` instead of the merged index for local lookups (a knot's
62//! body lives in exactly one file, so this was always sufficient — see
63//! `brink_analyzer::resolve::lookup_local_in_scope`), which also fixes the
64//! finding-4 cross-file duplicate-`DefinitionId` aliasing: resolution no
65//! longer merges locals from different files, so it can no longer pick the
66//! wrong file's declaration. With locals gone, dropping the rest is
67//! behavior-neutral by construction (locked by the `query_equivalence` tests
68//! and the oracle gate).
69//!
70//! # Memory bounding (FG-5, issue #647, decision log "FG-5 memory bounding")
71//!
72//! The per-file query families (`parse`, `lowered`, `suppressions`,
73//! `resolve`, `per_file_diagnostics`, `value_meta`, `call_site_diagnostics`,
74//! `diagnostics`) and the per-def families keyed by [`DefKey`]
75//! (`signature`, `def_body`,
76//! `referenced_globals`, `call_edges`, `solve_scc`, `inferred_signature`,
77//! `infer_body`) each carry a salsa `lru` capacity — a **runaway guard**,
78//! not a working-set trim. Issue #537's measurement (large synthetic
79//! projects, 2,000-edit sessions) showed every one of these families scales
80//! with live project size and shows zero session-length growth, so a tight
81//! LRU would only evict live working-set entries and buy recompute churn on
82//! big projects, never save memory. The ceilings are sized far above
83//! realistic project scale on purpose (≈30× the measured 132-file scale for
84//! per-file families, ≈10× the measured 1,549-def scale for per-def
85//! families) so they never evict in steady state, and exist only to cap the
86//! pathological/runaway case. **No eviction *policy* design happened here**
87//! — that was explicitly ruled out by the data; see the decision log entry
88//! for the full ruling. `def_body`, `solve_scc`, `signature`, `infer_body`,
89//! and `lowered` additionally specify a `heap_size` estimator
90//! ([`heap_size`], issue #538) — the families #537 flagged as the dominant
91//! Arc-hidden payloads, so `crate::memory::snapshot`'s `heap_bytes` column
92//! reads `Some(_)` for them instead of the honest-`None` every query
93//! reported before this pass.
94
95use std::collections::{BTreeMap, BTreeSet};
96use std::sync::Arc;
97
98use brink_analyzer::{
99 AnalysisOptions, CallGraph, HarvestIndex, HarvestNames, ImportScope, InferenceResult, SccGraph,
100 Sig, TypePolicy,
101};
102use brink_format::{
103 CallAtom, CapabilityParam, DefinitionId, DirectEffects, EffectRowEntry, NameId, StoryData,
104};
105use brink_ir::suppressions::{Suppressions, apply_suppressions, parse_suppressions};
106use brink_ir::symbols::project_manifest;
107use brink_ir::{
108 Diagnostic, DiagnosticCode, FileId, HirFile, ResolutionMap, Severity, SymbolIndex, SymbolKind,
109 SymbolManifest, lower_declarations, lower_single_knot, lower_top_level,
110};
111use brink_syntax::Parse;
112use brink_syntax_native::Parse as NativeParse;
113
114use crate::db::resolve_include_path;
115use crate::determinism::{LookupMap, LookupSet};
116use crate::include_graph::IncludeGraph;
117
118mod analysis;
119mod heap_size;
120pub(crate) mod segments;
121
122/// Whether the file rides the ink segment road (#3064 option A) — the
123/// delta protocol serves ink files only; native files use the whole-doc
124/// queries.
125pub(crate) fn is_ink_file(db: &BrinkDatabase, file: SourceFile) -> bool {
126 file_language(file.path(db)) == Language::Ink
127}
128
129pub(crate) use segments::{
130 FileSegment, file_segments_query, line_contexts_query, projection_query, semantic_tokens_query,
131};
132
133pub use analysis::ResolvedProject;
134pub(crate) use analysis::{
135 MemberSet, analysis_diagnostics_query, analysis_query, await_purity_diagnostics_query,
136 call_site_diagnostics_query, call_site_metas_query, coalesce_types_query,
137 comparator_contract_diagnostics_query, contributor_diagnostics_query,
138 conventions_confinement_diagnostics_query, conventions_projection_query, diagnostics_query,
139 effects_assertion_diagnostics_query, external_claim_handlers_query, external_meta_query,
140 has_errors_in_closure_query, has_errors_query, import_closure_query, inline_docs_query,
141 per_file_diagnostics_query, resolutions_index_query, subset_analysis_query,
142 ufcs_resolution_query, value_meta_query, whole_project_diagnostics_query,
143};
144
145// ─── Database ────────────────────────────────────────────────────────
146
147/// The salsa database behind [`crate::ProjectDb`].
148///
149/// Ingredients are registered explicitly (salsa's `inventory` feature is
150/// off): link-time collection via life-before-main is exactly the kind of
151/// platform magic that breaks on wasm, and the explicit list keeps the query
152/// surface reviewable. A query missing from the list panics loudly on first
153/// use — any test exercising it catches that immediately.
154#[salsa::db]
155#[derive(Clone)]
156pub(crate) struct BrinkDatabase {
157 storage: salsa::Storage<Self>,
158}
159
160#[salsa::db]
161impl salsa::Database for BrinkDatabase {}
162
163impl Default for BrinkDatabase {
164 fn default() -> Self {
165 Self {
166 storage: salsa::Storage::builder()
167 // Inputs + interned keys.
168 .ingredient::<SourceFile>()
169 .ingredient::<ProjectInput>()
170 .ingredient::<DefKey<'_>>()
171 // Option A total (2026-08-24): the member-set key for
172 // per-root subset analysis (`subset_analysis_query`).
173 .ingredient::<MemberSet<'_>>()
174 // Per-knot lowering (#3084): the segment tracked structs
175 // plus the segmentation query minting them.
176 .ingredient::<FileSegment<'_>>()
177 .ingredient::<file_segments_query>()
178 .ingredient::<segments::segment_lowered_query>()
179 .ingredient::<resolved_dialect_query>()
180 .ingredient::<segments::segment_projection_query>()
181 .ingredient::<segments::projection_query>()
182 .ingredient::<segments::segment_line_contexts_query>()
183 .ingredient::<segments::line_contexts_query>()
184 .ingredient::<segments::file_resolution_kinds_query>()
185 .ingredient::<segments::segment_resolution_kinds_query>()
186 .ingredient::<segments::segment_semantic_tokens_query>()
187 .ingredient::<segments::segment_semantic_tokens_classifier_query>()
188 .ingredient::<segments::semantic_tokens_query>()
189 // Layer 1.
190 .ingredient::<parse_query>()
191 // B0.10a native compile seam (issue #1106): the frontend-
192 // specific parse ingredient, dispatched by `lowered_query`.
193 .ingredient::<parse_native_query>()
194 // Issue #2289: the project-*independent* raw lowering
195 // (`raw_lowered_query`) and the project-aware canonical
196 // entry point (`lowered_query`) that merges in the
197 // conventions module's cross-file claiming reach — see
198 // either query's own doc.
199 .ingredient::<raw_lowered_query>()
200 .ingredient::<lowered_query>()
201 .ingredient::<suppressions_query>()
202 .ingredient::<include_graph_query>()
203 // Layer 2.
204 .ingredient::<module_map_query>()
205 .ingredient::<symbol_index_query>()
206 .ingredient::<harvest_index_query>()
207 // Issue #2134: the harvest index's range-free completion
208 // projection — the sibling of `resolution_index_query`
209 // below, for the same Eq-cutoff reason (see this module's
210 // doc and `harvest_completion_index_query`'s own).
211 .ingredient::<harvest_completion_index_query>()
212 .ingredient::<resolution_index_query>()
213 // Issue #2272 review finding: the shared per-file
214 // `ImportScope` derivation `resolve_query` and
215 // `per_file_diagnostics_query` both now call.
216 .ingredient::<file_import_scope_query>()
217 .ingredient::<resolve_query>()
218 .ingredient::<signature_query>()
219 // Issue #530: the per-file locals path signature_query
220 // itself can't take — see local_signature_query's doc.
221 .ingredient::<local_signature_query>()
222 // FG-3 (issue #632): analysis_query decomposed into narrow
223 // cutoff-friendly projections. resolutions_index_query
224 // (index+resolutions, no diagnostics) and
225 // analysis_diagnostics_query (every diagnostic source,
226 // assembled from per-file contributors +
227 // whole_project_diagnostics_query) are independent queries
228 // now, so a diagnostics-only edit never invalidates a
229 // resolutions-only reader and vice versa.
230 // per_file_diagnostics_query/contributor_diagnostics_query
231 // are the per-file validate/dialect_gate/annotation-content
232 // split — a body edit in file Y leaves file X's contributor
233 // memo untouched. analysis_query itself survives as a thin
234 // assembler over these for `db.analysis()`'s existing
235 // LSP/IDE/CLI-facing shape.
236 .ingredient::<resolutions_index_query>()
237 .ingredient::<per_file_diagnostics_query>()
238 .ingredient::<contributor_diagnostics_query>()
239 // FG-3 completion (issue #750): the external-check family,
240 // decomposed. inline_docs_query (project doc merge, Eq
241 // cutoff) + external_meta_query (index-driven E039/E040 +
242 // enrichment, no HIR) + call_site_metas_query (the
243 // range-free name→meta cutoff seam) feed the per-file
244 // value_meta_query / call_site_diagnostics_query, so a body
245 // edit in file Y re-runs only Y's own value-meta and
246 // call-site walks; whole_project_diagnostics_query is now a
247 // thin aggregator (plus the genuinely whole-project M-2
248 // modules pass and strict pass).
249 .ingredient::<inline_docs_query>()
250 .ingredient::<external_meta_query>()
251 .ingredient::<call_site_metas_query>()
252 .ingredient::<value_meta_query>()
253 .ingredient::<call_site_diagnostics_query>()
254 .ingredient::<whole_project_diagnostics_query>()
255 // B3a UFCS (issue #1506): the verdict table, shared by
256 // `whole_project_diagnostics_query` (diagnostics half) and
257 // LIR lowering (`lir_knot_chunk_query`/`lir_lowering_query`).
258 .ingredient::<ufcs_resolution_query>()
259 // B1 `or`-coalescing (issue #1471/#1492): the recorded
260 // per-step chain shapes LIR lowering consumes
261 // (`lir_knot_chunk_query`/`lir_lowering_query`).
262 .ingredient::<coalesce_types_query>()
263 .ingredient::<analysis_diagnostics_query>()
264 .ingredient::<analysis_query>()
265 // Option A total (2026-08-24): per-root subset analysis —
266 // the retired `analyze_with_modules` composition relocated
267 // into a member-set-keyed query (brink-lsp's road).
268 .ingredient::<subset_analysis_query>()
269 .ingredient::<diagnostics_query>()
270 // FG-4a (issue #791): the `has_errors` boolean projection
271 // (PR #753's seam finding #3) and the LIR-lowering split it
272 // gates — see `lir_query`'s doc comment. type_policy_query
273 // (issue #806) is the matching narrow `.types` projection so
274 // an unrelated AnalysisOptions edit can't re-execute the
275 // `no_eq` lowering memo.
276 .ingredient::<has_errors_query>()
277 // Issue #1032 collapse ruling: the closure-scoped counterpart
278 // `compileProject`'s artifact path (`story_data_query`) reads
279 // instead of the whole-project `has_errors_query`/`lir_query`
280 // above — see `has_errors_in_closure_query`'s doc comment.
281 .ingredient::<has_errors_in_closure_query>()
282 .ingredient::<type_policy_query>()
283 // The `.lints` sibling projection (issue #1160) — see
284 // `lint_policy_query`'s doc comment.
285 .ingredient::<lint_policy_query>()
286 // FG-4d (issue #830): per-knot LIR chunk memos + the
287 // cutoff-friendly struct-shape projection they read;
288 // `lir_lowering_query` is now the link phase assembling them.
289 .ingredient::<struct_shape_data_query>()
290 .ingredient::<normalized_stamped_query>()
291 // FG-4e (issue #839): decl_hir_query is the per-file
292 // backdating projection lir_prelude_decls_query reads
293 // instead of raw HIR, so a knot body edit doesn't force the
294 // whole-project declaration collection to re-execute.
295 .ingredient::<decl_hir_query>()
296 .ingredient::<lir_prelude_decls_query>()
297 .ingredient::<KnotChunkKey<'_>>()
298 // Issue #460: the knot-invariant half of a chunk's lowering
299 // environment, hoisted out of the per-knot memo so it is
300 // built once per revision instead of once per knot.
301 .ingredient::<chunk_lowering_ctx_query>()
302 .ingredient::<lir_knot_chunk_query>()
303 .ingredient::<lir_lowering_query>()
304 // Layer 2/3: type inference (TM-1, advisory-only).
305 // Per-def/per-SCC decomposition (FG-2, issue #631):
306 // call_edges(def) -> call_graph() -> scc_membership() ->
307 // solve_scc(SccId) -> inferred_signature(def)/infer_body(def).
308 // Lazy per-reference globals + full dependency narrowing
309 // (FG-2.1, issue #638): inferable_defs_query/def_body_query/
310 // referenced_globals_query are the new per-def projections
311 // call_edges_query/solve_scc_query read instead of every
312 // project file's HIR.
313 .ingredient::<inference_index_query>()
314 .ingredient::<inferable_defs_query>()
315 .ingredient::<def_body_query>()
316 .ingredient::<referenced_globals_query>()
317 .ingredient::<call_edges_query>()
318 .ingredient::<call_graph_query>()
319 .ingredient::<scc_membership_query>()
320 .ingredient::<solve_scc_query>()
321 .ingredient::<inferred_signature_query>()
322 .ingredient::<external_signatures_query>()
323 .ingredient::<type_inference_query>()
324 .ingredient::<infer_body_query>()
325 .ingredient::<type_diagnostics_query>()
326 // T2-1 (issue #860): advisory effect-row inference, sited
327 // beside inferred_signature. def_effect_atoms_query is the
328 // per-def atom harvest (same body walk referenced_globals/
329 // call_edges drive); effects_scc_query lifts solve_scc's
330 // per-SCC fixpoint to the effect lattice; effects_query is the
331 // per-def view.
332 .ingredient::<def_effect_atoms_query>()
333 .ingredient::<effects_scc_query>()
334 .ingredient::<effects_query>()
335 // T2-2 (issue #861): the `#@effects(…)` assertion's
336 // per-file exceedance check, reading `effects_query` only
337 // for defs that actually carry an assertion.
338 .ingredient::<effects_assertion_diagnostics_query>()
339 // FS-2 (issue #928): the `await`-condition purity gate (E105),
340 // reading `effects_query` only for defs a condition calls.
341 .ingredient::<await_purity_diagnostics_query>()
342 // NS-A4 (issue #1110, extended to the fn-value verb trio by
343 // issue #1679): the comparator-contract gate (E119),
344 // reading `effects_query` only for defs named as inline
345 // `#fn` comparators/callbacks of `sort_by`/`sorted_by`/
346 // `map`/`filter`/`fold`.
347 .ingredient::<comparator_contract_diagnostics_query>()
348 // Conventions-module confinement gate (E169, issue #1844):
349 // the MODULE half of the §9.1 claiming-handler confinement
350 // ruling. Reads `module_map_query` only for a file that
351 // declared at least one claiming handler.
352 .ingredient::<conventions_confinement_diagnostics_query>()
353 // The cross-file claiming injection seam (issue #2289):
354 // the project's configured conventions module's declared
355 // `@[convention]` handlers, read once per project revision
356 // and merged into every other native file's lowering by
357 // `lowered_query`.
358 .ingredient::<external_claim_handlers_query>()
359 // The reusable transitive `IMPORT` closure (issue #2111
360 // finding 3): generic over any entry file, so #2167 can
361 // reuse it for E169 confinement relaxation.
362 .ingredient::<import_closure_query>()
363 // The serialized conventions projection (issue #2111, NS-T
364 // seam 1/6): the editor-facing artifact, reading the
365 // resolved conventions module's import closure to resolve
366 // `attach = StructName` schemas (finding 1).
367 .ingredient::<conventions_projection_query>()
368 // Layer 3.
369 .ingredient::<lir_query>()
370 .ingredient::<lir_in_closure_query>()
371 .ingredient::<story_data_query>()
372 .build(),
373 }
374 }
375}
376
377// ─── Layer 0: inputs ─────────────────────────────────────────────────
378
379/// One source file: identity (stable [`FileId`] + project-relative path) and
380/// its current text. The text is the only mutable input — editor overlays and
381/// disk loads both go through `set_text`.
382#[salsa::input]
383pub(crate) struct SourceFile {
384 pub file_id: FileId,
385 #[returns(ref)]
386 pub path: String,
387 #[returns(ref)]
388 pub text: String,
389}
390
391/// The project-level input: the file set (sorted by [`FileId`]), the compile
392/// entry point, the analysis options (host manifest + external-check
393/// severity), and the native source root.
394#[salsa::input]
395pub(crate) struct ProjectInput {
396 #[returns(ref)]
397 pub files: Vec<SourceFile>,
398 pub entry: Option<FileId>,
399 #[returns(ref)]
400 pub analysis_options: AnalysisOptions,
401 /// The registered screenplay dialect's pure-JSON config (#3064 B1),
402 /// `None` when no dialect is active. The COMPILED form
403 /// ([`brink_ir::ResolvedDialect`], regex objects, not `Eq`) is derived
404 /// by [`resolved_dialect_query`] so per-segment editor queries can
405 /// read it as a tracked dependency; a dialect swap invalidates them
406 /// all, which is correct and rare (session setup / dialect toggle).
407 #[returns(ref)]
408 pub dialect: Option<brink_ir::DialogueDialect>,
409 /// The directory native `.brink` keys are root-relative *to*, for a
410 /// consumer that registers files under some other prefix (issue #1572:
411 /// the LSP keys by absolute OS path). `None` — every compile path, where
412 /// `discover_native` already keys root-relative — means "the keys are
413 /// already root-relative", and is byte-identical to the pre-#1572 world.
414 /// Only [`crate::modules::root_relative_key`] reads it, for native module
415 /// identity ([`module_map_query`]'s native branch).
416 #[returns(ref)]
417 pub native_root: Option<String>,
418 /// The directory `.ink` keys are root-relative *to*, for
419 /// [`hir::root_content_scope_path`](brink_ir::hir::root_content_scope_path)'s
420 /// qualifier (issue #1696) — ink's sibling of `native_root` above, reusing
421 /// the same [`crate::modules::root_relative_key`] mechanism #1572 built.
422 /// Unlike native, ink's CLI discovery has no `RealFs`-scoped tree to key
423 /// root-relative "for free": `brink-driver`'s `discover` BFS registers
424 /// files under whatever spelling the caller passed `prepare_driver`
425 /// (`brink-compiler/src/driver.rs`), so `main.ink`, `./main.ink`, and an
426 /// absolute spelling of the same file used to mint different anonymous
427 /// root-content `DefinitionId`s for byte-identical source. `None` (no
428 /// caller has registered a root) is byte-identical to the pre-#1696
429 /// world — `root_relative_key` returns every path unchanged.
430 #[returns(ref)]
431 pub ink_root: Option<String>,
432}
433
434// ─── Layer 1: per-file queries ───────────────────────────────────────
435
436/// Parse one file's text into a lossless CST.
437///
438/// `lru = 4096`: a per-file runaway-guard ceiling (issue #647, decision log
439/// "FG-5 memory bounding"), not a working-set trim — see this module's doc
440/// comment's "Memory bounding" section.
441/// Compile the registered dialect config into its resolved (regex-
442/// compiled) form (#3064 B1). Memoized so regex compilation happens once
443/// per config change, never per keystroke; a config that fails to
444/// compile resolves to `None` (the session-level `set_dialect` surface
445/// already validated it, so this arm is unreachable in practice).
446/// `no_eq`: `ResolvedDialect` holds compiled regexes with no `Eq` — the
447/// memo re-executes only when the config input changes, and downstream
448/// consumers should be invalidated then anyway, so skipping backdating
449/// is both required and correct.
450#[salsa::tracked(returns(ref), no_eq)]
451pub(crate) fn resolved_dialect_query(
452 db: &dyn salsa::Database,
453 project: ProjectInput,
454) -> ResolvedDialectHandle {
455 ResolvedDialectHandle(
456 project
457 .dialect(db)
458 .as_ref()
459 .and_then(|config| brink_ir::ResolvedDialect::compile(config).ok())
460 .map(Arc::new),
461 )
462}
463
464/// Memo payload wrapper for `no_eq` queries whose output type lives
465/// upstream without a `salsa::Update` impl (#3064 B2). Semantics: the
466/// memo never backdates — correct wherever every re-execution implies a
467/// genuinely changed output (per-segment projections re-execute only
468/// when their segment's content changed; the assembly re-executes every
469/// edit because offsets moved).
470#[derive(Clone)]
471pub(crate) struct NoEqArc<T>(pub Arc<T>);
472
473impl<T> std::fmt::Debug for NoEqArc<T> {
474 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
475 f.debug_tuple("NoEqArc")
476 .field(&std::any::type_name::<T>())
477 .finish()
478 }
479}
480
481#[expect(
482 unsafe_code,
483 reason = "salsa::Update is an unsafe trait by design; this is the \
484 always-replace impl a derive would emit for a local type. \
485 The body is a plain pointer write per the trait's documented \
486 contract."
487)]
488// SAFETY: always replaces the old value and reports it changed — the
489// most conservative legal `Update` behavior (never falsely "unchanged").
490unsafe impl<T: 'static> salsa::Update for NoEqArc<T> {
491 unsafe fn maybe_update(old_pointer: *mut Self, new_value: Self) -> bool {
492 // SAFETY: caller guarantees `old_pointer` is valid per the trait
493 // contract; plain replacement drops the old value normally.
494 unsafe { *old_pointer = new_value };
495 true
496 }
497}
498
499/// Memo payload for [`resolved_dialect_query`]: `ResolvedDialect` holds
500/// compiled regexes (no `Eq`, no derivable `salsa::Update`), so this
501/// newtype carries the manual always-replace `Update` impl the `no_eq`
502/// memo needs — the query re-executes only on a config change, when
503/// "changed" is the correct verdict by construction.
504#[derive(Clone)]
505pub(crate) struct ResolvedDialectHandle(pub Option<Arc<brink_ir::ResolvedDialect>>);
506
507impl std::fmt::Debug for ResolvedDialectHandle {
508 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
509 f.debug_tuple("ResolvedDialectHandle")
510 .field(&self.0.is_some())
511 .finish()
512 }
513}
514
515#[expect(
516 unsafe_code,
517 reason = "salsa::Update is an unsafe trait by design; this is the \
518 always-replace impl `#[derive(salsa::Update)]` would emit if \
519 `ResolvedDialect` were local (it lives in brink-ir, which \
520 deliberately has no salsa dependency). The body is a plain \
521 pointer write per the trait's documented contract."
522)]
523// SAFETY: always replaces the old value and reports it changed — the
524// most conservative legal `Update` behavior (never falsely "unchanged").
525unsafe impl salsa::Update for ResolvedDialectHandle {
526 unsafe fn maybe_update(old_pointer: *mut Self, new_value: Self) -> bool {
527 // SAFETY: caller guarantees `old_pointer` is valid per the trait
528 // contract; plain replacement drops the old value normally.
529 unsafe { *old_pointer = new_value };
530 true
531 }
532}
533
534#[salsa::tracked(returns(ref), lru = 4096)]
535pub(crate) fn parse_query(db: &dyn salsa::Database, file: SourceFile) -> Parse {
536 brink_syntax::parse(file.text(db))
537}
538
539/// Parse one native `.brink` file's text into a lossless CST.
540///
541/// The frontend-specific sibling of [`parse_query`] (B0.10a, the native
542/// compile seam, issue #1106). `brink_syntax_native::Parse` is structurally
543/// identical to `brink_syntax::Parse` (`{green, errors}`, `Clone + Eq`) but a
544/// distinct nominal type, so it needs its own tracked ingredient — matching
545/// attrs (`returns(ref)`, `lru = 4096`, the same per-file runaway-guard
546/// ceiling, issue #647). Only ever executed for files [`file_language`]
547/// classifies as [`Language::Native`], so an `.ink` file never runs the native
548/// parser and vice-versa — see [`lowered_query`].
549#[salsa::tracked(returns(ref), lru = 4096)]
550pub(crate) fn parse_native_query(db: &dyn salsa::Database, file: SourceFile) -> NativeParse {
551 brink_syntax_native::parse(file.text(db))
552}
553
554/// Per-file lowering output: assembled HIR, symbol manifest, and lowering +
555/// syntax diagnostics — the exact product the retired `FileState` cached.
556#[derive(Debug, Clone, PartialEq)]
557pub(crate) struct LoweredFile {
558 pub hir: HirFile,
559 pub manifest: SymbolManifest,
560 pub diagnostics: Vec<Diagnostic>,
561 /// B0.3 `validate_admission` output (docs/hir-admission-contract.md
562 /// §4.2, issue #1172), plus — for a native `.brink` file only — B0.9's
563 /// `validate_native_accept_list` output appended after it (issue
564 /// #1179) — kept deliberately separate from `diagnostics`: both
565 /// admission gates are non-suppressible (NF-6, always-on), so neither
566 /// must ever flow through `apply_suppressions` the way lowering/syntax
567 /// diagnostics do (`partition_diagnostics` below). Computed here so it
568 /// runs on every lowering, matching the "the validator runs on every
569 /// keystroke in the editor" perf posture — see `heap_size.rs`'s
570 /// `lowered_file_heap_size` for the matching estimator update.
571 pub admission: Vec<Diagnostic>,
572}
573
574/// Lower one file to HIR, ignoring project identity entirely. Salsa's
575/// dependency tracking on the `parse` input replaces the retired per-knot
576/// green-node/byte-offset cache (`knot_cache`): the composition below is
577/// byte-identical to what `set_file` produced.
578///
579/// The project-*independent* half of lowering — deliberately kept that way
580/// (no `ProjectInput` parameter) so an edit to one file can never, through
581/// this query alone, invalidate another file's memo. [`lowered_query`] is
582/// the project-aware entry point every other consumer should read instead
583/// (issue #2289's cross-file claiming reach needs project identity to know
584/// which file the conventions module is); this raw query now has exactly
585/// two callers: [`suppressions_query`] (whose `allow_scopes` are a pure
586/// CST scan, unaffected by which handlers a line ends up claimed by — see
587/// `brink_ir::hir::lower_native::annotation::allow_scopes`) and
588/// [`external_claim_handlers_query`] (reading the conventions module's OWN
589/// declared handlers, which are likewise invariant to whatever `external`
590/// set that file itself was lowered with — [`Elements::handler_decls`]
591/// only ever reads local declarations, never an injected one — see that
592/// method's own doc in `brink-ir`). Reading the raw query here, rather than
593/// the project-aware one, is what breaks the circular
594/// dependency [`lowered_query`] → [`external_claim_handlers_query`] would
595/// otherwise close on itself when lowering the conventions module's own
596/// file.
597///
598/// `lru = 4096`: per-file runaway-guard ceiling (issue #647). `heap_size`:
599/// one of the five #538/#647 estimators — #537 flagged this family's
600/// per-def analogues (`def_body`/`solve_scc`) as the dominant Arc-hidden
601/// payload; this is the per-file sibling.
602///
603/// `Arc`-wrapped (issue #2289 review finding): every OTHER native file in a
604/// project with no conventions module configured — the overwhelmingly
605/// common case, and every ink file in every project — takes
606/// [`lowered_query`]'s pass-through arm, which used to deep-clone this
607/// query's plain `LoweredFile` (the whole HIR/manifest/diagnostics payload)
608/// into a second salsa memo for zero semantic reason. `Arc::clone` on that
609/// same pass-through arm is a refcount bump instead — this needs no change
610/// at any of this module's many `&lowered_query(..).hir`-shaped call sites
611/// (deref coercion carries a `&Arc<LoweredFile>` through to `.hir` exactly
612/// like a `&LoweredFile` did), only here and at `lowered_query`'s own body.
613#[salsa::tracked(returns(ref), lru = 4096, heap_size = heap_size::lowered_file_heap_size)]
614pub(crate) fn raw_lowered_query(db: &dyn salsa::Database, file: SourceFile) -> Arc<LoweredFile> {
615 let file_id = file.file_id(db);
616 // Decide the frontend from the path *before* touching either parser
617 // (B0.10a, the native compile seam, issue #1106): this branch precedes
618 // the parse call, so an `.ink` file never runs the native parser and a
619 // native file never runs the ink one.
620 //
621 // The ink arm rides the SEGMENT road (#3084): per-knot fragment
622 // parse + lower, memoized per content-keyed segment, assembled with
623 // range rebasing — so a knot-interior edit re-lowers one knot, not
624 // the file. The retired whole-file composition (`lower_file`)
625 // remains in this module as the road's corpus-equality oracle: the
626 // assembled HIR/manifest/admission are byte-identical, diagnostics
627 // multiset-identical (see `assemble_lowered_file`'s doc and the
628 // gate tests in `segments.rs`). Note the segment road deliberately
629 // does NOT read `parse_query` — the analysis path no longer pays a
630 // whole-file parse per keystroke; IDE consumers that want the
631 // whole-file tree still pull `parse_query` themselves.
632 Arc::new(match file_language(file.path(db)) {
633 Language::Ink => segments::assemble_lowered_file(db, file),
634 Language::Native => lower_native_file(file_id, parse_native_query(db, file), None),
635 })
636}
637
638/// Lower one file to HIR — the canonical, project-aware entry point every
639/// consumer besides [`suppressions_query`]/[`external_claim_handlers_query`]
640/// should read (see [`raw_lowered_query`]'s own doc for why those two are
641/// the exception).
642///
643/// For an ink file, or a native file that either IS the project's
644/// configured conventions module or has none configured, this is
645/// byte-identical to [`raw_lowered_query`] (a clone of its cached
646/// `Arc<LoweredFile>` — a refcount bump, no extra lowering work and,
647/// since the #2289 review finding below, no struct copy either). For every
648/// OTHER native file in a project with a conventions module configured,
649/// this re-lowers with [`external_claim_handlers_query`]'s ordered handler
650/// set merged in, so a `claims`-declared handler in that one module claims
651/// prose across the WHOLE project (issue #2289, correcting the file-local
652/// claiming defect the 2026-08-05 ruling names — see `brink_ir::hir::
653/// lower_native::element`'s module doc, "Cross-file claiming reach").
654///
655/// `Arc`-wrapped (issue #2289 review finding): before this, the pass-through
656/// arms below (every ink file, and every native file with no injection to
657/// do — the overwhelming majority of files in the overwhelming majority of
658/// projects) deep-cloned [`raw_lowered_query`]'s entire `LoweredFile`
659/// (HIR + manifest + diagnostics + admission) into a second salsa memo for
660/// zero semantic benefit, and both queries' `heap_size = heap_size::
661/// lowered_file_heap_size` estimator (issues #538/#647) double-counted that
662/// duplicated payload's residency. `Arc<LoweredFile>::clone` on those same
663/// arms is a refcount bump instead — see [`raw_lowered_query`]'s own doc.
664///
665/// `lru = 4096`/`heap_size`: same per-file runaway-guard/estimator posture
666/// as [`raw_lowered_query`] — see that query's own doc.
667#[salsa::tracked(returns(ref), lru = 4096, heap_size = heap_size::lowered_file_heap_size)]
668pub(crate) fn lowered_query(
669 db: &dyn salsa::Database,
670 project: ProjectInput,
671 file: SourceFile,
672) -> Arc<LoweredFile> {
673 let file_id = file.file_id(db);
674 if file_language(file.path(db)) != Language::Native {
675 return Arc::clone(raw_lowered_query(db, file));
676 }
677 let external = external_claim_handlers_query(db, project);
678 match &**external {
679 Some((conventions_file_id, decls)) if *conventions_file_id != file_id => {
680 Arc::new(lower_native_file(
681 file_id,
682 parse_native_query(db, file),
683 Some(decls.as_slice()),
684 ))
685 }
686 // No conventions module configured, or this file IS that module —
687 // either way, nothing to inject; byte-identical to the raw lowering.
688 _ => Arc::clone(raw_lowered_query(db, file)),
689 }
690}
691
692/// Which frontend a source file feeds — decided purely from its path (B0.10a,
693/// issue #1106). Deliberately *not* stored on any input, HIR, or
694/// `AnalysisOptions`: the "no dialect tag near HIR" posture keeps this an
695/// internal, ephemeral classification used only as [`file_language`]'s return.
696/// It is a different axis from `brink_analyzer::Dialect` (an ink-extension
697/// gate) — do not conflate the two.
698#[derive(Debug, Clone, Copy, PartialEq, Eq)]
699pub(crate) enum Language {
700 Ink,
701 Native,
702}
703
704/// Classify a source file's frontend from its path. A pure, deterministic
705/// extension test (`.brink` → native, everything else → ink) — no schema
706/// change, no `HashMap` iteration. Uses `Path::extension` to match the
707/// codebase's existing extension convention (e.g. `brink-lsp`'s `ext ==
708/// "ink"`).
709///
710/// Compared case-insensitively (issue #2329, the case-handling bug flagged
711/// on #2327's review): a real ink file spelled `story.INK` is reachable on
712/// a case-insensitive filesystem (macOS/Windows default) and must classify
713/// identically to `story.ink`, not fall through to the "everything else is
714/// ink" branch by accident of extension casing — which happens to give the
715/// same answer for `Language::Ink` but would have silently misclassified a
716/// `.BRINK` file as ink.
717pub(crate) fn file_language(path: &str) -> Language {
718 if std::path::Path::new(path)
719 .extension()
720 .is_some_and(|ext| ext.eq_ignore_ascii_case("brink"))
721 {
722 Language::Native
723 } else {
724 Language::Ink
725 }
726}
727
728/// Parsed suppression/expectation directives for one file — both channels
729/// merged into the one value every [`apply_suppressions`] call site reads.
730///
731/// The `//brink-disable`/`//brink-expect` comment channel is a pure text
732/// scan ([`parse_suppressions`]). The `@[allow(Exxx, …)]` annotation channel
733/// (issue #1161) rides the real `@[…]` grammar, so its declaration-scoped
734/// records are produced by lowering and picked up here off
735/// [`brink_ir::HirFile::allow_scopes`] — always empty for an ink file, whose
736/// annotation channel has no `allow` tenant. Reads [`raw_lowered_query`],
737/// not the project-aware [`lowered_query`] (issue #2289): `allow_scopes`
738/// is a pure CST scan (`brink_ir::hir::lower_native::annotation::
739/// allow_scopes`), unaffected by which handlers a claiming rewrite ends up
740/// using, so the project-independent lowering is exactly as correct here
741/// and avoids this query depending on project identity at all.
742///
743/// `lru = 4096`: per-file runaway-guard ceiling (issue #647).
744#[salsa::tracked(returns(ref), lru = 4096)]
745pub(crate) fn suppressions_query(db: &dyn salsa::Database, file: SourceFile) -> Suppressions {
746 let mut out = parse_suppressions(file.text(db));
747 out.allow_scopes
748 .clone_from(&raw_lowered_query(db, file).hir.allow_scopes);
749 out
750}
751
752/// The `INCLUDE` graph over the whole project. Always complete — edges are
753/// derived from every file's HIR against the full path set, so the old
754/// "rebuild after batch load" step no longer exists.
755///
756/// Reads [`raw_lowered_query`], not the project-aware [`lowered_query`]
757/// (issue #2289): `hir.includes` is an ink-only structural directive list,
758/// unaffected by claiming injection — and reading the project-aware query
759/// here would close a cycle, since [`external_claim_handlers_query`]
760/// reads [`module_map_query`], which reads this query.
761#[salsa::tracked(returns(ref))]
762pub(crate) fn include_graph_query(db: &dyn salsa::Database, project: ProjectInput) -> IncludeGraph {
763 let files = project.files(db);
764 let path_to_id: LookupMap<&str, FileId> = files
765 .iter()
766 .map(|f| (f.path(db).as_str(), f.file_id(db)))
767 .collect();
768
769 let mut graph = IncludeGraph::new();
770 for file in files {
771 // Issue #2329: a non-source document (`brink.toml`, `.md`, `.json`,
772 // `.ink.json`) never lowers through the ink frontend here — `path_to_id`
773 // above still lists it as a resolution *target* (an ink file could, in
774 // principle, name it in an `INCLUDE`, which is a different diagnostic's
775 // problem), but it never contributes edges of its own.
776 if !is_source_file(file.path(db)) {
777 continue;
778 }
779 let hir = &raw_lowered_query(db, *file).hir;
780 let include_ids: Vec<FileId> = hir
781 .includes
782 .iter()
783 .filter_map(|inc| {
784 let resolved = resolve_include_path(file.path(db), &inc.file_path);
785 path_to_id.get(resolved.as_str()).copied()
786 })
787 .collect();
788 graph.update(file.file_id(db), include_ids);
789 }
790 graph
791}
792
793/// The ordered set of files that participate in codegen for `project`, in
794/// compile (paste-before) order — the single native-vs-ink codegen-closure
795/// decision, shared by every codegen-scoping site
796/// ([`struct_shape_data_query`], [`lir_prelude_decls_query`],
797/// [`lir_lowering_query`], [`lir_in_closure_query`], and
798/// [`has_errors_in_closure_query`](crate::queries::analysis::has_errors_in_closure_query)).
799///
800/// **Ink** projects thread reachability through `INCLUDE`: the closure is
801/// `entry`'s transitive `INCLUDE` closure ([`IncludeGraph::topological_order`],
802/// the issue #815 narrowing every codegen path already used). This is exactly
803/// the previous behavior — a project whose entry is an `.ink` file is
804/// unaffected.
805///
806/// **Native** projects have no `INCLUDE` edges, so the ink closure would reach
807/// only `entry` and every sibling `.brink` module would silently miss codegen
808/// (issue #1296). The decision-log ruling *"Native multi-file linking"*
809/// (2026-07-23) makes the **discovered module set the compilation unit**: the
810/// closure is *every* discovered `.brink` module, ordered by `FileId` — which
811/// `brink_driver::discover_native` mints in sorted-key order, so the order is
812/// deterministic and mount-independent. Consequences that follow directly:
813///
814/// - All discovered modules link into the one `StoryData`; the entry file
815/// still only designates the *start flow* (compilation universe ≠ execution
816/// entry).
817/// - A `.brink` file that fails to compile is an error **even if no other
818/// module references it** — its diagnostics are inside the closure the build
819/// gate reads, so it fails the build (Rust parity: the whole module tree is
820/// the unit).
821///
822/// A project is "native" iff its entry file is a `.brink` module; the closure
823/// then ranges over the `.brink` files only (any stray `.ink` file sharing the
824/// session db is not a discovered native module and never enters it).
825/// Reachability-based dead-module elimination is an explicitly-deferred future
826/// subtraction (decision-log) — this closure is the full discovered set.
827pub(crate) fn compilation_closure_files(
828 db: &dyn salsa::Database,
829 project: ProjectInput,
830) -> Vec<FileId> {
831 let Some(entry) = project.entry(db) else {
832 return Vec::new();
833 };
834 let files = project.files(db);
835 let entry_is_native = files
836 .iter()
837 .find(|f| f.file_id(db) == entry)
838 .is_some_and(|f| file_language(f.path(db)) == Language::Native);
839
840 if entry_is_native {
841 // Every discovered `.brink` module is the compilation unit. Sort by
842 // `FileId` (minted in sorted-key order by `discover_native`) so the
843 // order is deterministic regardless of session-db insertion order.
844 let mut ids: Vec<FileId> = files
845 .iter()
846 .filter(|f| file_language(f.path(db)) == Language::Native)
847 .map(|f| f.file_id(db))
848 .collect();
849 ids.sort_unstable_by_key(|id| id.0);
850 ids
851 } else {
852 include_graph_query(db, project).topological_order(entry)
853 }
854}
855
856/// Whether `path` names a file this db treats as compiler source at all: no
857/// extension at all, or a recognized `.ink`/`.brink` extension (compared
858/// case-insensitively). Everything else — project config (`brink.toml`),
859/// documentation (`.md`), a stray `.txt` note, and the retired converter's/
860/// oracle-regeneration JSON family (`.json`, which also covers `.ink.json` —
861/// `Path::extension` only ever reports the last dotted segment, so
862/// `story.ink.json` already matches on `"json"`) — is not source (issue
863/// #2329).
864///
865/// An **allowlist** of `.ink`/`.brink` plus "no extension", not a blocklist
866/// of known-bad extensions: a blocklist only ever excludes the extensions
867/// it happens to name, so an unlisted one (`.txt`, `.gitignore`, a stray
868/// binary asset) would still lower through the ink frontend — exactly the
869/// bug this issue describes. The "no extension" carve-out matches
870/// [`file_language`]'s own "everything unrecognized is ink" fallback:
871/// `brink_compiler::compile_with_options`'s test/bench callers pass an
872/// in-memory pseudo-path with no extension at all as `entry`, relying on
873/// exactly that fallback, and excluding it here would silently drop those
874/// callers' entry file from parsing/the symbol index.
875///
876/// This is the **one shared predicate** every whole-project query that
877/// iterates `project.files(db)` for parsing/symbol-index/diagnostics
878/// purposes gates on — [`module_map_query`], [`symbol_index_query`],
879/// [`harvest_index_query`], [`include_graph_query`], and the
880/// diagnostics-aggregation family in `queries/analysis.rs`
881/// (`resolutions_index_query`, `contributor_diagnostics_query`,
882/// `inline_docs_query`, `whole_project_diagnostics_query`,
883/// `ufcs_resolution_query`, `coalesce_types_query`,
884/// `analysis_diagnostics_query`, `has_errors_query`,
885/// `per_file_diagnostics_query`, `diagnostics_query`) — rather than each
886/// re-deriving its own ad-hoc extension check (the #2334-family "shared
887/// seam, not N copies" lesson). Queries already scoped to
888/// [`compilation_closure_files`]'s reachable set (e.g.
889/// `struct_shape_data_query`, `lir_lowering_query`,
890/// `has_errors_in_closure_query`) don't need this gate: a non-source
891/// document is never reachable through an `INCLUDE`/native-module edge, so
892/// it can never enter that closure in the first place.
893///
894/// [`project_is_all_native`] deliberately does **not** read this predicate —
895/// see that function's own doc and [`has_recognized_source_extension`] for
896/// why the nativity vote needs a strict allowlist instead.
897pub(crate) fn is_source_file(path: &str) -> bool {
898 match std::path::Path::new(path).extension() {
899 None => true,
900 Some(ext) => ext.eq_ignore_ascii_case("ink") || ext.eq_ignore_ascii_case("brink"),
901 }
902}
903
904/// Whether `path` names a file with a recognized ink (`.ink`) or native
905/// (`.brink`) source extension, compared case-insensitively — used by
906/// [`project_is_all_native`]'s nativity vote, and now (issue #2368) also the
907/// **shared, public seam** `brink-lsp`'s own file-watcher/workspace-scan
908/// classification (`is_source_path` in `crates/brink-lsp/src/backend.rs`)
909/// routes through, rather than carrying its own ad-hoc, case-sensitive `ext
910/// == "ink" || ext == "brink"` copy — the same "shared seam, not N copies"
911/// fix #2329/PR #2357 applied inside this crate's own query surfaces.
912///
913/// Deliberately narrower than [`is_source_file`]: that predicate's "no
914/// extension still counts as source" carve-out exists only to keep
915/// `compile_with_options`'s extension-less in-memory pseudo-path parsing
916/// (a lowering/symbol-index concern), and does not belong in the nativity
917/// vote — an extension-less or otherwise-unrecognized tracked file must be
918/// invisible to "is every source file here native", neither disqualifying
919/// nativity nor counting toward it, exactly as it was before #2329
920/// introduced [`is_source_file`]. The two predicates answer different
921/// questions for the same unrecognized-extension input.
922pub fn has_recognized_source_extension(path: &str) -> bool {
923 std::path::Path::new(path)
924 .extension()
925 .is_some_and(|ext| ext.eq_ignore_ascii_case("brink") || ext.eq_ignore_ascii_case("ink"))
926}
927
928/// Whether `path` names a native (`.brink`) source file, compared
929/// case-insensitively — [`file_language`] narrowed to a boolean, and public
930/// for the same reason [`has_recognized_source_extension`] is (issue
931/// #2368): `brink-lsp` carried two of its own ad-hoc, **case-sensitive**
932/// `ext == "brink"` copies (`crates/brink-lsp/src/backend.rs`'s
933/// `is_native_path` and `crates/brink-lsp/src/backend/projects.rs`'s
934/// function of the same name) for a classification this crate already
935/// performs correctly — a real `.BRINK` file on a case-insensitive
936/// filesystem must classify identically to `.brink`, not silently fall
937/// through to "not native" the way `ext == "brink"` alone would.
938pub fn is_native_source_path(path: &str) -> bool {
939 file_language(path) == Language::Native
940}
941
942/// Whether every *recognized source file* (ink or native) currently tracked
943/// in `project` is a native `.brink` module — `false` for an empty project,
944/// one holding even a single ink source file, or one whose only tracked
945/// files are non-source documents (there is then no native file to be "all"
946/// of).
947///
948/// A tracked file with neither a `.brink` nor an `.ink` extension —
949/// [`has_recognized_source_extension`] is `false` — is invisible to this
950/// check in both directions: it neither disqualifies nativity nor counts
951/// toward it. Before this fix (issue #2318), a project's own `brink.toml`
952/// sharing a session with its native source files — exactly how
953/// `IdeSession`'s editor callers load it, so the Binder can show/edit it —
954/// silently classified as [`Language::Ink`] via [`file_language`]'s
955/// "everything else is ink" fallback and flipped this to `false`, disabling
956/// M-2d cross-declared-module coexistence for a project that was, in every
957/// sense a compile cares about, fully native. The visible symptom was a
958/// self-contradictory pair of diagnostics for any name a project's own
959/// module shared with the mounted stdlib: reported as both a duplicate
960/// definition (the collision fell through to the undeclared/legacy "true
961/// duplicate" arm) and as undeclared outside `use std::…` (the project's own
962/// declaration had just been dropped as that "duplicate").
963///
964/// Reads [`has_recognized_source_extension`], not [`is_source_file`]: the
965/// latter's "no extension still counts as source" contract exists for a
966/// different question (see its own doc) and would wrongly disqualify
967/// nativity for an all-native project holding a stray extension-less or
968/// otherwise-unrecognized tracked file — reachable the same way `brink.toml`
969/// was (`IdeSession`'s editor callers load every discovered path into the
970/// session with no source-vs-config distinction).
971///
972/// [`project_is_native`]'s "entry file decides the frontend" rule is right
973/// for a codegen-shaped question ("which frontend am I compiling"), which
974/// always has an explicit entry (the CLI's compile target). `symbol_index_query`
975/// asks a different question — "does this project have any ink file whose
976/// `dialect` could actually be wrong" — for `ProjectDb`'s single
977/// whole-workspace `ProjectInput`, which a long-lived LSP session never
978/// anchors to an entry at all (`Backend` never calls `ProjectDb::set_entry`;
979/// issue #1562 review finding). A project every one of whose *source* files
980/// is native has no such file, by the same "native has no dialect to be
981/// wrong about" reasoning [`project_is_native`]'s own doc gives —
982/// regardless of whether anything ever called `set_entry`.
983pub(crate) fn project_is_all_native(db: &dyn salsa::Database, project: ProjectInput) -> bool {
984 let files = project.files(db);
985 let mut saw_source = false;
986 for f in files {
987 let path = f.path(db);
988 if !has_recognized_source_extension(path) {
989 continue;
990 }
991 saw_source = true;
992 if file_language(path) != Language::Native {
993 return false;
994 }
995 }
996 saw_source
997}
998
999// ─── Layer 2: project-wide names ─────────────────────────────────────
1000
1001/// Every file's resolved module (M-1, docs/modules-spec.md §1/§5) plus the
1002/// stem-collision diagnostics (`E085`). Extracted as its own memoized query
1003/// (issue #790) so both [`symbol_index_query`] — which qualifies identity by
1004/// declared module — and [`resolve_query`] — which needs each referring
1005/// file's module + imports to scope resolution — share one computation.
1006///
1007/// Undeclared stem-modules (the entire pre-modules corpus) resolve to
1008/// non-qualifying entries, so their `DefinitionId`s stay byte-identical.
1009#[salsa::tracked(returns(ref))]
1010pub(crate) fn module_map_query(
1011 db: &dyn salsa::Database,
1012 project: ProjectInput,
1013) -> (brink_analyzer::ModuleMap, Vec<Diagnostic>) {
1014 let files = project.files(db);
1015
1016 // Native `.brink` files derive their module PURELY from their
1017 // root-relative path (decision-log 2026-07-22), bypassing `resolve_modules`
1018 // entirely — they have no `#@module` inheritance and no INCLUDE graph, so
1019 // routing them through the ink resolver would only couple their save-key
1020 // identity to machinery they never use. Only ink files feed `resolve_modules`.
1021 //
1022 // `is_source_file`, not just `file_language(..) == Ink` (issue #2329):
1023 // `file_language`'s "everything else is ink" fallback previously handed
1024 // a project's own `brink.toml`/`.md`/`.json`/`.ink.json` straight into
1025 // `resolve_modules` as if it were real ink source, minting it a bogus
1026 // stem-derived module entry that could then collide with (or shadow) a
1027 // real file's declared module.
1028 let ink_inputs: Vec<crate::modules::FileModuleInput> = files
1029 .iter()
1030 .filter(|f| is_source_file(f.path(db)) && file_language(f.path(db)) == Language::Ink)
1031 .map(|f| {
1032 // `raw_lowered_query`, not the project-aware `lowered_query`
1033 // (issue #2289): `hir.module` is a structural top-of-file
1034 // directive, unaffected by claiming injection, and reading the
1035 // project-aware query here would close a cycle back through
1036 // `external_claim_handlers_query`, which itself reads this
1037 // very query to resolve the conventions module's file.
1038 let hir_module = raw_lowered_query(db, *f).hir.module.as_ref();
1039 crate::modules::FileModuleInput {
1040 file: f.file_id(db),
1041 stem: crate::modules::file_stem(f.path(db)).to_string(),
1042 declared: hir_module.map(|m| m.name.clone()),
1043 was: hir_module.and_then(|m| m.was.as_ref().map(|(old, _)| old.clone())),
1044 }
1045 })
1046 .collect();
1047 let (mut map, diags) =
1048 crate::modules::resolve_modules(&ink_inputs, include_graph_query(db, project));
1049
1050 // A native file's module NAME is `native_module_path(root-relative key)`,
1051 // marked `declared` so it always qualifies `DefinitionId` (path on disk =
1052 // identity). Only the *name* is path-derived and bypasses the resolver —
1053 // that is the save-key-critical isolation (the name is a pure function of
1054 // the path; `FileId` is only the map key, never hashed, so adding a file
1055 // cannot shift another file's identity).
1056 //
1057 // The rest of the module system is the SAME feature, just path-spelled:
1058 // `was` (rename migration, so a moved file's old saves still resolve) is
1059 // read from the file's own `@[was("old::path")]` annotation via its HIR,
1060 // exactly as the ink path reads `#@was` — never hard-dropped (issue #1286
1061 // wired the native parse/lower; `lower_native::module`). `None` when the
1062 // file authored no `@[was]`.
1063 // The key handed to `native_module_path` is the file's path made
1064 // root-relative to the project's registered `native_root` (issue #1572) —
1065 // a no-op for every compile path (`discover_native` already keys
1066 // root-relative, so `native_root` is `None`), and the normalization that
1067 // makes a long-lived editor session's absolute-path keys mint the *same*
1068 // module identity a real compile of the same tree does.
1069 let native_root = project.native_root(db).as_deref();
1070 for f in files {
1071 if file_language(f.path(db)) == Language::Native {
1072 // `raw_lowered_query` — see the identical note on the ink arm
1073 // above.
1074 let was = raw_lowered_query(db, *f)
1075 .hir
1076 .module
1077 .as_ref()
1078 .and_then(|m| m.was.as_ref().map(|(old, _)| old.clone()));
1079 let key = crate::modules::root_relative_key(native_root, f.path(db));
1080 map.insert(
1081 f.file_id(db),
1082 brink_analyzer::ResolvedModule {
1083 name: crate::modules::native_module_path(&key),
1084 declared: true,
1085 was,
1086 },
1087 );
1088 }
1089 }
1090
1091 (map, diags)
1092}
1093
1094/// The merged project-wide symbol index plus indexing diagnostics
1095/// (duplicates, built-in shadowing). Thin wrapper over
1096/// [`brink_analyzer::symbol_index`].
1097#[salsa::tracked(returns(ref))]
1098pub(crate) fn symbol_index_query(
1099 db: &dyn salsa::Database,
1100 project: ProjectInput,
1101) -> (Arc<SymbolIndex>, Vec<Diagnostic>) {
1102 let files = project.files(db);
1103 // `is_source_file` (issue #2329): a non-source document's manifest never
1104 // joins the project symbol index — see that predicate's own doc for the
1105 // full list of gated query surfaces.
1106 let manifest_refs: Vec<(FileId, &SymbolManifest)> = files
1107 .iter()
1108 .filter(|f| is_source_file(f.path(db)))
1109 .map(|f| (f.file_id(db), &lowered_query(db, project, *f).manifest))
1110 .collect();
1111
1112 let (module_map, module_diags) = module_map_query(db, project);
1113
1114 // M-2c/M-2d (issues #784/#790): the cross-declared-module duplicate
1115 // handling (E096 stopgap → coexistence) is dialect-gated (brink only)
1116 // inside `symbol_index_with_modules` itself, so the project's configured
1117 // dialect must reach it here.
1118 let dialect = project.analysis_options(db).dialect;
1119 // `is_native` (issue #1562 review finding): a native project has no
1120 // dialect to be wrong about — the same reasoning
1121 // `whole_project_diagnostics_query` uses to skip the ink-only `E064`
1122 // config error — so M-2d cross-declared-module coexistence must not
1123 // depend on a client having declared `dialect: "brink"`. Every
1124 // `.brink` file's module is its path and always *declared*, so without
1125 // this a native workspace under the (default) `StrictInk` dialect would
1126 // drop one of two same-name definitions from the index instead of
1127 // letting them coexist.
1128 //
1129 // `project_is_all_native` — the whole-file-set classification, correct
1130 // for a `ProjectInput` a long-lived editor/LSP session never anchors to
1131 // a compile `entry`. (The entry-derived `project_is_native` predicate
1132 // this comment used to contrast with retired when the option-A landing
1133 // moved its last caller, the strict pass, to this same predicate.)
1134 let is_native = project_is_all_native(db, project);
1135 let (index, mut diagnostics) =
1136 brink_analyzer::symbol_index_with_modules(&manifest_refs, module_map, dialect, is_native);
1137 diagnostics.extend(module_diags.clone());
1138 (index, diagnostics)
1139}
1140
1141/// The project-wide harvest index (issue #2114, `docs/prose-dialect-spec.md`
1142/// §5): every `@NAME` cue payload and every inline-markup span kind/
1143/// attribute name, harvested from every file's HIR and upgraded by the
1144/// registered host manifest's `markup` vocabulary — the compiler-side
1145/// sibling of [`symbol_index_query`]. Its dependency set is every file's
1146/// [`lowered_query`] output *plus* `project.analysis_options(db).host_manifest`
1147/// (read below to build the manifest upgrade) — a manifest edit does
1148/// invalidate this memo, unlike a plain per-file prose edit. That still
1149/// gives this query the same per-file early cutoff `symbol_index_query`
1150/// has for the `lowered_query` half: an edit to file A's prose only
1151/// recomputes this merge when file A's own `LoweredFile` output changes,
1152/// not on every keystroke project-wide.
1153///
1154/// Thin wrapper over [`brink_analyzer::harvest`] — see that function's own
1155/// doc, and `crate::db::ProjectDb::harvest_index` for the public surface a
1156/// completion consumer calls.
1157#[salsa::tracked(returns(ref))]
1158pub(crate) fn harvest_index_query(
1159 db: &dyn salsa::Database,
1160 project: ProjectInput,
1161) -> Arc<HarvestIndex> {
1162 let files = project.files(db);
1163 // `is_source_file` (issue #2329): a non-source document's HIR never
1164 // contributes cues/markup to the harvest index.
1165 let hir_refs: Vec<(FileId, &HirFile)> = files
1166 .iter()
1167 .filter(|f| is_source_file(f.path(db)))
1168 .map(|f| (f.file_id(db), &lowered_query(db, project, *f).hir))
1169 .collect();
1170 let manifest = project.analysis_options(db).host_manifest.as_ref();
1171 Arc::new(brink_analyzer::harvest(&hir_refs, manifest))
1172}
1173
1174/// The early-cutoff projection of [`harvest_index_query`] a completion
1175/// consumer reads (issue #2134): every cue and span/attribute *name*, with
1176/// every [`brink_analyzer::HarvestSite`]'s `TextRange` dropped.
1177///
1178/// [`HarvestIndex`] can never `Eq`-cutoff on its own — its sites carry real
1179/// ranges, so nearly any edit changes its output — the exact gap that
1180/// forced [`resolution_index_query`] to exist for the symbol index (see
1181/// this module's own doc, "The `resolution_index` cutoff seam"). This query
1182/// is the harvest index's sibling of that seam: a prose edit that adds no
1183/// cue/span/attribute *name* anywhere makes **this** query's own output
1184/// `Eq`-identical to before, so a memoized reader of *this* projection can
1185/// backdate across it.
1186///
1187/// **Correction (review finding on #2134):** this query still calls
1188/// [`harvest_index_query`] directly above, so *this* memo's own body still
1189/// re-runs the whole-project harvest merge on every edit that changes any
1190/// file's `lowered_query` output — same as before this projection existed.
1191/// It does not skip that merge, and does not claim to. What it buys is
1192/// downstream: any *memoized* consumer that reads `harvest_completion_names`
1193/// (rather than `harvest_index` directly) sees an `Eq`-stable value across a
1194/// pure range-shifting edit and can backdate its own memo on it, the same
1195/// benefit `resolution_index_query` gives `resolve_query`. No such memoized
1196/// downstream consumer exists today — both `brink-lsp`'s `completion`
1197/// handler and `brink-web`'s `EditorSession::completions` read
1198/// `harvest_completion_names()` directly, per request, with nothing between
1199/// them and this query to backdate — so the measured present-day
1200/// incrementality delta is zero. The seam is built for the next memoized
1201/// consumer, not a win realized yet.
1202#[salsa::tracked(returns(ref))]
1203pub(crate) fn harvest_completion_index_query(
1204 db: &dyn salsa::Database,
1205 project: ProjectInput,
1206) -> Arc<HarvestNames> {
1207 let index = harvest_index_query(db, project);
1208 Arc::new(index.names())
1209}
1210
1211/// The early-cutoff projection of the symbol index used by resolution:
1212/// declarations only (locals dropped entirely — issue #517), ranges zeroed
1213/// for every remaining symbol (see module docs). Neither a body edit that
1214/// shifts a global declaration's
1215/// range nor one that adds/removes a `~ temp`/param anywhere in the project
1216/// changes this output, so every file's `resolve` memo survives untouched.
1217///
1218/// Locals are dropped rather than range-zeroed like the rest: a `Param`/
1219/// `Temp` entry's *identity* (not just its range) changes when a body edit
1220/// adds or removes a local, so zeroing its range alone would not have
1221/// stopped the churn (finding 1). Resolution never needs locals from this
1222/// projection — [`resolve_query`] feeds `lookup_local_in_scope` the
1223/// declaring file's own per-file `manifest.locals` instead (a knot's body
1224/// lives in exactly one file, so cross-file local lookup was never
1225/// semantically required — see `brink_analyzer::resolve::lookup_local_in_scope`).
1226#[salsa::tracked(returns(ref))]
1227pub(crate) fn resolution_index_query(
1228 db: &dyn salsa::Database,
1229 project: ProjectInput,
1230) -> Arc<SymbolIndex> {
1231 let (index, _diags) = symbol_index_query(db, project);
1232 let mut stripped: SymbolIndex = (**index).clone();
1233 stripped
1234 .symbols
1235 .retain(|_, info| !matches!(info.kind, SymbolKind::Param | SymbolKind::Temp));
1236 let live_ids: LookupSet<DefinitionId> = stripped.symbols.keys().copied().collect();
1237 stripped.by_name.retain(|_, ids| {
1238 ids.retain(|id| live_ids.contains(id));
1239 !ids.is_empty()
1240 });
1241 for info in stripped.symbols.values_mut() {
1242 info.range = rowan::TextRange::default();
1243 }
1244 Arc::new(stripped)
1245}
1246
1247/// One file's [`ImportScope`] (issue #2272 review finding): the single
1248/// derivation [`resolve_query`] and [`analysis::per_file_diagnostics_query`]
1249/// now both call, so "the declared-module import scope for this file" has
1250/// exactly one answer instead of two independently-maintained copies of the
1251/// same four lines that could silently diverge — the exact failure mode
1252/// #2272's own gate run spent effort fixing. `ImportScope` carries no
1253/// `TextRange` (see its own doc — names only), so this query's derived
1254/// `PartialEq`/`Eq` still lets a range-only edit elsewhere backdate for every
1255/// caller, the same cutoff shape [`resolution_index_query`] already relies
1256/// on; a caller reading [`module_map_query`] directly instead (as
1257/// `per_file_diagnostics_query` briefly did before this extraction) would
1258/// pick up its range-bearing module diagnostics too, re-executing on any
1259/// range shift project-wide — the whole-project churn FG-3 exists to
1260/// eliminate.
1261///
1262/// `lru = 4096`: per-file runaway-guard ceiling (issue #647).
1263#[salsa::tracked(returns(ref), lru = 4096)]
1264pub(crate) fn file_import_scope_query(
1265 db: &dyn salsa::Database,
1266 project: ProjectInput,
1267 file: SourceFile,
1268) -> ImportScope {
1269 // `file_module` comes from the shared module map (declared modules
1270 // only — INCLUDE inheritance already applied), matching how
1271 // `symbol_index_query` qualified identity.
1272 let (module_map, _module_diags) = module_map_query(db, project);
1273 let file_module = module_map
1274 .get(&file.file_id(db))
1275 .filter(|m| m.declared)
1276 .map(|m| m.name.clone());
1277 let hir = &lowered_query(db, project, file).hir;
1278 ImportScope::new(file_module, &hir.imports)
1279}
1280
1281/// Resolve one file's references against the project-wide names. Thin
1282/// wrapper over [`brink_analyzer::resolve`], fed the decls-only cutoff
1283/// projection for globals and this file's own `manifest.locals` for
1284/// param/temp lookups — the per-file dependency edge that lets a `~ temp`
1285/// edit in file Y leave file X's memo untouched (issue #517).
1286///
1287/// `lru = 4096`: per-file runaway-guard ceiling (issue #647).
1288#[salsa::tracked(returns(ref), lru = 4096)]
1289pub(crate) fn resolve_query(
1290 db: &dyn salsa::Database,
1291 project: ProjectInput,
1292 file: SourceFile,
1293) -> (Arc<ResolutionMap>, Vec<Diagnostic>) {
1294 let index = resolution_index_query(db, project);
1295 let lowered = lowered_query(db, project, file);
1296
1297 // Import-scoped resolution (M-2d, docs/modules-spec.md §2; issue #790):
1298 // feed the resolver this file's own **declared** module and its `IMPORT`
1299 // list so a bare reference with same-name candidates across declared
1300 // modules binds to the one this file imported. The scope is inert for
1301 // the pre-modules / single-module world (no declared module qualifies
1302 // identity, so every candidate carries `module: None` and the resolver's
1303 // fast path is byte-identical). Shared with `per_file_diagnostics_query`
1304 // via `file_import_scope_query` (issue #2272 review finding) so the two
1305 // consult the literal same `ImportScope` object per file.
1306 let scope = file_import_scope_query(db, project, file);
1307
1308 brink_analyzer::resolve(file.file_id(db), &lowered.manifest, index, scope)
1309}
1310
1311/// Interned key for [`signature_query`] and [`local_signature_query`].
1312/// Keyed on the content-addressed [`DefinitionId`] alone: colliding ids
1313/// among non-local declarations (duplicate names across files) map to a
1314/// *single* index entry chosen deterministically by the merge, so the memo
1315/// cannot diverge from what a non-memoized `signature(def)` call would
1316/// return for the same id. For `signature_query`, local (`Param`/`Temp`)
1317/// ids no longer collide across files in a way that matters here —
1318/// [`resolution_index_query`] drops locals entirely (issue #517). A local's
1319/// `DefinitionId` itself carries no file component, so a colliding id
1320/// *would* matter for [`local_signature_query`] — that query disambiguates
1321/// by taking its own explicit `file` parameter alongside this same `DefKey`
1322/// (issue #530), rather than relying on uniqueness of the id alone.
1323#[salsa::interned]
1324pub(crate) struct DefKey<'db> {
1325 pub def: DefinitionId,
1326}
1327
1328/// Per-declaration signature stub (spec §4 layer 2). Reads the decls-only,
1329/// range-stripped index projection — [`Sig`] carries no ranges, so this is
1330/// output-identical to reading the full index for declarations, while
1331/// backdating across whitespace/body edits. Locals are not addressable here
1332/// (returns `None` for a `Param`/`Temp` [`DefinitionId`], issue #517):
1333/// resolving one would require scanning every file's `manifest.locals` to
1334/// find the declaring file, reintroducing the project-wide invalidation this
1335/// projection exists to avoid. Locals stay permanently non-addressable via
1336/// this query — see [`local_signature_query`] for the per-file path hover
1337/// now uses instead (issue #530).
1338///
1339/// **Declaring-file dependency only (issue #630 / FG-1 §2.1).**
1340/// `brink_analyzer::signature` reads only the declaring file's HIR (looked
1341/// up by `SymbolInfo.file`, known from the index) — this query used to build
1342/// `hir_refs` over *every* project file before calling it, so salsa recorded
1343/// a read-edge on every file's `lowered_query` regardless, and a body edit
1344/// in any file re-ran every signature memo. Filtering `project.files(db)`
1345/// down to the one matching `SourceFile` before calling `lowered_query`
1346/// means the only per-file dependency recorded is the declaring file's own —
1347/// a body edit elsewhere in the project no longer invalidates this memo.
1348///
1349/// **Manifest dependency (T1d-2b, issue #774, docs/t1d-spec.md §3).** Also
1350/// reads `project.analysis_options(db).host_manifest` so `Handle<K>`
1351/// annotations resolve to `Ty::Handle(K)` here — the registered manifest is
1352/// project-wide, host-set config, not derived from any file's edits, so
1353/// reading it is the same coarse dependency shape `per_file_diagnostics_query`
1354/// already reads `host_manifest` at, not a reintroduction of whole-project
1355/// per-file churn.
1356///
1357/// `lru = 16384`: per-def runaway-guard ceiling (issue #647, decision log
1358/// "FG-5 memory bounding" — #537's data showed this family scales with
1359/// live project defs, never with session length, so the ceiling is sized
1360/// far above realistic project scale and never evicts in steady state).
1361/// `heap_size = heap_size::signature_heap_size`: one of the five #538
1362/// estimators — #537 named `signature` the widest-fanout per-def memo.
1363#[salsa::tracked(lru = 16384, heap_size = heap_size::signature_heap_size)]
1364pub(crate) fn signature_query<'db>(
1365 db: &'db dyn salsa::Database,
1366 project: ProjectInput,
1367 def: DefKey<'db>,
1368) -> Option<Arc<Sig>> {
1369 let index = resolution_index_query(db, project);
1370 let def_id = def.def(db);
1371 let declaring_file = index.symbols.get(&def_id)?.file;
1372 let hir_refs: Vec<(FileId, &HirFile)> = project
1373 .files(db)
1374 .iter()
1375 .filter(|f| f.file_id(db) == declaring_file)
1376 .map(|f| (f.file_id(db), &lowered_query(db, project, *f).hir))
1377 .collect();
1378 let opts = project.analysis_options(db);
1379 brink_analyzer::signature(def_id, index, &hir_refs, opts.host_manifest.as_ref())
1380}
1381
1382/// The per-file locals path [`signature_query`] itself cannot take (issue
1383/// #530): [`resolution_index_query`] drops `Param`/`Temp` locals entirely
1384/// (issue #517), so `signature_query(def)` short-circuits to `None` for
1385/// any local `DefinitionId` — a silent "hover shows nothing" trap for
1386/// whoever wires hover/signature to a local next. A local's `DefinitionId`
1387/// carries no file component (content hash of `(scope, name, kind)` alone —
1388/// `brink_analyzer::local_signature`'s doc), so unlike [`signature_query`]
1389/// it cannot recover its declaring file from the project-wide index without
1390/// either a whole-project scan (reintroducing exactly the invalidation
1391/// #517's cutoff exists to kill) or a caller-supplied file. This query
1392/// takes `file` explicitly instead — the same per-file-only shape
1393/// [`resolve_query`] already uses for local lookups (a local's body lives
1394/// in exactly one file, issue #517) — so a body edit in a *different* file
1395/// leaves this memo untouched.
1396///
1397/// Per #531 (converge `symbol_index_query` to decls-only): this is
1398/// deliberately a *separate* query, not a widening of `signature_query`'s
1399/// own index read — it serves locals without merging the decls-only and
1400/// full indexes back together.
1401///
1402/// `lru = 4096`: per-(file, def) runaway-guard ceiling (issue #647,
1403/// decision log "FG-5 memory bounding"), matching the other per-file
1404/// families' ceiling — a `Sig` is small and this query reads only its own
1405/// file's `manifest.locals`, so it carries none of `signature_query`'s
1406/// wider per-def fanout. `heap_size = heap_size::signature_heap_size`
1407/// (issue #538/#530): the output is the identical `Option<Arc<Sig>>` shape
1408/// `signature_query` already estimates, so the same walk is reused rather
1409/// than duplicated — see `heap_size.rs`'s module doc.
1410#[salsa::tracked(lru = 4096, heap_size = heap_size::signature_heap_size)]
1411pub(crate) fn local_signature_query<'db>(
1412 db: &'db dyn salsa::Database,
1413 project: ProjectInput,
1414 file: SourceFile,
1415 def: DefKey<'db>,
1416) -> Option<Arc<Sig>> {
1417 let index = resolution_index_query(db, project);
1418 let manifest = &lowered_query(db, project, file).manifest;
1419 let opts = project.analysis_options(db);
1420 brink_analyzer::local_signature(def.def(db), manifest, index, opts.host_manifest.as_ref())
1421}
1422
1423// ─── Layer 2/3: type inference (TM-1, advisory-only) ──────────────────
1424//
1425// The checker substrate (typed-mode-spec §2/§9 step 1): `signature`/
1426// `infer_body`/`type_diagnostics`. **Advisory-only** — nothing here changes
1427// compiler output; `type_inference_query` is not read by `lir_query` or
1428// `story_data_query`, so it is lazy by construction (computed only when a
1429// consumer calls `infer_body`/`type_diagnostics`, which today is nobody —
1430// see the PR's warm/cold benchmark report). Whole-project, like
1431// `analysis_query`/`lir_query` in this same slice (scripting-substrate spec
1432// §7 defers per-container splitting to slice C); `infer_body_query` and
1433// `type_diagnostics_query` are thin per-def/per-file views over the one
1434// project-wide memo, mirroring `signature_query`'s and `diagnostics_query`'s
1435// own shape.
1436
1437/// The cutoff projection of the symbol index feeding whole-project type
1438/// inference (issue #630 / FG-1 §3): every symbol — declarations *and*
1439/// locals (`Param`/`Temp`) — with ranges zeroed.
1440///
1441/// Unlike [`resolution_index_query`] (name *resolution*'s projection, which
1442/// drops locals entirely — issue #517, because a local's identity, not just
1443/// its range, changes when a `~ temp` is added/removed elsewhere), inference
1444/// reads `index.symbols.get(def)` only for already-*resolved* ids
1445/// (`brink_analyzer::infer::body::ty_of_def`/`observe`/`infer_list_literal`)
1446/// to recover a local's `kind`/`name` — it never resolves a name against
1447/// this index, so dropping locals here would silently make every local
1448/// reference type as `Unknown`. Neither this query nor
1449/// [`brink_analyzer::signature`] (called for globals via `infer/mod.rs`'s
1450/// `collect_globals`) ever reads a symbol's range, so zeroing it is safe and
1451/// backdates this projection across any edit that adds/removes no
1452/// declaration or local.
1453#[salsa::tracked(returns(ref))]
1454pub(crate) fn inference_index_query(
1455 db: &dyn salsa::Database,
1456 project: ProjectInput,
1457) -> Arc<SymbolIndex> {
1458 let (index, _diags) = symbol_index_query(db, project);
1459 let mut stripped: SymbolIndex = (**index).clone();
1460 for info in stripped.symbols.values_mut() {
1461 info.range = rowan::TextRange::default();
1462 }
1463 Arc::new(stripped)
1464}
1465
1466/// A strongly-connected component's stable identifier (FG-2, issue #631):
1467/// the component's minimum-valued `DefinitionId` member, exactly
1468/// [`brink_analyzer::scc_graph`]'s own sort/dedup key. A plain alias, not a
1469/// fresh newtype — it *is* a real member `DefinitionId`, reused as the
1470/// component's name, so the existing [`DefKey`] interning already covers it:
1471/// [`solve_scc_query`]'s key is `DefKey::new(db, scc_id)`, the same
1472/// interning [`signature_query`]/[`infer_body_query`] use for a definition's
1473/// own id.
1474pub(crate) type SccId = DefinitionId;
1475
1476/// The project's inferable (knot/stitch) def ids, sourced from the index
1477/// alone (FG-2.1, issue #638, Ruling 2b — `inferable_defs_query`, the
1478/// `inference_index_query` precedent applied to the "which defs have a
1479/// body" question). No HIR read: `call_graph_query`'s per-def loop and
1480/// [`call_edges_query`]/[`referenced_globals_query`]'s `inferable`
1481/// membership check both read this instead of walking every project file's
1482/// HIR just to enumerate ids — a body edit that adds/removes no
1483/// knot/stitch declaration leaves this memo's *dependency edge* untouched
1484/// (it only reads `inference_index_query`, never `lowered_query`).
1485#[salsa::tracked(returns(ref))]
1486pub(crate) fn inferable_defs_query(
1487 db: &dyn salsa::Database,
1488 project: ProjectInput,
1489) -> BTreeSet<DefinitionId> {
1490 let index = inference_index_query(db, project);
1491 brink_analyzer::inferable_defs_from_index(index)
1492}
1493
1494/// One inferable def's own params + body, read from its declaring file's
1495/// HIR alone (FG-2.1, issue #638, Ruling 2b — `def_body_query(def)`, the
1496/// per-def HIR projection `solve_scc_query` reads instead of every
1497/// project file's `lowered_query`). `Arc<plain>`, `Eq`-derived so an
1498/// edit to a *different* def in the same declaring file — which still
1499/// changes that file's `lowered_query` output — backdates here as long as
1500/// this specific def's own params/body come out byte-identical.
1501#[derive(Debug, Clone, PartialEq, Eq)]
1502pub(crate) struct DefBody {
1503 pub file: FileId,
1504 pub params: Vec<brink_ir::Param>,
1505 /// The knot's `): type ===` return annotation, if any (T1c — feeds the
1506 /// annotation-firewall overlay in `infer_def_body`).
1507 pub return_annotation: Option<brink_ir::TypeExpr>,
1508 pub body: brink_ir::Block,
1509 /// Which frontend produced the declaring file (`HirFile::native`, issue
1510 /// #1862). Projected per def because this narrowed projection is all
1511 /// `solve_scc_query` holds — it never sees the whole `HirFile` — and
1512 /// `brink_analyzer::Def::native` needs it for the native bare-name
1513 /// fn-value typing rule (issue #1876).
1514 pub native: bool,
1515}
1516
1517/// `lru = 16384`: per-def runaway-guard ceiling (issue #647). `heap_size =
1518/// heap_size::def_body_heap_size`: one of the five #538 estimators — #537
1519/// named `def_body` (holds a full HIR `Block` clone per def) one of the
1520/// two dominant Arc-hidden-payload families.
1521#[salsa::tracked(lru = 16384, heap_size = heap_size::def_body_heap_size)]
1522pub(crate) fn def_body_query<'db>(
1523 db: &'db dyn salsa::Database,
1524 project: ProjectInput,
1525 def: DefKey<'db>,
1526) -> Option<Arc<DefBody>> {
1527 let index = inference_index_query(db, project);
1528 let def_id = def.def(db);
1529 let declaring_file = index.symbols.get(&def_id)?.file;
1530 let file = project
1531 .files(db)
1532 .iter()
1533 .find(|f| f.file_id(db) == declaring_file)?;
1534 let hir = &lowered_query(db, project, *file).hir;
1535 let (params, return_annotation, body) =
1536 brink_analyzer::def_body(def_id, &[(declaring_file, hir)], index)?;
1537 Some(Arc::new(DefBody {
1538 file: declaring_file,
1539 params,
1540 return_annotation,
1541 body,
1542 native: hir.native,
1543 }))
1544}
1545
1546/// The VAR/CONST global ids one def's body references (FG-2.1, issue #638,
1547/// Ruling 1 — `referenced_globals_query(def)`, the pre-scan `solve_scc_query`
1548/// resolves into a narrow `BodyCtx.globals` map via `signature_query`,
1549/// exactly the same declaring-file-only dependency edge
1550/// [`def_body_query`] uses). Also the per-def global *read set* a future T2
1551/// effect row needs — see `brink_analyzer::referenced_globals`'s docs.
1552///
1553/// Passes `None` for `brink_analyzer::referenced_globals`'s manifest
1554/// parameter (T1d-2b, issue #774) deliberately: this pass discards every
1555/// computed type (only the referenced-def-id *set* survives), so a
1556/// registered manifest can never change its output — reading
1557/// `project.analysis_options(db)` here would only add a needless
1558/// project-wide invalidation edge to the FG-2.1 narrow per-def dependency
1559/// this query exists to keep narrow, for zero behavioral benefit.
1560///
1561/// `lru = 16384`: per-def runaway-guard ceiling (issue #647).
1562#[salsa::tracked(lru = 16384)]
1563pub(crate) fn referenced_globals_query<'db>(
1564 db: &'db dyn salsa::Database,
1565 project: ProjectInput,
1566 def: DefKey<'db>,
1567) -> Arc<BTreeSet<DefinitionId>> {
1568 let index = inference_index_query(db, project);
1569 let def_id = def.def(db);
1570 let Some(declaring_file) = index.symbols.get(&def_id).map(|info| info.file) else {
1571 return Arc::new(BTreeSet::new());
1572 };
1573 let Some(file) = project
1574 .files(db)
1575 .iter()
1576 .find(|f| f.file_id(db) == declaring_file)
1577 else {
1578 return Arc::new(BTreeSet::new());
1579 };
1580 let hir = &lowered_query(db, project, *file).hir;
1581 let (resolutions, _diags) = resolve_query(db, project, *file);
1582 Arc::new(brink_analyzer::referenced_globals(
1583 def_id,
1584 &[(declaring_file, hir)],
1585 index,
1586 resolutions,
1587 None,
1588 ))
1589}
1590
1591/// Pass 1, per-def (FG-2, issue #631 — `call_edges(def)`). Thin salsa
1592/// wrapper over [`brink_analyzer::call_edges`]; the per-def key gives Eq
1593/// cutoff on this def's own edge set (`BTreeSet<DefinitionId>`, no ranges,
1594/// derived `Eq`) — see the design doc §2 table's explicit allowance to keep
1595/// reusing `infer_def_body` and discard types, as `infer_project` already
1596/// did, for this pass's computation.
1597///
1598/// **Narrowed inputs (FG-2.1, issue #638, Ruling 2a).** Reads only `def`'s
1599/// own declaring file's `lowered_query`/`resolve_query` — never every
1600/// project file's — plus the index-sourced [`inferable_defs_query`]. No
1601/// globals map at all (pass 1 discards every computed type; see
1602/// `brink_analyzer::call_edges`'s docs).
1603///
1604/// Passes `None` for `brink_analyzer::call_edges`'s manifest parameter
1605/// (T1d-2b, issue #774) — same rationale as [`referenced_globals_query`]:
1606/// pass 1 discards every computed type, so the manifest can never change
1607/// this query's output, and reading `project.analysis_options(db)` here
1608/// would only widen this per-def query's dependency edge for no benefit.
1609///
1610/// `lru = 16384`: per-def runaway-guard ceiling (issue #647).
1611#[salsa::tracked(lru = 16384)]
1612pub(crate) fn call_edges_query<'db>(
1613 db: &'db dyn salsa::Database,
1614 project: ProjectInput,
1615 def: DefKey<'db>,
1616) -> Arc<BTreeSet<DefinitionId>> {
1617 let index = inference_index_query(db, project);
1618 let def_id = def.def(db);
1619 let Some(declaring_file) = index.symbols.get(&def_id).map(|info| info.file) else {
1620 return Arc::new(BTreeSet::new());
1621 };
1622 let Some(file) = project
1623 .files(db)
1624 .iter()
1625 .find(|f| f.file_id(db) == declaring_file)
1626 else {
1627 return Arc::new(BTreeSet::new());
1628 };
1629 let hir = &lowered_query(db, project, *file).hir;
1630 let (resolutions, _diags) = resolve_query(db, project, *file);
1631 let inferable = inferable_defs_query(db, project);
1632 Arc::new(brink_analyzer::call_edges(
1633 def_id,
1634 &[(declaring_file, hir)],
1635 index,
1636 resolutions,
1637 inferable,
1638 None,
1639 ))
1640}
1641
1642/// The whole-project call graph, merged from every inferable def's
1643/// [`call_edges_query`] (FG-2, issue #631 — the derived `call_graph()` the
1644/// design doc's §2 table names). [`CallGraph`]'s `Eq` (added for this slice)
1645/// is the cutoff [`scc_membership_query`] backdates on.
1646///
1647/// Inherently project-wide (FG-2.1, issue #638, Ruling 2c: `call_graph_query`
1648/// is one of the two queries that "genuinely need project shape") — it must
1649/// enumerate every inferable def to build the graph. What FG-2.1 narrows is
1650/// each *individual* read inside the loop: [`inferable_defs_query`] is
1651/// index-only (no HIR), and each [`call_edges_query`] call is validated
1652/// without re-executing unless *that specific def's* declaring file changed
1653/// — so an edit in file X only pays for X's own defs, even though this
1654/// query's own closure still walks the whole project's def list every time
1655/// it *does* run.
1656#[salsa::tracked(returns(ref))]
1657pub(crate) fn call_graph_query(db: &dyn salsa::Database, project: ProjectInput) -> CallGraph {
1658 let defs = inferable_defs_query(db, project);
1659 let mut graph = CallGraph::new();
1660 for &def in defs {
1661 graph.add_node(def);
1662 let edges = call_edges_query(db, project, DefKey::new(db, def));
1663 for &callee in edges.iter() {
1664 graph.add_edge(def, callee);
1665 }
1666 }
1667 graph
1668}
1669
1670/// SCC partition + condensation DAG over the whole project's call graph
1671/// (FG-2, issue #631 — `scc_membership()` (+ topo order)). Thin salsa
1672/// wrapper over [`brink_analyzer::scc_graph`]. The other query Ruling 2c
1673/// keeps project-wide — SCC membership is inherently a global graph
1674/// property, not narrowable per-def.
1675#[salsa::tracked(returns(ref))]
1676pub(crate) fn scc_membership_query(db: &dyn salsa::Database, project: ProjectInput) -> SccGraph {
1677 let graph = call_graph_query(db, project);
1678 brink_analyzer::scc_graph(graph)
1679}
1680
1681/// One SCC's finalized inference result: signatures for the SCC's own
1682/// members plus their full body-type pictures. `Arc<plain>`, `Eq`-derived —
1683/// the per-SCC cutoff [`inferred_signature_query`]/[`infer_body_query`]
1684/// backdate on (Arc<plain> ruling, design doc §2 Fork 2).
1685///
1686/// **Does not carry `EXTERNAL` signatures (issue #1921).** `batch` never
1687/// contains an `EXTERNAL` (see [`brink_analyzer::solve_scc`]'s own doc), so
1688/// `signatures` here is scoped to the SCC's own knot/stitch members, same as
1689/// before #1921. [`type_inference_query`] is where every `EXTERNAL`'s
1690/// declaration-derived signature is merged in — once, at the aggregation,
1691/// not once per SCC — so it agrees with the pure whole-project
1692/// `infer_project` path without every `solve_scc_query` memo paying to
1693/// clone the project's whole external-signature map (that per-SCC
1694/// duplication would also make every memo's *value* depend on the host
1695/// manifest, which would cost `solve_scc_query`/`inferred_signature_query`/
1696/// `infer_body_query` the FG-2 cutoff `fg1_dependency_edges.rs` pins even
1697/// for an SCC that never calls an external).
1698#[derive(Debug, Clone, Default, PartialEq, Eq)]
1699pub(crate) struct SolvedScc {
1700 pub signatures: BTreeMap<DefinitionId, brink_analyzer::InferredSig>,
1701 pub bodies: BTreeMap<DefinitionId, brink_analyzer::BodyTypes>,
1702}
1703
1704/// Pass 2, per-SCC (FG-2, issue #631 — `solve_scc(SccId)`). Reads
1705/// `solve_scc_query` for every condensation predecessor first (recursion is
1706/// acyclic by construction — the condensation is a DAG, Fork 1 ruling — so
1707/// salsa never sees a cycle here), merges their finalized signatures into
1708/// `known_sigs`, then runs [`brink_analyzer::solve_scc`]'s bounded fixpoint
1709/// (plain Rust inside this one query execution) for exactly this
1710/// component's own members. Returns an empty [`SolvedScc`] for an id that
1711/// isn't any component's minimum member (defensive — never panics on a
1712/// stale/unknown key).
1713///
1714/// **Full narrowing (FG-2.1, issue #638, Ruling 2b + Ruling 1).** HIR:
1715/// [`def_body_query`] per member — only this batch's own declaring files,
1716/// never every project file's. Resolutions: only those same declaring
1717/// files' `resolve_query` results (sufficient to resolve every `Path` range
1718/// inside a member's own body, cross-file targets included — see
1719/// `signature_query`'s docs on why resolution reads the *source* file's map
1720/// only). Globals: the narrow map built from every member's
1721/// [`referenced_globals_query`] pre-scan, each id resolved through the
1722/// existing per-declaring-file [`signature_query`] (never a whole-project
1723/// globals scan). `inferable`: the same index-sourced
1724/// [`inferable_defs_query`] `call_edges_query` uses.
1725///
1726/// **Manifest dependency (T1d-2b, issue #774, docs/t1d-spec.md §3).** Also
1727/// reads `project.analysis_options(db).host_manifest`, threaded to
1728/// [`brink_analyzer::solve_scc`] so a `Handle<K>` param/return/temp
1729/// annotation resolves to `Ty::Handle(K)` during the per-SCC body-uses
1730/// solve — the seam that makes strict-mode handle-kind rejection reachable
1731/// end-to-end (the #767 acceptance criterion): once two locals of
1732/// different declared handle kinds are unified together, the #627 lattice
1733/// folds them to `Ty::Conflicted`, and `strict::check`'s pre-existing
1734/// `E066` classification reports it — this query is what was missing to
1735/// let a genuine `Ty::Handle` ever reach that lattice from body-usage
1736/// inference through the salsa-memoized pipeline. Same coarse project-wide
1737/// dependency shape [`signature_query`]/`per_file_diagnostics_query`
1738/// already read `host_manifest` at — unlike [`call_edges_query`]/
1739/// [`referenced_globals_query`] (whose *outputs* the manifest can never
1740/// change), this query's output genuinely depends on it.
1741///
1742/// **`inline_docs` dependency (issue #805).** Also reads [`inline_docs_query`]
1743/// — the same project-wide merged `///` doc-comment memo [`external_meta_query`]
1744/// already reads — and threads it to [`brink_analyzer::solve_scc`] so an
1745/// `EXTERNAL` documented purely inline (no matching registered
1746/// `ManifestExternal`) now seeds a `known_sigs` entry too, and so a
1747/// registered/inline param or return type naming a *scalar* semantic type
1748/// (not just a `Handle<K>` kind) resolves to its own base `Ty`. Range-free
1749/// (`DocBlock` carries no source spans), so this doesn't reintroduce the
1750/// whole-project-HIR churn FG-2/FG-2.1 eliminated — a doc-content-preserving
1751/// edit backdates through `inline_docs_query`'s own `Eq` cutoff exactly like
1752/// every other reader of that memo.
1753///
1754/// `lru = 16384`: per-def (per-SCC) runaway-guard ceiling (issue #647).
1755/// `heap_size = heap_size::solve_scc_heap_size`: one of the five #538
1756/// estimators — #537 named `solve_scc` (holds signatures+bodies per SCC)
1757/// the other dominant Arc-hidden-payload family alongside `def_body`.
1758#[salsa::tracked(lru = 16384, heap_size = heap_size::solve_scc_heap_size)]
1759pub(crate) fn solve_scc_query<'db>(
1760 db: &'db dyn salsa::Database,
1761 project: ProjectInput,
1762 scc: DefKey<'db>,
1763) -> Arc<SolvedScc> {
1764 let scc_id: SccId = scc.def(db);
1765 let membership = scc_membership_query(db, project);
1766 let Some(batch) = membership
1767 .order
1768 .iter()
1769 .find(|comp| comp.iter().next().copied() == Some(scc_id))
1770 else {
1771 return Arc::new(SolvedScc::default());
1772 };
1773
1774 let mut known_sigs: BTreeMap<DefinitionId, brink_analyzer::InferredSig> = BTreeMap::new();
1775 if let Some(deps) = membership.depends_on.get(&scc_id) {
1776 for &dep in deps {
1777 let solved = solve_scc_query(db, project, DefKey::new(db, dep));
1778 known_sigs.extend(solved.signatures.iter().map(|(k, v)| (*k, v.clone())));
1779 }
1780 }
1781
1782 let index = inference_index_query(db, project);
1783 let inferable = inferable_defs_query(db, project);
1784
1785 // Per-def HIR projection (Ruling 2b): only this batch's own members'
1786 // bodies. `member_bodies` keeps the owned `Arc<DefBody>`s alive for the
1787 // `Def` borrows built from them below.
1788 let member_bodies: BTreeMap<DefinitionId, Arc<DefBody>> = batch
1789 .iter()
1790 .filter_map(|&id| def_body_query(db, project, DefKey::new(db, id)).map(|b| (id, b)))
1791 .collect();
1792 let defs: Vec<brink_analyzer::Def<'_>> = member_bodies
1793 .iter()
1794 .map(|(&id, b)| brink_analyzer::Def {
1795 id,
1796 file: b.file,
1797 params: &b.params,
1798 body: &b.body,
1799 return_annotation: b.return_annotation.as_ref(),
1800 native: b.native,
1801 })
1802 .collect();
1803
1804 // Pre-scan + narrow map (Ruling 1): union of every member's
1805 // referenced_globals, each resolved through the existing
1806 // per-declaring-file `signature_query`.
1807 let mut global_ids: BTreeSet<DefinitionId> = BTreeSet::new();
1808 for &member in batch {
1809 global_ids.extend(referenced_globals_query(db, project, DefKey::new(db, member)).iter());
1810 }
1811 // `value_ty` carries the declaration's type at full `Ty` fidelity —
1812 // scalars, `List<L>`, and (since issue #1540) `Array`/`Map`/`Struct`/
1813 // `Fn`/`Handle` alike (`Option`/`Range` have no annotation grammar yet,
1814 // so they never reach here). Mirrors `brink_analyzer::infer::
1815 // collect_globals`'s own single read exactly, so this narrowed path
1816 // stays composed-equals-monolithic with it.
1817 let mut globals: BTreeMap<DefinitionId, brink_analyzer::Ty> = BTreeMap::new();
1818 for gid in global_ids {
1819 if let Some(sig) = signature_query(db, project, DefKey::new(db, gid))
1820 && let Some(ty) = sig.value_ty.clone()
1821 {
1822 globals.insert(gid, ty);
1823 }
1824 }
1825
1826 // Narrowed resolutions (Ruling 2b): only this batch's own declaring
1827 // files' `resolve_query` results, deduplicated by file.
1828 let mut resolutions = ResolutionMap::new();
1829 let member_files: BTreeSet<FileId> = member_bodies.values().map(|b| b.file).collect();
1830 for file_id in member_files {
1831 if let Some(file) = project.files(db).iter().find(|f| f.file_id(db) == file_id) {
1832 let (file_map, _diags) = resolve_query(db, project, *file);
1833 resolutions.extend(file_map.iter().cloned());
1834 }
1835 }
1836
1837 let opts = project.analysis_options(db);
1838 let inline_docs = inline_docs_query(db, project);
1839 let (signatures, bodies) = brink_analyzer::solve_scc(
1840 batch,
1841 &defs,
1842 index,
1843 &resolutions,
1844 &globals,
1845 inferable,
1846 known_sigs,
1847 opts.host_manifest.as_ref(),
1848 inline_docs,
1849 );
1850 Arc::new(SolvedScc { signatures, bodies })
1851}
1852
1853/// Per-def inferred signature (`inferred_signature(def)`, FG-2 issue #631 —
1854/// the missing per-def API TM-2's firewall consumer needs most). `None` for
1855/// a def with no inferable body (not a knot/stitch, or an unknown id) — same
1856/// `None` contract as [`signature_query`]/[`infer_body_query`].
1857///
1858/// `lru = 16384`: per-def runaway-guard ceiling (issue #647).
1859#[salsa::tracked(lru = 16384)]
1860pub(crate) fn inferred_signature_query<'db>(
1861 db: &'db dyn salsa::Database,
1862 project: ProjectInput,
1863 def: DefKey<'db>,
1864) -> Option<Arc<brink_analyzer::InferredSig>> {
1865 let def_id = def.def(db);
1866 let membership = scc_membership_query(db, project);
1867 let scc_id = *membership.member_of.get(&def_id)?;
1868 let solved = solve_scc_query(db, project, DefKey::new(db, scc_id));
1869 solved.signatures.get(&def_id).cloned().map(Arc::new)
1870}
1871
1872/// One def's raw effect atoms (T2-1, docs/effects-spec.md §2/§4, issue #860 —
1873/// `def_effect_atoms(def)`). The per-def read/write/call-kind atom bundle the
1874/// effect-row fixpoint closes over, harvested by the exact same body walk
1875/// [`referenced_globals_query`]/[`call_edges_query`] already drive — same
1876/// declaring-file-only HIR + resolution dependency edges, same index-sourced
1877/// [`inferable_defs_query`] for classifying a call target as an edge vs. a
1878/// terminal external. Passes `None` for the manifest for the same reason
1879/// [`call_edges_query`] does: this pass discards every computed type, so the
1880/// manifest can never change the *structural* atom sets it keeps.
1881///
1882/// `lru = 16384`: per-def runaway-guard ceiling (issue #647).
1883#[salsa::tracked(lru = 16384)]
1884pub(crate) fn def_effect_atoms_query<'db>(
1885 db: &'db dyn salsa::Database,
1886 project: ProjectInput,
1887 def: DefKey<'db>,
1888) -> Arc<brink_analyzer::EffectAtoms> {
1889 let index = inference_index_query(db, project);
1890 let def_id = def.def(db);
1891 let Some(declaring_file) = index.symbols.get(&def_id).map(|info| info.file) else {
1892 return Arc::new(brink_analyzer::EffectAtoms::default());
1893 };
1894 let Some(file) = project
1895 .files(db)
1896 .iter()
1897 .find(|f| f.file_id(db) == declaring_file)
1898 else {
1899 return Arc::new(brink_analyzer::EffectAtoms::default());
1900 };
1901 let hir = &lowered_query(db, project, *file).hir;
1902 let (resolutions, _diags) = resolve_query(db, project, *file);
1903 let inferable = inferable_defs_query(db, project);
1904 Arc::new(brink_analyzer::def_effect_atoms(
1905 def_id,
1906 &[(declaring_file, hir)],
1907 index,
1908 resolutions,
1909 inferable,
1910 None,
1911 ))
1912}
1913
1914/// One SCC's finalized effect rows (T2-1, docs/effects-spec.md §4, issue #860
1915/// — the per-SCC effect fixpoint, lifting [`solve_scc_query`]'s exact shape to
1916/// the effect lattice). Reads every condensation-predecessor SCC's
1917/// `effects_scc_query` first for `known_rows` (recursion is acyclic — the
1918/// condensation is a DAG, same Fork 1 ruling [`solve_scc_query`] relies on, so
1919/// salsa never sees a cycle), collects every member's
1920/// [`def_effect_atoms_query`], then runs [`brink_analyzer::solve_scc_effects`]
1921/// for this component's own members. Returns an empty map for an id that isn't
1922/// any component's minimum member (defensive — never panics on a stale key).
1923///
1924/// `lru = 16384`: per-def (per-SCC) runaway-guard ceiling (issue #647).
1925#[salsa::tracked(lru = 16384)]
1926pub(crate) fn effects_scc_query<'db>(
1927 db: &'db dyn salsa::Database,
1928 project: ProjectInput,
1929 scc: DefKey<'db>,
1930) -> Arc<BTreeMap<DefinitionId, brink_analyzer::EffectRow>> {
1931 let scc_id: SccId = scc.def(db);
1932 let membership = scc_membership_query(db, project);
1933 let Some(batch) = membership
1934 .order
1935 .iter()
1936 .find(|comp| comp.iter().next().copied() == Some(scc_id))
1937 else {
1938 return Arc::new(BTreeMap::new());
1939 };
1940
1941 let mut known_rows: BTreeMap<DefinitionId, brink_analyzer::EffectRow> = BTreeMap::new();
1942 if let Some(deps) = membership.depends_on.get(&scc_id) {
1943 for &dep in deps {
1944 let solved = effects_scc_query(db, project, DefKey::new(db, dep));
1945 known_rows.extend(solved.iter().map(|(k, v)| (*k, v.clone())));
1946 }
1947 }
1948
1949 let atoms: BTreeMap<DefinitionId, brink_analyzer::EffectAtoms> = batch
1950 .iter()
1951 .map(|&id| {
1952 (
1953 id,
1954 (*def_effect_atoms_query(db, project, DefKey::new(db, id))).clone(),
1955 )
1956 })
1957 .collect();
1958
1959 Arc::new(brink_analyzer::solve_scc_effects(
1960 batch,
1961 &atoms,
1962 &known_rows,
1963 ))
1964}
1965
1966/// Per-def effect row (T2-1, docs/effects-spec.md §4, issue #860 —
1967/// `effects(def)`, the advisory row query sited beside
1968/// [`inferred_signature_query`]). Routes through the def's SCC exactly as
1969/// [`inferred_signature_query`] routes through [`solve_scc_query`]. `None` for
1970/// a def with no inferable body (not a knot/stitch, or an unknown id) — same
1971/// contract as [`inferred_signature_query`]/[`infer_body_query`].
1972///
1973/// **Consumed by `story_data` since T2-3** (#862): `populate_effect_rows`
1974/// reads this for every inferable def to emit the `EffectRows` section. The
1975/// row is still additive metadata the *runtime* does not consume (the linker
1976/// never reads `effect_rows`), so the oracle stays byte-identical — but the row
1977/// now ships in the `.inkb`, so this is no longer advisory-only. `lir_product`
1978/// and `diagnostics` still do not read it.
1979///
1980/// `lru = 16384`: per-def runaway-guard ceiling (issue #647).
1981#[salsa::tracked(lru = 16384)]
1982pub(crate) fn effects_query<'db>(
1983 db: &'db dyn salsa::Database,
1984 project: ProjectInput,
1985 def: DefKey<'db>,
1986) -> Option<Arc<brink_analyzer::EffectRow>> {
1987 let def_id = def.def(db);
1988 let membership = scc_membership_query(db, project);
1989 let scc_id = *membership.member_of.get(&def_id)?;
1990 let solved = effects_scc_query(db, project, DefKey::new(db, scc_id));
1991 solved.get(&def_id).cloned().map(Arc::new)
1992}
1993
1994/// Every `EXTERNAL`'s declaration-derived signature, project-wide (issue
1995/// #1921 — [`brink_analyzer::collect_external_sigs`] as its own memo).
1996/// `Arc<plain>`, `Eq`-derived, so a `host_manifest`/`inline_docs` edit that
1997/// leaves every `EXTERNAL`'s declared signature unchanged backdates this
1998/// memo exactly like [`solve_scc_query`] already backdates its own
1999/// `host_manifest`-dependent `Ty::Handle` resolution (T1d-2b, issue #774).
2000/// That backdating is *why* this is its own `#[salsa::tracked]` query and
2001/// not an inline call inside [`type_inference_query`]: reading
2002/// `project.analysis_options(db)` — a raw salsa input, never backdated on
2003/// its own — directly inside `type_inference_query` would tie
2004/// `type_inference_query`'s *own* memo to that input's revision instead of
2005/// to this query's `Eq`-cutoff output, forcing `type_inference_query` to
2006/// re-execute (a fresh `Arc::new`, breaking the pointer-identity guarantee
2007/// `fg1_dependency_edges.rs` pins) on *any* `AnalysisOptions` edit,
2008/// including a diagnostics-only one like `external_check` that never
2009/// touches an external's declared signature at all.
2010#[salsa::tracked(returns(ref))]
2011pub(crate) fn external_signatures_query(
2012 db: &dyn salsa::Database,
2013 project: ProjectInput,
2014) -> Arc<BTreeMap<DefinitionId, brink_analyzer::InferredSig>> {
2015 let index = inference_index_query(db, project);
2016 let inline_docs = inline_docs_query(db, project);
2017 let opts = project.analysis_options(db);
2018 Arc::new(brink_analyzer::collect_external_sigs(
2019 index,
2020 opts.host_manifest.as_ref(),
2021 inline_docs,
2022 ))
2023}
2024
2025/// Whole-project type inference — now an aggregation over
2026/// [`scc_membership_query`] + [`solve_scc_query`] (FG-2, issue #631; was a
2027/// single monolithic [`brink_analyzer::infer_project`] call
2028/// pre-decomposition). Still re-sourced off `inference_index`/`resolve`,
2029/// never `analysis_query` (FG-1 §3) — every query this reads
2030/// (`scc_membership_query` -> `call_graph_query` -> `call_edges_query` ->
2031/// `inference_inputs`, plus [`external_signatures_query`] below) traces
2032/// back to the same two roots (or its own `Eq`-cutoff memo), so the
2033/// pointer-identity guarantee `fg1_dependency_edges.rs` pins (a
2034/// diagnostics-only edit leaves this memo fully validated, never
2035/// re-executed) still holds after this refactor.
2036///
2037/// **Merges in every `EXTERNAL`'s signature once, here (issue #1921).**
2038/// [`solve_scc_query`]'s own `signatures` never carries one — `batch` is
2039/// never an `EXTERNAL` (see [`brink_analyzer::solve_scc`]'s own doc) — so
2040/// without this, a UFCS call into an `EXTERNAL` went argument-unchecked on
2041/// this db-backed path even though the identical call was already checked
2042/// through the pure `infer_project` path (whose `solve_batches` sibling
2043/// returns `known_sigs` wholesale, no batch filter). This re-merge reads
2044/// [`external_signatures_query`] — its own backdating memo, see its doc —
2045/// exactly once, at this single aggregation point, deliberately *not*
2046/// inside [`solve_scc_query`] itself: doing it per-SCC would (a) make every
2047/// `solve_scc_query` memo hold a full clone of the project's whole
2048/// external-signature map, multiplying `solve_scc_heap_size`'s per-memo
2049/// heap accounting by the SCC count, and (b) make every SCC's memoized
2050/// *value* depend on every `EXTERNAL`'s declared type, so a `host_manifest`
2051/// edit touching one external would invalidate every SCC's cutoff —
2052/// including SCCs that never call that external — costing
2053/// `inferred_signature_query`/`infer_body_query` the exact FG-2 per-def
2054/// cutoff `fg1_dependency_edges.rs` pins, project-wide. Merging only here
2055/// keeps that per-def cutoff intact; only this one aggregation memo
2056/// re-executes (cheaply — no fixpoint solving, just a map merge) when a
2057/// manifest edit changes an external's declared type.
2058#[salsa::tracked(returns(ref))]
2059pub(crate) fn type_inference_query(
2060 db: &dyn salsa::Database,
2061 project: ProjectInput,
2062) -> Arc<InferenceResult> {
2063 let membership = scc_membership_query(db, project);
2064 let mut signatures = BTreeMap::new();
2065 let mut bodies = BTreeMap::new();
2066 let mut seen: BTreeSet<SccId> = BTreeSet::new();
2067 for comp in &membership.order {
2068 let Some(scc_id) = comp.iter().next().copied() else {
2069 continue;
2070 };
2071 if !seen.insert(scc_id) {
2072 continue;
2073 }
2074 let solved = solve_scc_query(db, project, DefKey::new(db, scc_id));
2075 signatures.extend(solved.signatures.iter().map(|(k, v)| (*k, v.clone())));
2076 bodies.extend(solved.bodies.iter().map(|(k, v)| (*k, v.clone())));
2077 }
2078 signatures.extend(
2079 external_signatures_query(db, project)
2080 .iter()
2081 .map(|(k, v)| (*k, v.clone())),
2082 );
2083 Arc::new(InferenceResult { signatures, bodies })
2084}
2085
2086/// Per-def inferred body types (`infer_body(def)`). Re-pointed at
2087/// `solve_scc(scc_of(def))` (FG-2, issue #631) — was a view over the
2088/// whole-project `type_inference_query` memo pre-decomposition. `None` for a
2089/// def with no inferable body (not a knot/stitch, or an unknown id) — same
2090/// `None` contract as [`signature_query`].
2091///
2092/// `lru = 16384`: per-def runaway-guard ceiling (issue #647). `heap_size =
2093/// heap_size::infer_body_heap_size`: one of the five #538 estimators.
2094#[salsa::tracked(lru = 16384, heap_size = heap_size::infer_body_heap_size)]
2095pub(crate) fn infer_body_query<'db>(
2096 db: &'db dyn salsa::Database,
2097 project: ProjectInput,
2098 def: DefKey<'db>,
2099) -> Option<Arc<brink_analyzer::BodyTypes>> {
2100 let def_id = def.def(db);
2101 let membership = scc_membership_query(db, project);
2102 let scc_id = *membership.member_of.get(&def_id)?;
2103 let solved = solve_scc_query(db, project, DefKey::new(db, scc_id));
2104 solved.bodies.get(&def_id).cloned().map(Arc::new)
2105}
2106
2107/// Per-file type diagnostics (`type_diagnostics(FileId)`). **Advisory-only
2108/// in this slice**: TM-1 produces inference *results* (`infer_body`,
2109/// `signature`), not new user-facing diagnostics (typed-mode-spec §9 step 1:
2110/// "essentially no new user-facing diagnostics") — this always returns
2111/// empty today. The query exists now, correctly shaped, so TM-3 (strict
2112/// mode's `Unknown`-escape errors) only has to fill the body in rather than
2113/// threading a new query through every consumer.
2114#[salsa::tracked(returns(ref))]
2115pub(crate) fn type_diagnostics_query(
2116 db: &dyn salsa::Database,
2117 project: ProjectInput,
2118 file: SourceFile,
2119) -> Vec<Diagnostic> {
2120 let _ = (db, project, file);
2121 Vec::new()
2122}
2123
2124// ─── Layer 3: lowering / codegen (whole-project in slice B) ──────────
2125
2126/// Outcome of the pipeline through LIR lowering, mirroring
2127/// `brink-compiler`'s `compile_lir` stage sequence exactly.
2128///
2129/// `program` is `None` when errors (or a missing entry point) prevented
2130/// lowering; `errors`/`warnings` are the suppression-filtered, partitioned
2131/// diagnostics (plus LIR lowering warnings on success).
2132#[derive(Clone, Default)]
2133pub struct LirProduct {
2134 /// The lowered LIR program, if diagnostics allowed lowering to run.
2135 pub program: Option<Arc<brink_ir::lir::Program>>,
2136 /// Error-severity diagnostics (compilation failed if non-empty).
2137 pub errors: Vec<Diagnostic>,
2138 /// Warning-severity diagnostics (including LIR warnings on success).
2139 pub warnings: Vec<Diagnostic>,
2140}
2141
2142/// `lir::Program` has no `PartialEq`, so program identity (`Arc::ptr_eq`) is
2143/// the only cheap proxy. This impl exists solely to satisfy salsa's update
2144/// fallback — backdating is disabled via `no_eq` on [`lir_query`], so it is
2145/// never used to claim two independently-computed programs equal.
2146impl PartialEq for LirProduct {
2147 fn eq(&self, other: &Self) -> bool {
2148 let program_eq = match (&self.program, &other.program) {
2149 (Some(a), Some(b)) => Arc::ptr_eq(a, b),
2150 (None, None) => true,
2151 _ => false,
2152 };
2153 program_eq && self.errors == other.errors && self.warnings == other.warnings
2154 }
2155}
2156
2157/// The LIR-lowering half of [`lir_query`] split out on its own (issue #791 /
2158/// FG-4a — PR #753's seam finding #3), gated on [`has_errors_query`]'s
2159/// narrow boolean instead of the full [`analysis_diagnostics_query`] vector.
2160/// Reads only [`resolutions_index_query`], every file's [`lowered_query`]
2161/// HIR, [`include_graph_query`], and the narrow [`type_policy_query`]
2162/// projection — never the raw analysis diagnostics, and never the raw
2163/// `AnalysisOptions` input field (issue #806) — so a diagnostics edit that
2164/// changes *content* without flipping [`has_errors_query`]'s verdict, and an
2165/// options edit that doesn't change the `types` policy, both leave this memo
2166/// (and its `Arc<Program>` pointer) fully validated, not re-executed
2167/// (`fg4a_dependency_edges.rs`).
2168/// `no_eq`: `lir::Program` has no `PartialEq`, same reasoning as
2169/// [`LirProduct`]'s own impl below.
2170#[derive(Clone, Default)]
2171pub(crate) struct LirLowering {
2172 /// The lowered LIR program, if lowering succeeded with no Error-severity
2173 /// lowering diagnostic.
2174 pub program: Option<Arc<brink_ir::lir::Program>>,
2175 /// Error-severity diagnostics raised *during* LIR lowering (never from
2176 /// `analysis_diagnostics_query` — those are [`lir_query`]'s own concern).
2177 pub errors: Vec<Diagnostic>,
2178 /// Warning-severity diagnostics raised during LIR lowering.
2179 pub warnings: Vec<Diagnostic>,
2180}
2181
2182impl PartialEq for LirLowering {
2183 fn eq(&self, other: &Self) -> bool {
2184 let program_eq = match (&self.program, &other.program) {
2185 (Some(a), Some(b)) => Arc::ptr_eq(a, b),
2186 (None, None) => true,
2187 _ => false,
2188 };
2189 program_eq && self.errors == other.errors && self.warnings == other.warnings
2190 }
2191}
2192
2193// ─── FG-4d: per-container LIR chunk memos + link ─────────────────────
2194//
2195// `lir_lowering_query` below is now the **link phase**
2196// (`docs/fine-grained-salsa-proposal.md` §5 + the three-resolution-moments
2197// appendix): it reads a per-knot chunk memo per definition, assembles them
2198// (plus the whole-root chunk) into a `Program`, and gates on
2199// `has_errors_query`. The per-knot memos (`lir_knot_chunk_query`) are the
2200// FG-4d win — an edit that leaves a knot's declaring file, the project
2201// resolutions, and the struct-shape projection unchanged leaves that knot's
2202// chunk `Arc` pointer-identical (non-re-execution), and the whole-project
2203// link re-runs but backdates on the `StoryData` `Eq` firebreak
2204// (`story_data_query`). Byte-identity with the monolithic path is by
2205// construction: the chunk lowering and assembly are the *same* `brink-ir`
2206// functions `lower_to_program_with_type_mode` composes, fed the same inputs
2207// in the same interleaved walk order.
2208//
2209// Input-breadth limit (issue #830, #815): the per-knot memo depends on the
2210// whole-project `resolutions_index_query` and `struct_shape_data_query`, so
2211// non-re-execution holds for edits those two backdate across (a
2212// diagnostics-only / `AnalysisOptions` edit, and — for a knot in an
2213// *unedited* file — any edit whose resolutions/struct-shapes are unchanged).
2214// `topological_order` is now narrowed to `entry`'s transitive `INCLUDE`
2215// closure (issue #815, landed separately) rather than falling back to all
2216// project files, so `struct_shape_data_query` and the link's own inputs are
2217// scoped the same way.
2218
2219/// The cutoff-friendly struct-shape projection (FG-4d): the
2220/// `NameId`-free, `Eq`-able [`StructShapeData`] the per-knot chunk memo reads
2221/// instead of every file's HIR. Backdates when no `STRUCT` declaration (or
2222/// struct-typed global annotation) changed, so an unrelated edit leaves the
2223/// knot chunks that read it pointer-identical. Reads the same
2224/// `resolutions_index_query` index the monolithic path's `build_prelude`
2225/// does, so its ids/offsets are byte-identical.
2226#[salsa::tracked(returns(ref))]
2227pub(crate) fn struct_shape_data_query(
2228 db: &dyn salsa::Database,
2229 project: ProjectInput,
2230) -> brink_ir::lir::StructShapeData {
2231 if project.entry(db).is_none() {
2232 return brink_ir::lir::StructShapeData::default();
2233 }
2234 let files = project.files(db);
2235 let by_id: LookupMap<FileId, SourceFile> = files.iter().map(|f| (f.file_id(db), *f)).collect();
2236 let topo = compilation_closure_files(db, project);
2237 let hir_refs: Vec<(FileId, &HirFile)> = topo
2238 .iter()
2239 .filter_map(|id| {
2240 by_id
2241 .get(id)
2242 .map(|f| (*id, &lowered_query(db, project, *f).hir))
2243 })
2244 .collect();
2245 let resolved = resolutions_index_query(db, project);
2246 brink_ir::lir::build_struct_shape_data(&hir_refs, &resolved.index, &resolved.resolutions)
2247}
2248
2249/// One file's declaration-only HIR projection (issue #839 / FG-4e):
2250/// `constants`/`variables`/`lists`/`structs`/`externals` kept, `root_content`
2251/// and every knot's body dropped to `Block::default()`/`Vec::new()`. Backed
2252/// by [`HirFile`]'s derived `PartialEq`, so a body-only edit — one that
2253/// leaves every declaration untouched — backdates this memo across the edit,
2254/// same as [`normalized_stamped_query`] backdates the file's syntax tree.
2255///
2256/// This is the per-file dependency edge [`lir_prelude_decls_query`] reads
2257/// instead of the raw, body-carrying [`lowered_query`]: `brink_ir::lir`'s
2258/// declaration-collection passes ([`collect_globals`], [`collect_lists`],
2259/// [`collect_externals`], [`build_shape_table`], [`build_global_shape_map`])
2260/// never read `root_content`/`knots` (see `PreludeDecls`'s doc in
2261/// `brink-ir`), so stripping them here is behavior-neutral for every reader
2262/// and turns "any reachable file changed" into "a reachable file's
2263/// declarations changed" as the trigger for re-interning the project name
2264/// table.
2265///
2266/// [`collect_globals`]: brink_ir::lir::build_prelude_decls
2267/// [`collect_lists`]: brink_ir::lir::build_prelude_decls
2268/// [`collect_externals`]: brink_ir::lir::build_prelude_decls
2269/// [`build_shape_table`]: brink_ir::lir::build_prelude_decls
2270/// [`build_global_shape_map`]: brink_ir::lir::build_prelude_decls
2271#[salsa::tracked(returns(ref))]
2272pub(crate) fn decl_hir_query(
2273 db: &dyn salsa::Database,
2274 project: ProjectInput,
2275 file: SourceFile,
2276) -> HirFile {
2277 let hir = &lowered_query(db, project, file).hir;
2278 HirFile {
2279 root_content: brink_ir::hir::Block::default(),
2280 knots: Vec::new(),
2281 ..hir.clone()
2282 }
2283}
2284
2285/// One file's HIR after the pre-LIR normalize + container-id stamp passes,
2286/// memoized per file (FG-4d). Without this, each of a K-knot file's per-knot
2287/// chunk memos would repeat the file's normalize+stamp, turning a cold
2288/// compile into O(K²) work per file; sharing it here keeps the per-def split
2289/// from regressing cold compile. Both passes are per-file independent, so
2290/// this is byte-identical to the file's slice of the whole-project prelude.
2291/// Reads the whole-project index only for label-container stamping (the same
2292/// index the monolithic path stamps with), and `HirFile`'s value `Eq`
2293/// backdates it across edits that leave this file's normalized shape
2294/// unchanged.
2295#[salsa::tracked(returns(ref))]
2296pub(crate) fn normalized_stamped_query(
2297 db: &dyn salsa::Database,
2298 project: ProjectInput,
2299 file: SourceFile,
2300) -> Arc<HirFile> {
2301 let resolved = resolutions_index_query(db, project);
2302 let mut hir = lowered_query(db, project, file).hir.clone();
2303 brink_ir::normalize_file(&mut hir);
2304 let mut slice = [(file.file_id(db), hir)];
2305 // #1504: the file's own path qualifies its root-content scope path, so
2306 // two files' root weaves no longer mint the same anonymous ids. Reading
2307 // `path` here adds no invalidation edge this memo did not already have —
2308 // it is an input field of the `SourceFile` it is already keyed on.
2309 //
2310 // #1696: the qualifier is the file's *root-relative* key, not its raw
2311 // registered path — `crate::modules::root_relative_key` against the
2312 // project's registered `ink_root` (`None` for every ordinary compile,
2313 // where it is a no-op), the same normalization `native_root` already
2314 // gives `.brink` module identity (issue #1572).
2315 let ink_root = project.ink_root(db).as_deref();
2316 let file_paths: LookupMap<FileId, String> = std::iter::once((
2317 file.file_id(db),
2318 crate::modules::root_relative_key(ink_root, file.path(db)).into_owned(),
2319 ))
2320 .collect();
2321 brink_ir::stamp_container_ids(&mut slice, &resolved.index, &file_paths);
2322 let [(_, stamped)] = slice;
2323 Arc::new(stamped)
2324}
2325
2326/// [`brink_ir::lir::PreludeDecls`] wrapped so [`lir_prelude_decls_query`]
2327/// (`no_eq`: the wrapped `ShapeTable`/`GlobalShapeMap` carry `NameId`s valid
2328/// only within this specific prelude's numbering, so they cannot be `Eq`
2329/// without a NameId-free relocation redesign — same reasoning as
2330/// [`LirLowering`]'s `program`) can still satisfy salsa's `Update` bound.
2331/// `Arc`-wrapped so a validated (non-re-executed) memo hands back the *same*
2332/// allocation — `Arc::ptr_eq` is the non-re-execution proof, same pattern as
2333/// [`LoweredChunk`].
2334#[derive(Clone)]
2335pub(crate) struct PreludeDeclsResult {
2336 pub decls: Arc<brink_ir::lir::PreludeDecls>,
2337}
2338
2339impl PartialEq for PreludeDeclsResult {
2340 fn eq(&self, other: &Self) -> bool {
2341 Arc::ptr_eq(&self.decls, &other.decls)
2342 }
2343}
2344
2345/// The whole-project declaration-level prelude (issue #839 / FG-4e —
2346/// `docs/fine-grained-salsa-proposal.md`'s pattern, applied past structs to
2347/// the rest of `build_prelude`): [`brink_ir::lir::build_prelude_decls`] over
2348/// every entry-reachable file's [`decl_hir_query`] projection instead of the
2349/// monolithic link's inline `build_prelude` call over raw, body-carrying
2350/// HIR. Because [`decl_hir_query`] itself backdates across a body-only edit,
2351/// a knot body edit anywhere in the project leaves *this* query's recorded
2352/// dependencies unchanged — [`lir_lowering_query`] no longer pays
2353/// `collect_globals`/`collect_lists`/`collect_externals`/`build_shape_table`/
2354/// `build_global_shape_map`'s full re-interning cost on every recompile, only
2355/// when some reachable file's actual declarations change (`fg4e_prelude_
2356/// decls.rs`).
2357///
2358/// Same input-breadth limit as [`struct_shape_data_query`] (issue #815):
2359/// scoped to `entry`'s transitive `INCLUDE` closure, not full backdating
2360/// across *any* unrelated project file's declarations (that would need a
2361/// per-file decl memo feeding the interning step directly, which the
2362/// project-wide, order-sensitive `NameTable` this produces does not allow
2363/// without the same NameId-free-projection-plus-relocation redesign
2364/// `StructShapeData` did for structs alone — out of this slice's scope, see
2365/// the PR description).
2366#[salsa::tracked(no_eq)]
2367pub(crate) fn lir_prelude_decls_query(
2368 db: &dyn salsa::Database,
2369 project: ProjectInput,
2370) -> PreludeDeclsResult {
2371 let type_mode = match type_policy_query(db, project) {
2372 TypePolicy::Strict => brink_ir::lir::TypeMode::Strict,
2373 TypePolicy::Gradual => brink_ir::lir::TypeMode::Gradual,
2374 };
2375 if project.entry(db).is_none() {
2376 return PreludeDeclsResult {
2377 decls: Arc::new(brink_ir::lir::PreludeDecls::empty(type_mode)),
2378 };
2379 }
2380 let files = project.files(db);
2381 let by_id: LookupMap<FileId, SourceFile> = files.iter().map(|f| (f.file_id(db), *f)).collect();
2382 let topo = compilation_closure_files(db, project);
2383 let decl_refs: Vec<(FileId, &HirFile)> = topo
2384 .iter()
2385 .filter_map(|id| {
2386 by_id
2387 .get(id)
2388 .map(|f| (*id, decl_hir_query(db, project, *f)))
2389 })
2390 .collect();
2391 let resolved = resolutions_index_query(db, project);
2392 // #1774: reaches `decls::collect_globals`'s lambda-lifting path, which
2393 // qualifies a lambda-literal decl default's synthesized function by the
2394 // owning file — same #1696 root-relative convention as
2395 // `chunk_lowering_ctx_query`/`lir_lowering_query`'s own `file_paths`.
2396 // `project.files(db)` is already read just above (`by_id`), so reading
2397 // each file's `.path(db)` here adds no new dependency edge.
2398 let ink_root = project.ink_root(db).as_deref();
2399 let file_paths: LookupMap<FileId, String> = files
2400 .iter()
2401 .map(|f| {
2402 (
2403 f.file_id(db),
2404 crate::modules::root_relative_key(ink_root, f.path(db)).into_owned(),
2405 )
2406 })
2407 .collect();
2408 // Review finding on #1774: a decl-default lambda body is lowered through
2409 // the same `lower_lambda` machinery as any other lambda (issue #1709),
2410 // so it needs the same UFCS/`or`-coalescing verdict tables any other
2411 // lambda body gets — not the empty placeholder pair every *other*
2412 // caller of `AnalyzerTables` uses because those callers genuinely never
2413 // ran an analyzer pass. Same construction `chunk_lowering_ctx_query`
2414 // (:1970-1972) and `lir_lowering_query` (:2119-2121) already use; no new
2415 // dependency edge risk (`ufcs_resolution_query`/`coalesce_types_query`
2416 // are re-sourced off `resolutions_index_query`/`lowered_query`/
2417 // `type_inference_query`, never off this query or anything downstream of
2418 // it, so this cannot introduce a salsa cycle).
2419 let ufcs = &ufcs_resolution_query(db, project).table;
2420 let coalesce = coalesce_types_query(db, project);
2421 let tables = brink_ir::lir::AnalyzerTables { ufcs, coalesce };
2422 let decls = brink_ir::lir::build_prelude_decls(
2423 &decl_refs,
2424 &resolved.index,
2425 &resolved.resolutions,
2426 &file_paths,
2427 type_mode,
2428 tables,
2429 );
2430 PreludeDeclsResult {
2431 decls: Arc::new(decls),
2432 }
2433}
2434
2435/// Interned key for [`lir_knot_chunk_query`]: a knot identified by its
2436/// declaring file and its index within that file's knot list. Keyed on
2437/// `(FileId, knot_index)` rather than the knot's `DefinitionId` so two knots
2438/// that would hash to the same address (e.g. same-named file-local knots)
2439/// never collapse onto one memo — the byte-identity hazard `DefinitionId`
2440/// keying would carry.
2441#[salsa::interned]
2442pub(crate) struct KnotChunkKey<'db> {
2443 pub file: FileId,
2444 pub knot_index: u32,
2445}
2446
2447/// One knot's lowered LIR chunk plus its lowering diagnostics — the value a
2448/// per-knot memo stores. `chunk` is `Arc`-wrapped so a validated (non-re-
2449/// executed) memo hands back the *same* allocation, which
2450/// `Arc::ptr_eq` detects (the non-re-execution proof — same reasoning as
2451/// `LirLowering`'s `program`). The `PartialEq` exists solely to satisfy
2452/// salsa's `Update` bound; `no_eq` disables backdating, so it is never used
2453/// to claim two independently-lowered chunks equal.
2454#[derive(Clone, Default)]
2455pub(crate) struct LoweredChunk {
2456 pub chunk: Arc<brink_ir::lir::ScopeChunk>,
2457 pub diagnostics: Vec<Diagnostic>,
2458}
2459
2460impl PartialEq for LoweredChunk {
2461 fn eq(&self, other: &Self) -> bool {
2462 Arc::ptr_eq(&self.chunk, &other.chunk) && self.diagnostics == other.diagnostics
2463 }
2464}
2465
2466/// [`brink_ir::lir::ChunkLoweringCtx`] wrapped so
2467/// [`chunk_lowering_ctx_query`] (`no_eq`: it holds the same
2468/// `ShapeTable`/`GlobalShapeMap` `PreludeDeclsResult` cannot make `Eq`) can
2469/// satisfy salsa's `Update` bound. `Arc`-wrapped so a validated memo hands
2470/// back the same allocation — same pattern as [`PreludeDeclsResult`].
2471#[derive(Clone)]
2472pub(crate) struct ChunkLoweringCtxResult {
2473 pub ctx: Arc<brink_ir::lir::ChunkLoweringCtx>,
2474}
2475
2476impl PartialEq for ChunkLoweringCtxResult {
2477 fn eq(&self, other: &Self) -> bool {
2478 Arc::ptr_eq(&self.ctx, &other.ctx)
2479 }
2480}
2481
2482/// The knot-invariant half of [`lir_knot_chunk_query`]'s lowering
2483/// environment, built once per project revision instead of once per knot
2484/// (issue #460).
2485///
2486/// Every input here is whole-project — the flattened resolution lookup, the
2487/// reconstructed struct-shape tables, the `FileId`→path map, the type mode —
2488/// so each of the project's K per-knot memos used to rebuild all of it,
2489/// making the per-knot LIR layer `O(K × project size)`. The measured cost on
2490/// `compile_bench`'s 50-file × 20-knot synthetic project was the dominant
2491/// share of cold LIR lowering; hoisting it here makes that share `O(1)` in K.
2492///
2493/// This query's dependency set is exactly the subset of
2494/// [`lir_knot_chunk_query`]'s dependencies it took over
2495/// ([`resolutions_index_query`], [`struct_shape_data_query`],
2496/// [`type_policy_query`], and the files' `path` fields), so no chunk memo
2497/// gains or loses an invalidation edge: anything that re-executes this
2498/// re-executed every chunk before.
2499#[salsa::tracked(no_eq)]
2500pub(crate) fn chunk_lowering_ctx_query(
2501 db: &dyn salsa::Database,
2502 project: ProjectInput,
2503) -> ChunkLoweringCtxResult {
2504 let resolved = resolutions_index_query(db, project);
2505 let shape_data = struct_shape_data_query(db, project);
2506 // Narrow `.types` projection (issue #806/#809) — not the raw
2507 // `AnalysisOptions` field — so an unrelated options edit doesn't
2508 // re-execute this memo (and through it, every knot chunk).
2509 let type_mode = match type_policy_query(db, project) {
2510 TypePolicy::Strict => brink_ir::lir::TypeMode::Strict,
2511 TypePolicy::Gradual => brink_ir::lir::TypeMode::Gradual,
2512 };
2513 // #1696: root-relative, not raw — see `normalized_stamped_query`'s doc.
2514 let ink_root = project.ink_root(db).as_deref();
2515 let file_paths: LookupMap<FileId, String> = project
2516 .files(db)
2517 .iter()
2518 .map(|f| {
2519 (
2520 f.file_id(db),
2521 crate::modules::root_relative_key(ink_root, f.path(db)).into_owned(),
2522 )
2523 })
2524 .collect();
2525 ChunkLoweringCtxResult {
2526 ctx: Arc::new(brink_ir::lir::ChunkLoweringCtx::new(
2527 &resolved.resolutions,
2528 shape_data,
2529 file_paths,
2530 type_mode,
2531 )),
2532 }
2533}
2534
2535/// Lower one knot into a self-contained LIR chunk — the per-`DefinitionId`
2536/// unit of FG-4d. Reads only the declaring file's `lowered_query` HIR
2537/// (per-file edge), the whole-project `resolutions_index_query`
2538/// (backdates across body/diagnostics edits), and
2539/// `struct_shape_data_query` (backdates unless a struct declaration
2540/// changed) — so a knot in an unedited file whose resolutions and struct
2541/// shapes are unchanged keeps its chunk `Arc` across the edit. `no_eq`:
2542/// `ScopeChunk` has no `PartialEq` (holds `lir::Container`), so this never
2543/// backdates — the link re-runs and re-anchors on `StoryData`'s `Eq`.
2544#[salsa::tracked(no_eq)]
2545pub(crate) fn lir_knot_chunk_query(
2546 db: &dyn salsa::Database,
2547 project: ProjectInput,
2548 key: KnotChunkKey<'_>,
2549) -> LoweredChunk {
2550 let file_id = key.file(db);
2551 let knot_index = key.knot_index(db) as usize;
2552 let Some(source) = project
2553 .files(db)
2554 .iter()
2555 .copied()
2556 .find(|f| f.file_id(db) == file_id)
2557 else {
2558 return LoweredChunk::default();
2559 };
2560
2561 let resolved = resolutions_index_query(db, project);
2562 // The knot-invariant half of the lowering environment (resolution
2563 // lookup, struct-shape tables, file paths, type mode), built once per
2564 // project revision rather than once per knot — issue #460.
2565 let ctx = &chunk_lowering_ctx_query(db, project).ctx;
2566
2567 // The file's normalized+stamped HIR, shared across all its knots'
2568 // memos (so a K-knot file normalizes once, not K times).
2569 let hir_file = normalized_stamped_query(db, project, source);
2570 let Some(knot) = hir_file.knots.get(knot_index) else {
2571 return LoweredChunk::default();
2572 };
2573
2574 let ufcs = &ufcs_resolution_query(db, project).table;
2575 let coalesce = coalesce_types_query(db, project);
2576 let tables = brink_ir::lir::AnalyzerTables { ufcs, coalesce };
2577 let (chunk, diagnostics) = brink_ir::lir::lower_knot_chunk_incremental(
2578 hir_file,
2579 knot,
2580 &resolved.index,
2581 ctx,
2582 file_id,
2583 tables,
2584 );
2585 LoweredChunk {
2586 chunk: Arc::new(chunk),
2587 diagnostics,
2588 }
2589}
2590
2591/// The project's TM-3 `types` policy as its own narrow projection query
2592/// (issue #806 / PR #809, mirroring [`has_errors_query`]'s pattern): a raw
2593/// `project.analysis_options(db).types` field read inside
2594/// [`lir_lowering_query`] would register a dependency on the *whole*
2595/// `AnalysisOptions` input field, so any options edit — registering a host
2596/// manifest, toggling `semantic_type_check`, even re-setting the identical
2597/// value — would force the `no_eq` lowering memo to fully re-execute and
2598/// allocate a fresh `Arc<Program>`. `TypePolicy`'s derived `Eq` is the
2599/// cheapest possible cutoff: an options edit that doesn't change `.types`
2600/// re-executes only this trivial projection, backdates it, and leaves
2601/// [`lir_lowering_query`] (and its `Arc<Program>` pointer) fully validated —
2602/// see `fg4a_dependency_edges.rs`. Behavior-neutral by construction: the
2603/// same field, read one query-hop later.
2604///
2605/// FG-4d (issue #830) also routes the per-knot chunk memos' `.types` read
2606/// through this projection, so an `AnalysisOptions` edit that leaves `.types`
2607/// unchanged keeps every knot chunk `Arc` pointer-identical.
2608///
2609/// Since the #1127 default flip this projects the *resolved* policy
2610/// (`AnalysisOptions::type_policy()` — explicit `types` or the dialect-keyed
2611/// default), so the cutoff argument is unchanged: same narrow `TypePolicy`
2612/// value, resolved one query-hop later.
2613#[salsa::tracked]
2614pub(crate) fn type_policy_query(db: &dyn salsa::Database, project: ProjectInput) -> TypePolicy {
2615 project.analysis_options(db).type_policy()
2616}
2617
2618/// The project's resolved `[lints]` policy (issue #1160) as its own narrow
2619/// projection query — [`type_policy_query`]'s sibling, same cutoff argument:
2620/// [`lir_lowering_query`]'s severity partition needs `AnalysisOptions.lints`,
2621/// but reading it through `project.analysis_options(db)` directly would
2622/// register a dependency on the *whole* input field, so an unrelated options
2623/// edit (registering a host manifest, say) would force the `no_eq` lowering
2624/// memo to fully re-execute. `LintPolicy`'s derived `Eq` gives the same
2625/// cheap-cutoff property `TypePolicy` already has here.
2626#[salsa::tracked]
2627pub(crate) fn lint_policy_query(
2628 db: &dyn salsa::Database,
2629 project: ProjectInput,
2630) -> brink_analyzer::LintPolicy {
2631 project.analysis_options(db).lints.clone()
2632}
2633
2634/// FG-4d **link phase**: assemble the per-knot chunk memos and the whole-root
2635/// chunk into a `Program`. See the section comment above for the
2636/// byte-identity and non-re-execution arguments.
2637///
2638/// Gated on [`has_errors_in_closure_query`] (issue #1032 collapse ruling),
2639/// not the whole-project [`has_errors_query`] — this function has two
2640/// callers with different pre-conditions: [`lir_query`] already gates on the
2641/// (stronger) whole-project check before ever calling here, so for that
2642/// caller this is inert (whole-project-clean implies closure-clean, since
2643/// the closure is a subset); [`lir_in_closure_query`] gates on exactly this
2644/// (weaker) check itself, so this must use the same one to actually permit
2645/// lowering when only a file outside `entry`'s closure is broken. The
2646/// per-file lowering below was already scoped to `topological_order(entry)`
2647/// (issue #815) regardless of which gate is used here.
2648#[salsa::tracked(no_eq)]
2649pub(crate) fn lir_lowering_query(db: &dyn salsa::Database, project: ProjectInput) -> LirLowering {
2650 if project.entry(db).is_none() {
2651 return LirLowering::default();
2652 }
2653 if has_errors_in_closure_query(db, project) {
2654 return LirLowering::default();
2655 }
2656
2657 let files = project.files(db);
2658 let resolved = resolutions_index_query(db, project);
2659
2660 // LIR inputs in compile (paste-before) order, mirroring
2661 // `Driver::lir_inputs`. The order comes from [`compilation_closure_files`]:
2662 // for an ink project this is `entry`'s transitive `INCLUDE` closure
2663 // (issue #815); for a native project it is every discovered `.brink`
2664 // module (issue #1296 — native files have no `INCLUDE` edges, so the whole
2665 // discovered tree is the compilation unit). Files outside it never lower
2666 // here; their diagnostics still run independently via
2667 // `analysis_diagnostics_query`/`diagnostics_query` below and in
2668 // `super::diagnostics_query`, which iterate `project.files(db)`
2669 // directly rather than through this order.
2670 let by_id: LookupMap<FileId, SourceFile> = files.iter().map(|f| (f.file_id(db), *f)).collect();
2671 let topo = compilation_closure_files(db, project);
2672 // #1696: root-relative, not raw — see `normalized_stamped_query`'s doc.
2673 // Feeds `lower_root_content_for_prelude`'s `IdAllocator::set_path_prefix`
2674 // call below, which must agree with `normalized_stamped_query`'s
2675 // pre-stamped HIR ids byte-for-byte, so both use the same normalization.
2676 let ink_root = project.ink_root(db).as_deref();
2677 let paths: LookupMap<FileId, String> = topo
2678 .iter()
2679 .filter_map(|id| {
2680 by_id.get(id).map(|f| {
2681 (
2682 *id,
2683 crate::modules::root_relative_key(ink_root, f.path(db)).into_owned(),
2684 )
2685 })
2686 })
2687 .collect();
2688
2689 // TM-4c (`docs/typed-mode-spec.md` §6): the project's `types` policy
2690 // gates static-offset record field ops, and also partitions lowering
2691 // diagnostics by effective severity below. Read through the narrow
2692 // [`type_policy_query`] projection, never the raw `AnalysisOptions`
2693 // input field — see its doc comment (issue #806).
2694 //
2695 // [`brink-ir`'s local `TypeMode` mirror](brink_ir::lir::TypeMode) is now
2696 // decided once, inside [`lir_prelude_decls_query`]'s own
2697 // `type_policy_query` read — this function no longer needs its own copy
2698 // (issue #839 / FG-4e removed the direct `build_prelude` call that used
2699 // to consume it here).
2700 let types = type_policy_query(db, project);
2701 // [`lint_policy_query`]'s sibling narrow projection (issue #1160) —
2702 // same cutoff rationale as `type_policy_query` above.
2703 let lints = lint_policy_query(db, project);
2704
2705 // Whole-project prelude (issue #839 / FG-4e): declarations + struct
2706 // shapes + the seeded name table come from [`lir_prelude_decls_query`]
2707 // (its own memo, cutoff-friendly across body-only edits — see its doc),
2708 // and the normalized+stamped HIR reuses the already-memoized per-file
2709 // [`normalized_stamped_query`] instead of `build_prelude`'s inline
2710 // normalize+stamp recompute. `assemble_prelude` is pure composition —
2711 // byte-identical to the monolithic `build_prelude` by construction (see
2712 // `PreludeDecls`'s doc in `brink-ir`).
2713 let prelude_decls = lir_prelude_decls_query(db, project);
2714 let normalized: Vec<(FileId, HirFile)> = topo
2715 .iter()
2716 .filter_map(|id| {
2717 by_id
2718 .get(id)
2719 .map(|f| (*id, (**normalized_stamped_query(db, project, *f)).clone()))
2720 })
2721 .collect();
2722 let prelude = brink_ir::lir::assemble_prelude((*prelude_decls.decls).clone(), normalized);
2723 let ufcs = &ufcs_resolution_query(db, project).table;
2724 let coalesce = coalesce_types_query(db, project);
2725 let tables = brink_ir::lir::AnalyzerTables { ufcs, coalesce };
2726 let (root_chunks, root_temp_slots) = brink_ir::lir::lower_root_content_for_prelude(
2727 &prelude,
2728 &resolved.index,
2729 &resolved.resolutions,
2730 &paths,
2731 tables,
2732 );
2733
2734 // Interleave in walk order (per file: root content, then that file's
2735 // knots) — the order the assembler dedups names against. Knot chunks come
2736 // from the per-knot memos; declaration diagnostics lead, matching the
2737 // monolithic diagnostic order exactly.
2738 let mut lir_diagnostics = prelude.decl_diagnostics.clone();
2739 let mut ordered_chunks: Vec<brink_ir::lir::ScopeChunk> = Vec::new();
2740 let prelude_files = prelude.files();
2741 let mut root_iter = root_chunks.into_iter();
2742 for (file_id, hir_file) in &prelude_files {
2743 if let Some((chunk, diags)) = root_iter.next() {
2744 ordered_chunks.push(chunk);
2745 lir_diagnostics.extend(diags);
2746 }
2747 for knot_index in 0..hir_file.knots.len() {
2748 #[expect(
2749 clippy::cast_possible_truncation,
2750 reason = "a file won't declare anywhere near u32::MAX knots"
2751 )]
2752 let key = KnotChunkKey::new(db, *file_id, knot_index as u32);
2753 let lowered = lir_knot_chunk_query(db, project, key);
2754 ordered_chunks.push((*lowered.chunk).clone());
2755 lir_diagnostics.extend(lowered.diagnostics.clone());
2756 }
2757 }
2758
2759 let program =
2760 brink_ir::lir::assemble_program(&prelude, ordered_chunks, root_temp_slots, &resolved.index);
2761
2762 // LIR lowering itself is total (T1b-2: every construct lowers to a
2763 // program regardless of dialect). Error-severity lowering diagnostics
2764 // (T1b-3's E055/E056) still gate `program: None` exactly like an
2765 // analysis-phase error would.
2766 let (lir_errors, lir_warnings): (Vec<Diagnostic>, Vec<Diagnostic>) =
2767 lir_diagnostics.into_iter().partition(|d| {
2768 brink_analyzer::effective_severity(d.code, types, &lints) == Severity::Error
2769 });
2770
2771 if lir_errors.is_empty() {
2772 LirLowering {
2773 program: Some(Arc::new(program)),
2774 errors: lir_errors,
2775 warnings: lir_warnings,
2776 }
2777 } else {
2778 // A lowering-phase Error-severity diagnostic (E055/E056) blocks
2779 // compilation: surface it, never hand back a diagnostically-invalid
2780 // program.
2781 LirLowering {
2782 program: None,
2783 errors: lir_errors,
2784 warnings: lir_warnings,
2785 }
2786 }
2787}
2788
2789/// Whole-project LIR lowering (slice B: one project query; slice C splits it
2790/// per container). `no_eq`: `lir::Program` has no `PartialEq`, so this memo
2791/// never backdates — [`story_data_query`] backdates on `StoryData` instead.
2792#[salsa::tracked(returns(ref), no_eq)]
2793pub(crate) fn lir_query(db: &dyn salsa::Database, project: ProjectInput) -> LirProduct {
2794 let files = project.files(db);
2795 let Some(entry) = project.entry(db) else {
2796 return LirProduct::default();
2797 };
2798
2799 // FG-3 (issue #632): read the assembled diagnostics directly, not
2800 // through the bundled `analysis_query` — a resolutions-only change (no
2801 // diagnostic anywhere differs) leaves this half's dependency fully
2802 // validated.
2803 let diagnostics = analysis_diagnostics_query(db, project);
2804
2805 // Diagnostic gate — the same report `compile_lir` builds.
2806 let disable_all = files
2807 .iter()
2808 .find(|f| f.file_id(db) == entry)
2809 .is_some_and(|f| suppressions_query(db, *f).disable_all);
2810 // `is_source_file` (issue #2329): a non-source document's lowering
2811 // (bogus ink-parse) diagnostics never contribute to the build gate — the
2812 // exact same filter `has_errors_query` applies to the identical input
2813 // shape this query's own doc says it must match.
2814 let inputs: Vec<FileDiagnostics<'_>> = files
2815 .iter()
2816 .filter(|f| is_source_file(f.path(db)))
2817 .map(|f| FileDiagnostics {
2818 file: f.file_id(db),
2819 source: f.text(db),
2820 suppressions: suppressions_query(db, *f),
2821 lowering: &lowered_query(db, project, *f).diagnostics,
2822 })
2823 .collect();
2824 let opts = project.analysis_options(db);
2825 let types = opts.type_policy();
2826 let (mut errors, mut warnings) =
2827 partition_diagnostics(&inputs, diagnostics, disable_all, types, &opts.lints);
2828
2829 // FG-4a (issue #791, PR #753 seam finding #3): the gate deciding
2830 // whether to attempt (potentially expensive) LIR lowering reads the
2831 // narrow `has_errors_query` boolean projection instead of
2832 // `errors.is_empty()` directly. `errors`/`warnings` above are still
2833 // needed for *this* query's own return value — diagnostic content, not
2834 // just presence — but the lowering itself is fully delegated to
2835 // `lir_lowering_query`, which reads `has_errors_query` only (never the
2836 // raw diagnostics vector), so its `Arc<Program>` survives a diagnostics
2837 // edit that doesn't flip the error verdict. `has_errors_query` computes
2838 // this exact `errors.is_empty()` value from the same inputs (see its
2839 // doc comment), so this is behavior-neutral by construction.
2840 if has_errors_query(db, project) {
2841 return LirProduct {
2842 program: None,
2843 errors,
2844 warnings,
2845 };
2846 }
2847
2848 let lowering = lir_lowering_query(db, project);
2849 errors.extend(lowering.errors);
2850 warnings.extend(lowering.warnings);
2851
2852 LirProduct {
2853 program: lowering.program,
2854 errors,
2855 warnings,
2856 }
2857}
2858
2859/// The compile-scoped counterpart to [`lir_query`] (issue #1032 collapse
2860/// ruling, option (a) "both, scoped"): gates on [`has_errors_in_closure_query`]
2861/// — `entry`'s transitive `INCLUDE` closure — instead of the whole-project
2862/// [`has_errors_query`]. [`lir_query`] itself is untouched: `db.lir_product()`
2863/// and `db.has_errors()` stay whole-project-gated, exactly as FG-4a's
2864/// dependency-edge tests (`fg4a_dependency_edges.rs`) pin.
2865///
2866/// This is what [`story_data_query`] reads, so `db.story_data()` —
2867/// `compileProject`'s artifact path — no longer fails a clean entry just
2868/// because some other file loaded into the same session db (a WIP scratch
2869/// file, a second unrelated story) happens to have an error. That error
2870/// still surfaces through `diagnostics_query`/`db.diagnostics(file)`
2871/// (unchanged, whole-project) — this only narrows the *build gate*, not
2872/// what's diagnosed. For the CLI driver (`brink-compiler`), whose db is
2873/// already built from `brink-driver::discover(entry)` — entry plus its
2874/// transitive `INCLUDE`s only — `project.files(db)` and the closure coincide,
2875/// so this is behaviorally identical to the old whole-project gate there.
2876///
2877/// `errors`/`warnings` are computed the same closure-filtered way
2878/// [`has_errors_in_closure_query`] computes its verdict, so a file outside
2879/// `entry`'s closure never contributes to `compileProject`'s own error/
2880/// warning list either.
2881#[salsa::tracked(returns(ref), no_eq)]
2882pub(crate) fn lir_in_closure_query(db: &dyn salsa::Database, project: ProjectInput) -> LirProduct {
2883 let files = project.files(db);
2884 let Some(entry) = project.entry(db) else {
2885 return LirProduct::default();
2886 };
2887
2888 let closure: LookupSet<FileId> = compilation_closure_files(db, project).into_iter().collect();
2889
2890 let diagnostics: Vec<Diagnostic> = analysis_diagnostics_query(db, project)
2891 .iter()
2892 .filter(|d| closure.contains(&d.file))
2893 .cloned()
2894 .collect();
2895
2896 let disable_all = files
2897 .iter()
2898 .find(|f| f.file_id(db) == entry)
2899 .is_some_and(|f| suppressions_query(db, *f).disable_all);
2900 let inputs: Vec<FileDiagnostics<'_>> = files
2901 .iter()
2902 .filter(|f| closure.contains(&f.file_id(db)))
2903 .map(|f| FileDiagnostics {
2904 file: f.file_id(db),
2905 source: f.text(db),
2906 suppressions: suppressions_query(db, *f),
2907 lowering: &lowered_query(db, project, *f).diagnostics,
2908 })
2909 .collect();
2910 let opts = project.analysis_options(db);
2911 let types = opts.type_policy();
2912 let (mut errors, mut warnings) =
2913 partition_diagnostics(&inputs, &diagnostics, disable_all, types, &opts.lints);
2914
2915 if has_errors_in_closure_query(db, project) {
2916 return LirProduct {
2917 program: None,
2918 errors,
2919 warnings,
2920 };
2921 }
2922
2923 let lowering = lir_lowering_query(db, project);
2924 errors.extend(lowering.errors);
2925 warnings.extend(lowering.warnings);
2926
2927 LirProduct {
2928 program: lowering.program,
2929 errors,
2930 warnings,
2931 }
2932}
2933
2934/// Outcome of the full pipeline: compiled [`StoryData`] or the diagnostics
2935/// that prevented it. Batch compile = pull this one query (spec §5).
2936#[derive(Debug, Clone, Default, PartialEq)]
2937pub struct CompileProduct {
2938 /// The compiled story, if compilation succeeded.
2939 pub story: Option<Arc<StoryData>>,
2940 /// Error-severity diagnostics (compilation failed if non-empty).
2941 pub errors: Vec<Diagnostic>,
2942 /// Warning-severity diagnostics.
2943 pub warnings: Vec<Diagnostic>,
2944}
2945
2946/// Whole-project codegen: LIR → [`StoryData`] via `brink-codegen-inkb`.
2947///
2948/// `brink_codegen_inkb::emit` only fails on a `Program` that violates an
2949/// invariant an earlier, non-suppressible LIR-lowering diagnostic (E057) is
2950/// supposed to guarantee — see `CodegenError`'s doc comment and #586. That
2951/// can't happen via this query today (`lir.program` is only `Some` when
2952/// `lir.errors` is already empty, which requires E057 to not have fired),
2953/// but codegen has no way to prove that structurally, so this still handles
2954/// the `Err` case for real rather than assuming it away: surfaced as an
2955/// `E060` compile error (no meaningful source span survives into codegen
2956/// for this class of defect, so it's anchored at the project entry file
2957/// with an empty range) rather than silently downgrading to `story: None`
2958/// with an empty `errors` — which would look like "nothing to compile" to
2959/// every caller instead of "codegen refused to compile this."
2960/// Populate the T2-3 `EffectRows` table (#862, `docs/effects-spec.md` §11):
2961/// one factored row per inferable definition (every knot/stitch — the host's
2962/// resume-scheduling estimate, §12.1). Rows are read straight off the advisory
2963/// [`effects_query`] fixpoint and lowered to wire vocabulary:
2964///
2965/// - `reads`/`writes` cells ride through as [`DefinitionId`]s (already sorted —
2966/// they come from `BTreeSet`s).
2967/// - each call-kind name is interned into the story's `name_table`
2968/// (find-or-append, in the row's sorted call order for determinism) and
2969/// emitted as a [`CallAtom`] with the capability-parameter slot populated
2970/// `Any` (component-granular, the v1 value) and the reserved
2971/// handle-parameter slot left `None`.
2972/// - the per-dispatch entry list is empty in v1 (call-through-value is inferred
2973/// as opaque, folded into the direct part) — but the row structure ships the
2974/// slot so §7 narrowing is not structurally foreclosed.
2975/// - **#882 freeze semantics**: `is_entry` is `false` exactly when `def` is in
2976/// `story.private_defs` (already populated by codegen from
2977/// `Program::private_defs` — `#@private`, `docs/modules-spec.md` §4 — by
2978/// the time this runs), `true` otherwise. This is the *only* filter T2-3 was
2979/// missing: every row still ships regardless (a `#@private` knot/stitch can
2980/// still be captured as a fn-value token a *public* path holds, and the
2981/// dispatch-narrowing machinery resolves that token by `DefinitionId`, not
2982/// by name — `#@private` hides the name, not the cell). `is_entry` only
2983/// gates whether the row is a legitimate *host-lookup* target; it is never
2984/// used to drop a row from this table.
2985///
2986/// Appending call names to `name_table` is safe for inertness: existing
2987/// `NameId` indices are unchanged, and the only references to the appended
2988/// names are from `effect_rows`, which the runtime does not read.
2989#[expect(clippy::cast_possible_truncation)]
2990fn populate_effect_rows(db: &dyn salsa::Database, project: ProjectInput, story: &mut StoryData) {
2991 let inferable = inferable_defs_query(db, project);
2992 if inferable.is_empty() {
2993 return;
2994 }
2995
2996 // Owned name→id lookup so we can both read and append to `name_table`.
2997 let mut name_lookup: BTreeMap<String, u16> = story
2998 .name_table
2999 .iter()
3000 .enumerate()
3001 .map(|(i, s)| (s.clone(), i as u16))
3002 .collect();
3003
3004 // `story.private_defs` is sorted ascending by raw id (codegen hands it
3005 // straight from `Program::private_defs`, itself sorted by
3006 // `brink_ir::lir::lower::build_prelude_decls` — see that fn's doc). Cloned
3007 // once up front (small — one `u64` per `#@private` def, empty for the
3008 // all-public pre-modules world) so the loop below can freely mutate
3009 // `story.name_table` alongside without a field-borrow conflict; membership
3010 // is then a deterministic, order-independent binary search (mirrors
3011 // `brink_runtime::Program::is_private`).
3012 let private_defs = story.private_defs.clone();
3013 let is_private = |def: DefinitionId| {
3014 private_defs
3015 .binary_search_by_key(&def.to_raw(), |d| d.to_raw())
3016 .is_ok()
3017 };
3018
3019 let mut rows: Vec<EffectRowEntry> = Vec::with_capacity(inferable.len());
3020 for &def in inferable {
3021 let Some(row) = effects_query(db, project, DefKey::new(db, def)) else {
3022 continue;
3023 };
3024 let mut calls: Vec<CallAtom> = Vec::with_capacity(row.calls.len());
3025 for name in &row.calls {
3026 let id = if let Some(&id) = name_lookup.get(name) {
3027 id
3028 } else {
3029 let id = story.name_table.len() as u16;
3030 story.name_table.push(name.clone());
3031 name_lookup.insert(name.clone(), id);
3032 id
3033 };
3034 calls.push(CallAtom {
3035 name: NameId(id),
3036 capability: CapabilityParam::Any,
3037 handle_param: None,
3038 });
3039 }
3040 rows.push(EffectRowEntry {
3041 def,
3042 is_entry: !is_private(def),
3043 direct: DirectEffects {
3044 reads: row.reads.iter().copied().collect(),
3045 writes: row.writes.iter().copied().collect(),
3046 calls,
3047 // §6.1 (issue #1680): the wire's `EffectRows` section stays
3048 // one **ground** row per def, so a row still carrying a row
3049 // variable is *closed* to opaque here — the conservative
3050 // direction, and byte-identical to what shipped before holes
3051 // existed. Fork C's ruled encoding (an explicit hole slot,
3052 // filled by §7's token lookup) is the remaining wire half and
3053 // lands with runtime narrowing (#1723); the section is
3054 // section-locally versioned so it can grow without a format
3055 // bump.
3056 opaque: row.is_pessimal(),
3057 // NS-A2 (issue #1108): the three new row dimensions ship
3058 // straight from the analyzer's inferred row.
3059 emits: row.emits,
3060 tags: row.tags,
3061 faults: row.faults,
3062 },
3063 dispatches: Vec::new(),
3064 });
3065 }
3066 // `inferable` is a `BTreeSet`, so `rows` is already sorted by `def`.
3067 story.effect_rows = rows;
3068}
3069
3070/// Reads [`lir_in_closure_query`], not [`lir_query`] (issue #1032 collapse
3071/// ruling): `db.story_data()` — `compileProject`'s artifact path — gates on
3072/// `entry`'s `INCLUDE` closure only, so an error in some other file sharing
3073/// the session db no longer blocks this entry's build. `db.lir_product()`/
3074/// `db.has_errors()` stay on [`lir_query`]/[`has_errors_query`], whole-project
3075/// as before.
3076#[salsa::tracked(returns(ref))]
3077pub(crate) fn story_data_query(db: &dyn salsa::Database, project: ProjectInput) -> CompileProduct {
3078 let lir = lir_in_closure_query(db, project);
3079 let Some(program) = lir.program.as_ref() else {
3080 return CompileProduct {
3081 story: None,
3082 errors: lir.errors.clone(),
3083 warnings: lir.warnings.clone(),
3084 };
3085 };
3086 match brink_codegen_inkb::emit(program) {
3087 Ok(mut story) => {
3088 // T2-3 (#862, `docs/effects-spec.md` §11): first real emission into
3089 // the `EffectRows` section. Codegen has no analyzer access, so the
3090 // rows are attached here — this query is the one canonical codegen
3091 // site (FG-6), so there is exactly one emission point. The rows are
3092 // additive metadata the runtime does not consume yet, so episodes
3093 // stay byte-identical (the linker never reads `effect_rows`).
3094 populate_effect_rows(db, project, &mut story);
3095 CompileProduct {
3096 story: Some(Arc::new(story)),
3097 errors: lir.errors.clone(),
3098 warnings: lir.warnings.clone(),
3099 }
3100 }
3101 Err(err) => {
3102 let mut errors = lir.errors.clone();
3103 errors.push(Diagnostic {
3104 file: project.entry(db).unwrap_or(FileId(0)),
3105 range: rowan::TextRange::default(),
3106 message: format!("{}: {err}", DiagnosticCode::E060.title()),
3107 code: DiagnosticCode::E060,
3108 });
3109 CompileProduct {
3110 story: None,
3111 errors,
3112 warnings: lir.warnings.clone(),
3113 }
3114 }
3115 }
3116}
3117
3118// ─── Shared diagnostic partitioning ──────────────────────────────────
3119
3120/// Per-file inputs to [`partition_diagnostics`].
3121pub struct FileDiagnostics<'a> {
3122 /// The file these diagnostics belong to.
3123 pub file: FileId,
3124 /// The file's source text (for line-directive matching).
3125 pub source: &'a str,
3126 /// The file's parsed suppression directives.
3127 pub suppressions: &'a Suppressions,
3128 /// The file's lowering + syntax diagnostics.
3129 pub lowering: &'a [Diagnostic],
3130}
3131
3132/// Default (empty) suppressions for analysis diagnostics that reference a
3133/// file absent from the input set — mirrors the old driver's
3134/// `unwrap_or_default` behavior.
3135static NO_SUPPRESSIONS: Suppressions = Suppressions {
3136 disable_all: false,
3137 disable_file: false,
3138 line_directives: std::collections::BTreeMap::new(),
3139 allow_scopes: Vec::new(),
3140};
3141
3142/// Collect all diagnostics (lowering + analysis), apply suppressions, and
3143/// partition into `(errors, warnings)`.
3144///
3145/// This is the single implementation behind both `brink-driver`'s
3146/// `collect_diagnostics` and the [`lir_query`] gate — extracted so the query
3147/// path and the legacy driver path cannot drift. `files` must be ordered by
3148/// [`FileId`] (both callers iterate the sorted file set).
3149///
3150/// `disable_all`: whether the entry file carries `brink-disable-all`
3151/// (compiler mode skips analysis diagnostics entirely); pass `false` for LSP
3152/// mode where analysis diagnostics are always included.
3153///
3154/// `types`: the project's TM-3 `types` policy — every diagnostic is
3155/// partitioned by [`brink_analyzer::effective_severity`], not the raw
3156/// [`DiagnosticCode::severity`] default, so `E063` (annotation-vs-inference
3157/// mismatch) partitions as an error under `types = strict` and a warning
3158/// under `types = gradual` (the #640-round ruling) no matter which of this
3159/// function's two callers ([`lir_query`] or `brink-driver`'s
3160/// `collect_diagnostics`) is asking.
3161///
3162/// `lints`: the project's resolved `[lints]` policy (issue #1160), the other
3163/// input [`brink_analyzer::effective_severity`] partitions by — per-code
3164/// `deny`/`warn`/`allow`/`info`/`hint` overrides (issue #1162 added the
3165/// latter two) plus `deny-warnings`.
3166#[must_use]
3167pub fn partition_diagnostics(
3168 files: &[FileDiagnostics<'_>],
3169 analysis_diagnostics: &[Diagnostic],
3170 disable_all: bool,
3171 types: brink_analyzer::TypePolicy,
3172 lints: &brink_analyzer::LintPolicy,
3173) -> (Vec<Diagnostic>, Vec<Diagnostic>) {
3174 let mut errors = Vec::new();
3175 let mut warnings = Vec::new();
3176
3177 let mut partition = |d: Diagnostic| {
3178 if brink_analyzer::effective_severity(d.code, types, lints) == Severity::Error {
3179 errors.push(d);
3180 } else {
3181 warnings.push(d);
3182 }
3183 };
3184
3185 // Per-file lowering diagnostics.
3186 for input in files {
3187 let filtered = apply_suppressions(
3188 input.file,
3189 input.source,
3190 input.lowering.to_vec(),
3191 input.suppressions,
3192 );
3193 for d in filtered {
3194 partition(d);
3195 }
3196 }
3197
3198 // Analysis diagnostics (unless disable_all).
3199 if !disable_all {
3200 let mut by_file: LookupMap<FileId, Vec<Diagnostic>> = LookupMap::new();
3201 for d in analysis_diagnostics {
3202 by_file.entry(d.file).or_default().push(d.clone());
3203 }
3204 // Sort by FileId for determinism.
3205 let mut file_ids: Vec<_> = by_file.keys().copied().collect();
3206 file_ids.sort_by_key(|id| id.0);
3207 for fid in file_ids {
3208 let diags = by_file.remove(&fid).unwrap_or_default();
3209 let (source, suppressions) = files
3210 .iter()
3211 .find(|input| input.file == fid)
3212 .map_or(("", &NO_SUPPRESSIONS), |input| {
3213 (input.source, input.suppressions)
3214 });
3215 let filtered = apply_suppressions(fid, source, diags, suppressions);
3216 for d in filtered {
3217 partition(d);
3218 }
3219 }
3220 }
3221
3222 (errors, warnings)
3223}
3224
3225// ─── Per-file lowering (ported from the retired `set_file` path) ─────
3226
3227/// Lower one parsed file to HIR + manifest + diagnostics.
3228///
3229/// The composition is the pre-salsa `set_file`'s (per-knot lowering +
3230/// top-level lowering + assembly + syntax errors), restructured by #3088 so
3231/// every construct is lowered exactly once: `lower_declarations` harvests
3232/// the declaration surface (values AND their diagnostics, one walk),
3233/// `lower_top_level` lowers only top-level content (stitches, root weave,
3234/// `TODO:` notes), and `lower_single_knot` runs per knot. Before #3088 the
3235/// declarations came from a discarded whole-file `lower()` call — every
3236/// knot and declaration lowered twice per edit, and the file-level
3237/// `#@module`/`#@was` arbitration diagnostics (`E095`/`E049`) silently
3238/// dropped with that call's sink; both defects are gone (the arbitration
3239/// pair now reaches consumers, pinned by `module_arbitration_diagnostics_
3240/// reach_the_db_road` in `brink-db`'s tests). The manifest is not
3241/// assembled from fragments (B0.4, docs/hir-admission-contract.md Q3(b),
3242/// issue #1173): `project_manifest` derives the whole `SymbolManifest`
3243/// from the fully assembled `HirFile` in one pass.
3244fn lower_file(file_id: FileId, parse: &Parse) -> LoweredFile {
3245 let tree = parse.tree();
3246
3247 // Per-knot lowering (document order).
3248 let knot_entries: Vec<_> = tree
3249 .knots()
3250 .map(|knot_ast| lower_single_knot(file_id, &knot_ast))
3251 .collect();
3252
3253 // Top-level content lowering (stitches, root weave, top-level notes).
3254 let (root_content, top_level_knots, top_diagnostics) = lower_top_level(file_id, &tree);
3255
3256 // Declaration surface: values + diagnostics from one walk, plus module
3257 // identity, imports, and directive collections. Knots and root content
3258 // land in the skeleton below.
3259 let (mut hir, decl_diagnostics) = lower_declarations(file_id, &tree);
3260 hir.knots = knot_entries
3261 .iter()
3262 .filter_map(|(knot, _)| knot.clone())
3263 .collect();
3264 hir.knots.extend(top_level_knots);
3265 hir.root_content = root_content;
3266
3267 let manifest = project_manifest(&hir);
3268
3269 // Merge diagnostics (declaration + module arbitration first, matching
3270 // the retired composition's decl-first order), then surface
3271 // parser/syntax errors as compile diagnostics (`E037`) so malformed
3272 // source fails the compile.
3273 let mut diagnostics = decl_diagnostics;
3274 diagnostics.extend(top_diagnostics);
3275 for (_, knot_diags) in &knot_entries {
3276 diagnostics.extend(knot_diags.iter().cloned());
3277 }
3278 diagnostics.extend(parse.errors().iter().map(|e| Diagnostic {
3279 file: file_id,
3280 range: e.range,
3281 message: e.message.clone(),
3282 code: DiagnosticCode::E037,
3283 }));
3284
3285 // Anonymous-container state lint (`E157`, issue #1674): off/info by
3286 // default, `Warning`-flow-shaped otherwise — folded into `diagnostics`
3287 // (never `admission`) so it flows through `apply_suppressions`/
3288 // `effective_severity` in `partition_diagnostics` exactly like `E151`
3289 // below does for native, and is configurable through `[lints]`/
3290 // `//brink-disable` like any other tier-able diagnostic.
3291 diagnostics.extend(brink_analyzer::check_anonymous_stateful(file_id, &hir));
3292
3293 // B0.3 admission validator (docs/hir-admission-contract.md §4.2, issue
3294 // #1172): a loud, non-suppressible pass wired directly at this seam so
3295 // it runs on every lowering (NF-6, always-on). Kept in its own field —
3296 // never folded into `diagnostics` above, which flows through
3297 // `apply_suppressions` in `partition_diagnostics`.
3298 let file_len = parse.syntax().text_range().end();
3299 let admission = brink_analyzer::validate_admission(file_id, &hir, &manifest, file_len);
3300
3301 LoweredFile {
3302 hir,
3303 manifest,
3304 diagnostics,
3305 admission,
3306 }
3307}
3308
3309/// Lower one native `.brink` file to HIR (B0.10a, the native compile seam,
3310/// issue #1106) — the frontend-specific sibling of [`lower_file`], producing
3311/// the *same* [`LoweredFile`] so everything downstream (analysis, LIR,
3312/// codegen) is byte-for-byte frontend-agnostic.
3313///
3314/// Unlike ink, native lowering is a single whole-file entry point
3315/// (`lower_native::lower`) — there is no per-knot / top-level split to
3316/// reassemble, and it returns its own [`project_manifest`]-derived manifest
3317/// (B0.4) already, so this composes rather than re-deriving. Error-severity
3318/// syntax errors are surfaced as the same non-suppressible `E037` compile
3319/// diagnostic `lower_file` uses; Warning-severity ones (issue #1263 — e.g.
3320/// `<-` outside a choice point) map to `E131` instead, which
3321/// `DiagnosticCode::severity` reports as `Severity::Warning` so it never
3322/// gates `has_errors_query`/`has_errors_in_closure_query`. The B0.3
3323/// admission validator runs at the same seam (NF-6, always-on).
3324///
3325/// `external` is issue #2289's cross-file claiming injection: every
3326/// `@[convention]` handler declared in the project's configured
3327/// conventions module, resolved by [`external_claim_handlers_query`] and
3328/// threaded through by [`lowered_query`] — `None` for [`raw_lowered_query`]
3329/// (project-independent lowering) and for the conventions module's own
3330/// file. See `brink_ir::hir::lower_native::lower_with_conventions`'s own
3331/// doc for what merging it in changes.
3332fn lower_native_file(
3333 file_id: FileId,
3334 parse: &NativeParse,
3335 external: Option<&[brink_ir::ClaimHandlerDecl]>,
3336) -> LoweredFile {
3337 let tree = parse.tree();
3338
3339 let (hir, manifest, mut diagnostics) =
3340 brink_ir::hir::lower_native::lower_with_conventions(file_id, &tree, external);
3341
3342 // Surface parser diagnostics as compile diagnostics, split by severity:
3343 // `Error` becomes the non-suppressible `E037` (malformed source fails
3344 // the compile, mirrors `lower_file`); `Warning` becomes `E131`, which
3345 // is advisory only and must never block compilation.
3346 diagnostics.extend(parse.errors().iter().map(|e| Diagnostic {
3347 file: file_id,
3348 range: e.range,
3349 message: e.message.clone(),
3350 code: match e.severity {
3351 brink_syntax_native::ParseSeverity::Error => DiagnosticCode::E037,
3352 brink_syntax_native::ParseSeverity::Warning => DiagnosticCode::E131,
3353 },
3354 }));
3355
3356 // Native lint: asymmetric choice-branch dead-end (`E151`, issue #1219,
3357 // decision-log 2026-07-22 "Flows end implicitly (native)" item 4) — the
3358 // relocated residual value of ink's retired "ran out of content" error.
3359 // Deliberately folded into `diagnostics`, never `admission`: unlike the
3360 // B0.9 accept-list below, this is `Severity::Warning`-base, on by
3361 // default (not opt-in — see the lint module's own doc), and
3362 // configurable/suppressible through `[lints]`/`//brink-disable` like
3363 // any other tier-able diagnostic — it must flow through
3364 // `apply_suppressions`/`effective_severity` in `partition_diagnostics`,
3365 // which only `diagnostics` does.
3366 diagnostics.extend(brink_analyzer::check_native_choice_dead_end(file_id, &hir));
3367
3368 // Anonymous-container state lint (`E157`, issue #1674) — see `lower_file`'s
3369 // identical wiring comment; the check itself is frontend-agnostic (it
3370 // only reads `Choice::is_sticky`/`label` and `Sequence::kind`/branches,
3371 // both populated the same way by ink and native lowering).
3372 diagnostics.extend(brink_analyzer::check_anonymous_stateful(file_id, &hir));
3373
3374 // B0.3 admission validator (docs/hir-admission-contract.md §4.2, issue
3375 // #1172): the same loud, non-suppressible pass `lower_file` runs, kept in
3376 // its own `LoweredFile` field so it never flows through
3377 // `apply_suppressions`.
3378 let file_len = parse.syntax().text_range().end();
3379 let mut admission = brink_analyzer::validate_admission(file_id, &hir, &manifest, file_len);
3380
3381 // B0.9 native accept-list gate (docs/hir-admission-contract.md §4.4/§5
3382 // Q6, docs/b0-sequencing.md §B0.9, issue #1179): the inverse of the ink
3383 // `dialect_gate` reject-list, and native-only — this is the seam that
3384 // keys it off the producing frontend at the pipeline level (F-I#10):
3385 // `lower_native_file` only ever runs for a `.brink` file, never an
3386 // `.ink` one, so calling this here (and nowhere in `lower_file`) is the
3387 // whole dispatch, with no tag carried on the tree itself. Appended into
3388 // the same non-suppressible `admission` field B0.3 populates above —
3389 // both are loud, always-on checks at this exact seam.
3390 admission.extend(brink_analyzer::validate_native_accept_list(file_id, &hir));
3391
3392 LoweredFile {
3393 hir,
3394 manifest,
3395 diagnostics,
3396 admission,
3397 }
3398}
3399
3400#[cfg(test)]
3401mod tests {
3402 use super::{DefKey, call_graph_query, def_effect_atoms_query, inferable_defs_query};
3403 use crate::db::ProjectDb;
3404
3405 /// Issue #1736 finding (BLOCKING): the parity tests in
3406 /// `crates/internal/brink-db/tests/query_equivalence.rs` compare the two
3407 /// call-graph constructions' *outputs* on a fixture where they provably
3408 /// cannot disagree — `resolve_pending_value_calls` re-records every
3409 /// traced `#fn`/`bind` target as a `direct_calls` edge at its call site,
3410 /// so a fixture that ever calls what it creates can't exercise a
3411 /// `creates_fn_values`-outside-`direct_calls` shape. This test instead
3412 /// asserts the *edge set* directly: for every def, `call_graph_query`'s
3413 /// outgoing edges must cover `EffectAtoms.direct_calls ∪
3414 /// EffectAtoms.creates_fn_values` — the subset property
3415 /// `docs/effects-spec.md` §6.1a documents and
3416 /// `every_fn_value_creation_target_is_also_a_call_graph_edge`
3417 /// (`brink-analyzer`'s `infer::mod` tests) pins from the atom side.
3418 /// Unlike the output-parity tests, this goes red the day #1727 (lambda
3419 /// literals) breaks that subset property, independent of whether any
3420 /// particular fixture's diagnostics happen to still agree.
3421 #[test]
3422 fn call_graph_covers_direct_calls_and_creates_fn_values() {
3423 let mut db = ProjectDb::new();
3424 db.set_file(
3425 "main.ink",
3426 "VAR total = 0\nVAR extra = 0\n\
3427 === function bar(): int ===\n~ total = total + 1\n~ return total\n\
3428 === function baz(): int ===\n~ extra = extra + 100\n~ return extra\n\
3429 === function user(cond: int): int ===\n\
3430 ~ temp f = #fn(bar)\n{cond:\n ~ f = #fn(baz)\n}\n~ return f()\n"
3431 .to_owned(),
3432 );
3433
3434 let (salsa, project) = db.salsa_and_project();
3435 let graph = call_graph_query(salsa, project);
3436
3437 for &def in inferable_defs_query(salsa, project) {
3438 let atoms = def_effect_atoms_query(salsa, project, DefKey::new(salsa, def));
3439 let outgoing = graph.edges.get(&def).cloned().unwrap_or_default();
3440 for &callee in atoms
3441 .direct_calls
3442 .iter()
3443 .chain(atoms.creates_fn_values.iter())
3444 {
3445 assert!(
3446 outgoing.contains(&callee),
3447 "call_graph_query's edges for {def:?} do not cover \
3448 direct_calls ∪ creates_fn_values: missing edge to \
3449 {callee:?} (direct_calls={:?}, creates_fn_values={:?}, \
3450 graph edges={outgoing:?})",
3451 atoms.direct_calls,
3452 atoms.creates_fn_values,
3453 );
3454 }
3455 }
3456 }
3457
3458 /// Issue #2368: [`has_recognized_source_extension`]/
3459 /// [`is_native_source_path`] became `pub` so `brink-lsp` could route its
3460 /// own file-watcher classification through them instead of carrying two
3461 /// ad-hoc, case-sensitive copies — pin the case-insensitive contract
3462 /// those callers now depend on directly.
3463 #[test]
3464 fn public_extension_predicates_are_case_insensitive() {
3465 use super::{has_recognized_source_extension, is_native_source_path};
3466
3467 assert!(has_recognized_source_extension("story.ink"));
3468 assert!(has_recognized_source_extension("story.INK"));
3469 assert!(has_recognized_source_extension("main.brink"));
3470 assert!(has_recognized_source_extension("main.BRINK"));
3471 assert!(!has_recognized_source_extension("brink.toml"));
3472 assert!(!has_recognized_source_extension("notes.txt"));
3473
3474 assert!(is_native_source_path("main.brink"));
3475 assert!(is_native_source_path("main.BRINK"));
3476 assert!(!is_native_source_path("story.ink"));
3477 assert!(!is_native_source_path("story.INK"));
3478 assert!(!is_native_source_path("no_extension"));
3479 }
3480}