Skip to content

Commit 38459e9

Browse files
committed
Fix stability of Definition entities
1 parent 14bacca commit 38459e9

6 files changed

Lines changed: 218 additions & 49 deletions

File tree

crates/oak_db/src/definition.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ use aether_syntax::RBinaryExpression;
22
use aether_syntax::RSyntaxKind;
33
use biome_rowan::AstNode;
44
use biome_rowan::TextRange;
5+
use oak_semantic::semantic_index::DefinitionId;
56
use oak_semantic::semantic_index::DefinitionKind;
67
use oak_semantic::semantic_index::ScopeId;
8+
use rustc_hash::FxHashMap;
79

10+
use crate::Db;
811
use crate::File;
912
use crate::Name;
1013

@@ -86,3 +89,70 @@ fn is_right_assignment(node: &RBinaryExpression) -> bool {
8689
)
8790
})
8891
}
92+
93+
#[salsa::tracked]
94+
impl<'db> File {
95+
/// Look up the `Definition` entity for the binding at `(scope, def_id)` in
96+
/// this file. The entity has already been minted in [`File::definitions`]
97+
/// and this is a plain lookup. The salsa id stays stable across edits that
98+
/// renumber `def_id`.
99+
pub(crate) fn definition(
100+
self,
101+
db: &'db dyn Db,
102+
scope: ScopeId,
103+
def_id: DefinitionId,
104+
) -> Option<Definition<'db>> {
105+
self.definitions(db)
106+
.by_site
107+
.get(&DefinitionSite { scope, def_id })
108+
.copied()
109+
}
110+
111+
/// Mint every `Definition` in this file, keyed by its `(scope, def_id)`
112+
/// site. This is the single `Definition::new` call site: every resolution
113+
/// path looks entities up here rather than minting its own, so the same
114+
/// binding has one salsa id no matter how it's reached.
115+
///
116+
/// Identity is `(file, scope, name)` (see [`Definition`]). `def_id` is only
117+
/// the lookup key, never part of identity, so inserting a binding earlier
118+
/// in a scope doesn't churn the ids of the others.
119+
#[salsa::tracked(returns(ref))]
120+
fn definitions(self, db: &'db dyn Db) -> FileDefinitions<'db> {
121+
let index = self.semantic_index(db);
122+
let mut by_site = FxHashMap::default();
123+
124+
for scope in index.scope_ids() {
125+
let symbols = index.symbols(scope);
126+
for (def_id, def) in index.definitions(scope).iter() {
127+
let name = Name::new(db, symbols.symbol(def.symbol()).name());
128+
let definition = Definition::new(db, self, scope, name, def.kind().clone());
129+
by_site.insert(DefinitionSite { scope, def_id }, definition);
130+
}
131+
}
132+
133+
FileDefinitions { by_site }
134+
}
135+
}
136+
137+
/// Every `Definition` in a file, keyed by its definition site.
138+
///
139+
/// A `salsa::Update` struct rather than a bare map so the salsa-tracked
140+
/// `File::definitions` query can store it: salsa needs the `Update` impl to
141+
/// handle the `Definition<'db>` lifetime that the map values carry.
142+
#[derive(Debug, PartialEq, Eq, salsa::Update)]
143+
struct FileDefinitions<'db> {
144+
by_site: FxHashMap<DefinitionSite, Definition<'db>>,
145+
}
146+
147+
/// Map key for [`FileDefinitions`]: a binding's `(scope, def_id)` site.
148+
///
149+
/// A named struct rather than a `(ScopeId, DefinitionId)` tuple so it can
150+
/// derive `salsa::Update` (the salsa map key must implement it). The derive
151+
/// falls back to a plain replace for the `'static` index fields, which don't
152+
/// implement `Update` themselves (`oak_semantic` has no salsa dependency).
153+
/// Mirrors ty's `DefinitionNodeKey`.
154+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, salsa::Update)]
155+
struct DefinitionSite {
156+
scope: ScopeId,
157+
def_id: DefinitionId,
158+
}

