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