crates/oak_db/src/file_exports.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ impl File {
6161
#[salsa::tracked(returns(ref), cycle_result = exports_cycle_result)]
6262
pub fn exports(self, db: &dyn Db) -> FileExports {
6363
let mut entries: HashMap<String, ExportEntry> = HashMap::new();
64-
for (name, def) in self.semantic_index(db).exports() {
64+
for (name, (_def_id, def)) in self.semantic_index(db).exports() {
6565
let entry = match def.kind() {
6666
DefinitionKind::Import {
6767
file: target_url,

crates/oak_db/src/file_resolve.rs

Lines changed: 11 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ use std::collections::HashSet;
22
use std::rc::Rc;
33

44
use biome_rowan::TextSize;
5-
use oak_semantic::DefinitionId;
65
use oak_semantic::ScopeId;
76

87
use crate::Db;
@@ -93,8 +92,9 @@ impl<'db> File {
9392
/// visible subset of attaches / collation predecessors.
9493
///
9594
/// Not `#[salsa::tracked]` because keying on `(self, offset)` would
96-
/// balloon the cache. `Definition` creation delegates to the tracked
97-
/// [`File::resolve_export()`] and [`File::intern_definition()`].
95+
/// balloon the cache. The `Definition` entities it returns are minted by
96+
/// the tracked [`File::definitions()`], so they stay cached and
97+
/// identity-stable even though this lookup isn't.
9898
pub fn resolve_at(self, db: &'db dyn Db, offset: TextSize) -> Option<Definition<'db>> {
9999
let index = self.semantic_index(db);
100100
let (use_scope, _use_id, use_site) = index.use_at(offset)?;
@@ -111,12 +111,7 @@ impl<'db> File {
111111
let file_scope = ScopeId::from(0);
112112
if let Some((binding_scope, def_id, _def)) = index.resolve(&name, use_scope) {
113113
if binding_scope != file_scope {
114-
return Some(self.intern_definition(
115-
db,
116-
binding_scope,
117-
def_id,
118-
Name::new(db, name.as_str()),
119-
));
114+
return self.definition(db, binding_scope, def_id);
120115
}
121116
}
122117

@@ -173,23 +168,13 @@ impl<'db> File {
173168

174169
match current_file.exports(db).get(current_name.as_ref())? {
175170
ExportEntry::Local => {
176-
// Fetch exports again, this time through the semantic index
177-
// to get the volatile `kind` field that the firewall query
178-
// `File::exports()` doesn't expose.
179-
let kind = current_file
180-
.semantic_index(db)
181-
.exports()
182-
.get(current_name.as_ref())
183-
.map(|def| def.kind().clone())?;
184-
185-
let file_scope = ScopeId::from(0);
186-
return Some(Definition::new(
187-
db,
188-
current_file,
189-
file_scope,
190-
Name::new(db, current_name.as_ref()),
191-
kind,
192-
));
171+
// Look up the file-scope binding through the semantic index
172+
// to recover its `def_id`, then fetch the interned
173+
// definition. `exports()` returns the last-wins
174+
// definitions, so this is the right binding for the name.
175+
let index = current_file.semantic_index(db);
176+
let def_id = index.exports().get(current_name.as_ref())?.0;
177+
return current_file.definition(db, ScopeId::from(0), def_id);
193178
},
194179

195180
ExportEntry::Import { file, name } => {
@@ -199,22 +184,4 @@ impl<'db> File {
199184
}
200185
}
201186
}
202-
203-
/// Intern the salsa-tracked `Definition` entity for a binding identified
204-
/// by `(scope, def_id)` in this file's semantic index. Wraps
205-
/// `Definition::new` in a tracked context, which is required to construct
206-
/// tracked structs.
207-
#[salsa::tracked]
208-
fn intern_definition(
209-
self,
210-
db: &'db dyn Db,
211-
scope: ScopeId,
212-
def_id: DefinitionId,
213-
name: Name<'db>,
214-
) -> Definition<'db> {
215-
let kind = self.semantic_index(db).definitions(scope)[def_id]
216-
.kind()
217-
.clone();
218-
Definition::new(db, self, scope, name, kind)
219-
}
220187
}

crates/oak_db/src/tests/file_resolve.rs

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,138 @@ fn test_definition_id_stable_across_body_edits() {
271271
assert_ne!(range1, range2);
272272
}
273273

274+
#[test]
275+
fn test_definition_id_stable_across_def_id_renumber_local_path() {
276+
// The function-scope local path looks up its `Definition` from the single
277+
// mint site `File::definitions`, whose identity is `(file, scope, name)`.
278+
// `def_id` is only the lookup key, never part of identity, so prepending
279+
// an unrelated binding inside the function (which renumbers x's `def_id`)
280+
// leaves x's salsa id unchanged. This is the same stability the export
281+
// path has in `test_definition_id_stable_across_body_edits`, now extended
282+
// to the local path.
283+
use biome_rowan::TextSize;
284+
use salsa::plumbing::AsId;
285+
286+
let content1 = "f <- function() {\nx <- 1\nx\n}\n";
287+
let use1 = content1.find("\nx\n").expect("standalone use of x") + 1;
288+
289+
let mut db = TestDb::new();
290+
let files = setup_workspace(&mut db, &[("w/a.R", content1)]);
291+
let file = files[0];
292+
293+
let id1 = file
294+
.resolve_at(&db, TextSize::from(use1 as u32))
295+
.expect("use of x resolves to its function-scope binding")
296+
.as_id();
297+
298+
// Prepend an unrelated binding inside the function so x's DefinitionId
299+
// shifts 0 -> 1 within the function scope.
300+
let content2 = "f <- function() {\nw <- 0\nx <- 1\nx\n}\n";
301+
let use2 = content2.find("\nx\n").expect("standalone use of x") + 1;
302+
file.set_contents(&mut db).to(content2.to_string());
303+
304+
let id2 = file
305+
.resolve_at(&db, TextSize::from(use2 as u32))
306+
.expect("use of x still resolves")
307+
.as_id();
308+
309+
assert_eq!(id1, id2);
310+
}
311+
312+
#[test]
313+
fn test_definitions_mints_distinct_entities_for_same_name_redefinition() {
314+
// Two file-scope `x` bindings share the `(file, scope, name)` id-fields.
315+
// The single mint site must create two distinct salsa entities rather than
316+
// collide or panic; salsa disambiguates same-id-field tracked structs by
317+
// creation order. Resolving `x` forces the mint of both (via `definitions`)
318+
// and must land on the last definition (offset 7).
319+
let mut db = TestDb::new();
320+
let files = setup_workspace(&mut db, &[("w/a.R", "x <- 1\nx <- 2\n")]);
321+
let file = files[0];
322+
323+
let def = file.resolve(&db, name(&db, "x")).expect("x should resolve");
324+
assert_eq!(def.name(&db).text(&db).as_str(), "x");
325+
let range = def.name_range(&db).expect("local binding has a name range");
326+
assert_eq!(usize::from(range.start()), 7);
327+
}
328+
329+
#[test]
330+
fn test_position_shift_keeps_id_and_does_not_invalidate_identity_consumers() {
331+
// A pure position shift (prepend a comment, no binding added or removed)
332+
// moves the binding's AstPtr but leaves `(file, scope, name)` and its
333+
// ordinal unchanged, so the salsa id is stable. A downstream query that
334+
// reads only identity therefore stays cached across the rebuild; only
335+
// consumers of `kind` (the moved AstPtr) would re-run.
336+
use salsa::plumbing::AsId;
337+
338+
use crate::Db;
339+
use crate::Definition;
340+
341+
#[salsa::tracked]
342+
fn name_len<'db>(db: &'db dyn Db, def: Definition<'db>) -> usize {
343+
def.name(db).text(db).len()
344+
}
345+
346+
let mut db = TestDb::new();
347+
let files = setup_workspace(&mut db, &[("w/a.R", "x <- 1\n")]);
348+
let file = files[0];
349+
350+
let (id1, range1) = {
351+
let def = file.resolve(&db, name(&db, "x")).expect("x resolves");
352+
let _ = name_len(&db, def);
353+
(def.as_id(), def.name_range(&db))
354+
};
355+
assert_eq!(db.executions("name_len"), 1);
356+
357+
// Pure position shift: x moves down a line, no binding added or removed.
358+
file.set_contents(&mut db)
359+
.to("# comment\nx <- 1\n".to_string());
360+
361+
let (id2, range2) = {
362+
let def = file.resolve(&db, name(&db, "x")).expect("x still resolves");
363+
let _ = name_len(&db, def);
364+
(def.as_id(), def.name_range(&db))
365+
};
366+
367+
// Same entity, and the name range moved (it really was a position shift).
368+
assert_eq!(id1, id2);
369+
assert_ne!(range1, range2);
370+
// The identity-only consumer was not re-executed by the position shift.
371+
assert_eq!(db.executions("name_len"), 1);
372+
}
373+
374+
#[test]
375+
fn test_same_name_sibling_insertion_churns_later_definition_id() {
376+
// TRACKING TEST for a known boundary, not a guarantee to preserve.
377+
//
378+
// Identity is `(file, scope, name)` plus salsa's creation-order
379+
// disambiguator among same-name siblings. Inserting *another* `x` earlier
380+
// in the scope shifts the ordinals of the later `x` definitions, so their
381+
// salsa ids churn even though their position-stability would otherwise
382+
// hold. This matches ty's `push_additional_definition` ordering. The test
383+
// exists to notice if salsa's disambiguation ever changes.
384+
use salsa::plumbing::AsId;
385+
386+
let mut db = TestDb::new();
387+
let files = setup_workspace(&mut db, &[("w/a.R", "x <- 1\nx <- 2\n")]);
388+
let file = files[0];
389+
390+
// `resolve` is last-wins, so it returns the final `x` (`x <- 2`), ordinal 1.
391+
let id1 = file.resolve(&db, name(&db, "x")).expect("x resolves").as_id();
392+
393+
// Insert another `x` at the top. The final `x` is still last-wins, but its
394+
// ordinal among same-name siblings shifts from 1 to 2.
395+
file.set_contents(&mut db)
396+
.to("x <- 0\nx <- 1\nx <- 2\n".to_string());
397+
398+
let id2 = file
399+
.resolve(&db, name(&db, "x"))
400+
.expect("x still resolves")
401+
.as_id();
402+
403+
assert_ne!(id1, id2);
404+
}
405+
274406
#[test]
275407
fn test_resolve_unbound_name_in_package_does_not_cycle() {
276408
// Without exports-only sibling chase, A's `resolve` would walk into

crates/oak_semantic/src/semantic_index.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,14 +126,14 @@ impl SemanticIndex {
126126
/// Top-level definitions exported by this file (definitions in the file scope).
127127
/// Includes `Import`-kind forwarding definitions from `source()` calls.
128128
/// Last definition of each name wins (later assignments shadow earlier ones).
129-
pub fn exports(&self) -> FxHashMap<&str, &Definition> {
129+
pub fn exports(&self) -> FxHashMap<&str, (DefinitionId, &Definition)> {
130130
let file_scope = ScopeId::from(0);
131131
let symbols = &self.symbol_tables[file_scope];
132132

133133
let mut exports = FxHashMap::default();
134-
for (_id, def) in self.definitions[file_scope].iter() {
134+
for (id, def) in self.definitions[file_scope].iter() {
135135
let name = symbols.symbol(def.symbol()).name();
136-
exports.insert(name, def);
136+
exports.insert(name, (id, def));
137137
}
138138

139139
exports

crates/oak_semantic/tests/integration/builder.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1512,7 +1512,7 @@ fn test_file_exports_last_def_wins() {
15121512
let exports = index.exports();
15131513
assert_eq!(exports.len(), 2);
15141514
// The range should be the second `foo` (offset 9..12)
1515-
let def = exports.get("foo").unwrap();
1515+
let (_def_id, def) = exports.get("foo").unwrap();
15161516
assert_eq!(def.range().start(), biome_rowan::TextSize::from(9));
15171517
}
15181518

0 commit comments

Comments
 (0)