rubydex 0.2.9 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +69 -3
  3. data/THIRD_PARTY_LICENSES.html +34 -1
  4. data/exe/rdx +131 -55
  5. data/ext/rubydex/declaration.c +1 -1
  6. data/ext/rubydex/definition.c +32 -4
  7. data/ext/rubydex/graph.c +14 -3
  8. data/ext/rubydex/query.c +105 -0
  9. data/ext/rubydex/query.h +8 -0
  10. data/ext/rubydex/reference.c +60 -0
  11. data/ext/rubydex/rubydex.c +2 -0
  12. data/ext/rubydex/utils.c +12 -0
  13. data/ext/rubydex/utils.h +5 -0
  14. data/lib/rubydex/version.rb +1 -1
  15. data/rbi/rubydex.rbi +22 -0
  16. data/rust/Cargo.lock +7 -0
  17. data/rust/rubydex/Cargo.toml +1 -0
  18. data/rust/rubydex/benches/graph_memory.rs +22 -4
  19. data/rust/rubydex/src/compile_assertions.rs +15 -0
  20. data/rust/rubydex/src/diagnostic.rs +1 -1
  21. data/rust/rubydex/src/indexing/rbs_indexer.rs +14 -2
  22. data/rust/rubydex/src/indexing/ruby_indexer.rs +49 -58
  23. data/rust/rubydex/src/indexing/ruby_indexer_tests.rs +72 -16
  24. data/rust/rubydex/src/main.rs +2 -124
  25. data/rust/rubydex/src/model/declaration.rs +0 -11
  26. data/rust/rubydex/src/model/definitions.rs +27 -26
  27. data/rust/rubydex/src/model/document.rs +43 -7
  28. data/rust/rubydex/src/model/graph.rs +40 -28
  29. data/rust/rubydex/src/model/id.rs +55 -0
  30. data/rust/rubydex/src/model/ids.rs +21 -9
  31. data/rust/rubydex/src/model/name.rs +35 -7
  32. data/rust/rubydex/src/model/references.rs +16 -13
  33. data/rust/rubydex/src/operation/ruby_builder.rs +48 -59
  34. data/rust/rubydex/src/query/cypher/schema.rs +790 -0
  35. data/rust/rubydex/src/query/cypher/schema_info.rs +161 -0
  36. data/rust/rubydex/src/query/cypher/tests.rs +228 -0
  37. data/rust/rubydex/src/query/cypher.rs +57 -0
  38. data/rust/rubydex/src/query.rs +2 -0
  39. data/rust/rubydex/src/resolution.rs +248 -227
  40. data/rust/rubydex/src/resolution_tests.rs +263 -65
  41. data/rust/rubydex-sys/src/declaration_api.rs +6 -3
  42. data/rust/rubydex-sys/src/definition_api.rs +27 -7
  43. data/rust/rubydex-sys/src/graph_api.rs +158 -14
  44. data/rust/rubydex-sys/src/reference_api.rs +58 -12
  45. metadata +8 -2
@@ -1,13 +1,9 @@
1
1
  use clap::{Parser, ValueEnum};
2
- use std::{
3
- fs, mem,
4
- path::{Path, PathBuf},
5
- time::{Duration, Instant},
6
- };
2
+ use std::{fs, mem, path::PathBuf};
7
3
 
8
4
  use rubydex::{
9
5
  dot,
10
- indexing::{self, IndexerBackend, LanguageId, build_local_graph},
6
+ indexing::{self, IndexerBackend},
11
7
  integrity, listing,
12
8
  model::graph::Graph,
13
9
  resolution::Resolver,
@@ -16,7 +12,6 @@ use rubydex::{
16
12
  timer::{Timer, time_it},
17
13
  },
18
14
  };
19
- use url::Url;
20
15
 
21
16
  #[derive(Parser, Debug)]
22
17
  #[command(name = "rubydex_cli", about = "A Static Analysis Toolkit for Ruby", version)]
@@ -61,21 +56,6 @@ struct Args {
61
56
  help = "Write orphan definitions report to specified file"
62
57
  )]
63
58
  report_orphans: Option<String>,
64
-
65
- #[arg(
66
- long = "incremental_cycle",
67
- value_name = "N",
68
- help = "After the initial build, run N incremental resolution cycles and report their timings"
69
- )]
70
- incremental_cycle: Option<usize>,
71
-
72
- #[arg(
73
- long = "incremental_files",
74
- value_name = "N",
75
- default_value_t = 1,
76
- help = "Number of files to re-index per incremental cycle"
77
- )]
78
- incremental_files: usize,
79
59
  }
80
60
 
81
61
  #[derive(Debug, Clone, ValueEnum)]
@@ -149,14 +129,6 @@ fn main() {
149
129
 
150
130
  let backend = IndexerBackend::from(&args.indexer);
151
131
 
152
- // The incremental benchmark re-indexes files after the initial build, so keep a copy of the
153
- // paths before `index_files` consumes them.
154
- let incremental_paths = if args.incremental_cycle.is_some() {
155
- file_paths.clone()
156
- } else {
157
- Vec::new()
158
- };
159
-
160
132
  let errors = time_it!(indexing, { indexing::index_files(&mut graph, file_paths, backend) });
161
133
 
162
134
  for error in errors {
@@ -174,12 +146,6 @@ fn main() {
174
146
  resolver.resolve();
175
147
  });
176
148
 
177
- // Incremental resolution benchmark. Runs before the stop-after check so it can be combined with
178
- // `--stop-after=resolution`.
179
- if let Some(cycles) = args.incremental_cycle {
180
- run_incremental_resolution(&mut graph, &incremental_paths, cycles, args.incremental_files, backend);
181
- }
182
-
183
149
  if let Some(StopAfter::Resolution) = args.stop_after {
184
150
  return exit(args.stats);
185
151
  }
@@ -241,91 +207,3 @@ fn main() {
241
207
  // Forget the graph so we don't have to wait for deallocation and let the system reclaim the memory at exit
242
208
  mem::forget(graph);
243
209
  }
244
-
245
- /// Simulates incremental editing to measure incremental resolution cost. For each cycle it
246
- /// re-indexes a rotating window of `files_per_cycle` files (parsing plus the same invalidation the
247
- /// LSP performs on save) and then re-runs resolution over the resulting pending work, reporting
248
- /// per-cycle and aggregate `resolve()` timings.
249
- ///
250
- /// With `--stats`, the `compute_descendants` breakdown printed in the timing summary reflects the
251
- /// last incremental cycle, since it is recorded on every `resolve()`.
252
- fn run_incremental_resolution(
253
- graph: &mut Graph,
254
- paths: &[PathBuf],
255
- cycles: usize,
256
- files_per_cycle: usize,
257
- backend: IndexerBackend,
258
- ) {
259
- if paths.is_empty() || cycles == 0 || files_per_cycle == 0 {
260
- eprintln!("Skipping incremental resolution: nothing to re-index");
261
- return;
262
- }
263
-
264
- let files_per_cycle = files_per_cycle.min(paths.len());
265
-
266
- let mut reindex_total = Duration::ZERO;
267
- let mut resolve_total = Duration::ZERO;
268
- let mut resolve_min = Duration::MAX;
269
- let mut resolve_max = Duration::ZERO;
270
-
271
- println!();
272
- println!("Incremental resolution ({cycles} cycle(s), {files_per_cycle} file(s)/cycle)");
273
- println!(" Scenario: no-op reindex; files are indexed again without changing their contents.");
274
- println!(" This should be the fastest incremental resolution path.");
275
- println!(" Add scenario-based benchmarks before optimizing incremental resolution.");
276
-
277
- for cycle in 0..cycles {
278
- // Re-index the window of files for this cycle. Rotating the window across cycles samples
279
- // different parts of the codebase rather than measuring the same delta repeatedly.
280
- let reindex_start = Instant::now();
281
- for i in 0..files_per_cycle {
282
- let path = &paths[(cycle * files_per_cycle + i) % paths.len()];
283
- reindex_file(graph, path, backend);
284
- }
285
- reindex_total += reindex_start.elapsed();
286
-
287
- let resolve_start = Instant::now();
288
- Resolver::new(graph).resolve();
289
- let elapsed = resolve_start.elapsed();
290
-
291
- resolve_total += elapsed;
292
- resolve_min = resolve_min.min(elapsed);
293
- resolve_max = resolve_max.max(elapsed);
294
-
295
- println!(
296
- " cycle {:>3}: resolve {:9.3}ms",
297
- cycle + 1,
298
- elapsed.as_secs_f64() * 1000.0
299
- );
300
- }
301
-
302
- let avg = resolve_total / u32::try_from(cycles).expect("cycle count fits in u32");
303
-
304
- println!(
305
- " resolve total {:.3}ms avg {:.3}ms min {:.3}ms max {:.3}ms",
306
- resolve_total.as_secs_f64() * 1000.0,
307
- avg.as_secs_f64() * 1000.0,
308
- resolve_min.as_secs_f64() * 1000.0,
309
- resolve_max.as_secs_f64() * 1000.0,
310
- );
311
- println!(
312
- " reindex+invalidate total {:.3}ms (parse + document merge, excluded from resolve above)",
313
- reindex_total.as_secs_f64() * 1000.0,
314
- );
315
- }
316
-
317
- /// Re-indexes a single file into the graph, running the same invalidation the LSP performs on save.
318
- fn reindex_file(graph: &mut Graph, path: &Path, backend: IndexerBackend) {
319
- let Ok(source) = fs::read_to_string(path) else {
320
- eprintln!("Failed to read file `{}`", path.display());
321
- return;
322
- };
323
- let Ok(url) = Url::from_file_path(path) else {
324
- eprintln!("Couldn't build URI from path `{}`", path.display());
325
- return;
326
- };
327
-
328
- let language = path.extension().map_or(LanguageId::Ruby, LanguageId::from);
329
- let local_graph = build_local_graph(url.to_string(), &source, &language, backend);
330
- graph.consume_document_changes(local_graph);
331
- }
@@ -192,10 +192,6 @@ macro_rules! namespace_declaration {
192
192
  &self.ancestors
193
193
  }
194
194
 
195
- pub fn ancestors_mut(&mut self) -> &mut Ancestors {
196
- &mut self.ancestors
197
- }
198
-
199
195
  #[must_use]
200
196
  pub fn clone_ancestors(&self) -> Ancestors {
201
197
  self.ancestors.clone()
@@ -552,13 +548,6 @@ impl Namespace {
552
548
  all_namespaces!(self, it => it.remove_descendant(descendant_id));
553
549
  }
554
550
 
555
- pub fn for_each_ancestor<F>(&self, mut f: F)
556
- where
557
- F: FnMut(&Ancestor),
558
- {
559
- all_namespaces!(self, it => it.ancestors().iter().for_each(&mut f));
560
- }
561
-
562
551
  pub fn for_each_descendant<F>(&self, mut f: F)
563
552
  where
564
553
  F: FnMut(&DeclarationId),
@@ -29,6 +29,7 @@ use crate::{
29
29
  assert_mem_size,
30
30
  model::{
31
31
  comment::Comment,
32
+ id::id_from_parts,
32
33
  ids::{self, ConstantReferenceId, DefinitionId, NameId, StringId, UriId},
33
34
  visibility::Visibility,
34
35
  },
@@ -673,13 +674,13 @@ impl ConstantAliasDefinition {
673
674
 
674
675
  #[must_use]
675
676
  pub fn id(&self) -> DefinitionId {
676
- DefinitionId::from(&format!(
677
- "{}{}{}{}",
678
- *self.alias_constant.uri_id(),
677
+ id_from_parts!(
678
+ DefinitionId;
679
+ self.alias_constant.uri_id().get(),
679
680
  self.alias_constant.offset().start(),
680
- *self.alias_constant.name_id(),
681
- *self.target_name_id,
682
- ))
681
+ self.alias_constant.name_id().get(),
682
+ self.target_name_id.get(),
683
+ )
683
684
  }
684
685
 
685
686
  #[must_use]
@@ -757,7 +758,7 @@ impl ConstantVisibilityDefinition {
757
758
 
758
759
  #[must_use]
759
760
  pub fn id(&self) -> DefinitionId {
760
- DefinitionId::from(&format!("{}{}{}", *self.uri_id, self.offset.start(), *self.target))
761
+ id_from_parts!(DefinitionId; self.uri_id.get(), self.offset.start(), self.target.get())
761
762
  }
762
763
 
763
764
  #[must_use]
@@ -837,7 +838,7 @@ impl MethodVisibilityDefinition {
837
838
 
838
839
  #[must_use]
839
840
  pub fn id(&self) -> DefinitionId {
840
- DefinitionId::from(&format!("{}{}{}", *self.uri_id, self.offset.start(), *self.str_id))
841
+ id_from_parts!(DefinitionId; self.uri_id.get(), self.offset.start(), self.str_id.get(), self.flags.bits())
841
842
  }
842
843
 
843
844
  #[must_use]
@@ -1123,7 +1124,7 @@ impl AttrAccessorDefinition {
1123
1124
 
1124
1125
  #[must_use]
1125
1126
  pub fn id(&self) -> DefinitionId {
1126
- DefinitionId::from(&format!("{}{}{}", *self.uri_id, self.offset.start(), *self.str_id))
1127
+ id_from_parts!(DefinitionId; self.uri_id.get(), self.offset.start(), self.str_id.get())
1127
1128
  }
1128
1129
 
1129
1130
  #[must_use]
@@ -1204,7 +1205,7 @@ impl AttrReaderDefinition {
1204
1205
 
1205
1206
  #[must_use]
1206
1207
  pub fn id(&self) -> DefinitionId {
1207
- DefinitionId::from(&format!("{}{}{}", *self.uri_id, self.offset.start(), *self.str_id))
1208
+ id_from_parts!(DefinitionId; self.uri_id.get(), self.offset.start(), self.str_id.get())
1208
1209
  }
1209
1210
 
1210
1211
  #[must_use]
@@ -1285,7 +1286,7 @@ impl AttrWriterDefinition {
1285
1286
 
1286
1287
  #[must_use]
1287
1288
  pub fn id(&self) -> DefinitionId {
1288
- DefinitionId::from(&format!("{}{}{}", *self.uri_id, self.offset.start(), *self.str_id))
1289
+ id_from_parts!(DefinitionId; self.uri_id.get(), self.offset.start(), self.str_id.get())
1289
1290
  }
1290
1291
 
1291
1292
  #[must_use]
@@ -1363,7 +1364,7 @@ impl GlobalVariableDefinition {
1363
1364
 
1364
1365
  #[must_use]
1365
1366
  pub fn id(&self) -> DefinitionId {
1366
- DefinitionId::from(&format!("{}{}{}", *self.uri_id, self.offset.start(), *self.str_id))
1367
+ id_from_parts!(DefinitionId; self.uri_id.get(), self.offset.start(), self.str_id.get())
1367
1368
  }
1368
1369
 
1369
1370
  #[must_use]
@@ -1436,7 +1437,7 @@ impl InstanceVariableDefinition {
1436
1437
 
1437
1438
  #[must_use]
1438
1439
  pub fn id(&self) -> DefinitionId {
1439
- DefinitionId::from(&format!("{}{}{}", *self.uri_id, self.offset.start(), *self.str_id))
1440
+ id_from_parts!(DefinitionId; self.uri_id.get(), self.offset.start(), self.str_id.get())
1440
1441
  }
1441
1442
 
1442
1443
  #[must_use]
@@ -1509,7 +1510,7 @@ impl ClassVariableDefinition {
1509
1510
 
1510
1511
  #[must_use]
1511
1512
  pub fn id(&self) -> DefinitionId {
1512
- DefinitionId::from(&format!("{}{}{}", *self.uri_id, self.offset.start(), *self.str_id))
1513
+ id_from_parts!(DefinitionId; self.uri_id.get(), self.offset.start(), self.str_id.get())
1513
1514
  }
1514
1515
 
1515
1516
  #[must_use]
@@ -1583,13 +1584,13 @@ impl MethodAliasDefinition {
1583
1584
 
1584
1585
  #[must_use]
1585
1586
  pub fn id(&self) -> DefinitionId {
1586
- DefinitionId::from(&format!(
1587
- "{}{}{}{}",
1588
- *self.uri_id,
1587
+ id_from_parts!(
1588
+ DefinitionId;
1589
+ self.uri_id.get(),
1589
1590
  self.offset.start(),
1590
- *self.new_name_str_id,
1591
- *self.old_name_str_id,
1592
- ))
1591
+ self.new_name_str_id.get(),
1592
+ self.old_name_str_id.get(),
1593
+ )
1593
1594
  }
1594
1595
 
1595
1596
  #[must_use]
@@ -1669,13 +1670,13 @@ impl GlobalVariableAliasDefinition {
1669
1670
 
1670
1671
  #[must_use]
1671
1672
  pub fn id(&self) -> DefinitionId {
1672
- DefinitionId::from(&format!(
1673
- "{}{}{}{}",
1674
- *self.uri_id,
1673
+ id_from_parts!(
1674
+ DefinitionId;
1675
+ self.uri_id.get(),
1675
1676
  self.offset.start(),
1676
- *self.new_name_str_id,
1677
- *self.old_name_str_id,
1678
- ))
1677
+ self.new_name_str_id.get(),
1678
+ self.old_name_str_id.get(),
1679
+ )
1679
1680
  }
1680
1681
 
1681
1682
  #[must_use]
@@ -2,6 +2,7 @@ use std::path::PathBuf;
2
2
 
3
3
  use line_index::LineIndex;
4
4
  use url::Url;
5
+ use xxhash_rust::xxh3::xxh3_64;
5
6
 
6
7
  use crate::assert_mem_size;
7
8
  use crate::diagnostic::Diagnostic;
@@ -17,8 +18,9 @@ pub struct Document {
17
18
  method_reference_ids: Vec<MethodReferenceId>,
18
19
  constant_reference_ids: Vec<ConstantReferenceId>,
19
20
  diagnostics: Vec<Diagnostic>,
21
+ content_hash: u64,
20
22
  }
21
- assert_mem_size!(Document, 176);
23
+ assert_mem_size!(Document, 184);
22
24
 
23
25
  impl Document {
24
26
  #[must_use]
@@ -30,6 +32,7 @@ impl Document {
30
32
  method_reference_ids: Vec::new(),
31
33
  constant_reference_ids: Vec::new(),
32
34
  diagnostics: Vec::new(),
35
+ content_hash: xxh3_64(source.as_bytes()),
33
36
  }
34
37
  }
35
38
 
@@ -38,6 +41,11 @@ impl Document {
38
41
  &self.uri
39
42
  }
40
43
 
44
+ #[must_use]
45
+ pub fn content_hash(&self) -> u64 {
46
+ self.content_hash
47
+ }
48
+
41
49
  #[must_use]
42
50
  pub fn line_index(&self) -> &LineIndex {
43
51
  &self.line_index
@@ -84,6 +92,39 @@ impl Document {
84
92
  self.diagnostics.push(diagnostic);
85
93
  }
86
94
 
95
+ /// The file-system path of this document, decoded from its URI.
96
+ ///
97
+ /// Returns `None` when the URI is not a `file://` URL (e.g. the synthetic built-in document) or
98
+ /// cannot be converted to a path. Uses `Url` so percent-encoding and platform-specific paths
99
+ /// (including Windows drive paths) are handled correctly.
100
+ #[must_use]
101
+ pub fn file_path(&self) -> Option<PathBuf> {
102
+ let url = Url::parse(&self.uri).ok()?;
103
+ if url.scheme() != "file" {
104
+ return None;
105
+ }
106
+ url.to_file_path().ok()
107
+ }
108
+
109
+ /// The base file name of this document (the last path segment), decoded from its URI.
110
+ ///
111
+ /// Prefers the platform file path, but falls back to the last URL path segment so it still works
112
+ /// for `file://` URIs that don't convert to a local path on the current platform (e.g. a
113
+ /// drive-less path like `file:///foo.rb` on Windows). Returns `None` only when the URI has no
114
+ /// usable path segment (e.g. the synthetic built-in document).
115
+ #[must_use]
116
+ pub fn file_name(&self) -> Option<String> {
117
+ if let Some(path) = self.file_path()
118
+ && let Some(name) = path.file_name()
119
+ {
120
+ return Some(name.to_string_lossy().into_owned());
121
+ }
122
+
123
+ let url = Url::parse(&self.uri).ok()?;
124
+ let segment = url.path_segments()?.rfind(|segment| !segment.is_empty())?;
125
+ Some(segment.to_string())
126
+ }
127
+
87
128
  /// Computes the require path for this document given load paths.
88
129
  ///
89
130
  /// Returns `None` if:
@@ -97,12 +138,7 @@ impl Document {
97
138
  /// Panics if load path entries exceed u16.
98
139
  #[must_use]
99
140
  pub fn require_path(&self, load_paths: &[PathBuf]) -> Option<(String, u16)> {
100
- let url = Url::parse(&self.uri).ok()?;
101
- if url.scheme() != "file" {
102
- return None;
103
- }
104
-
105
- let file_path = url.to_file_path().ok()?;
141
+ let file_path = self.file_path()?;
106
142
  if file_path.extension().is_none_or(|ext| ext != "rb") {
107
143
  return None;
108
144
  }
@@ -2,7 +2,6 @@ use std::collections::HashSet;
2
2
  use std::collections::hash_map::Entry;
3
3
  use std::path::{Path, PathBuf};
4
4
 
5
- use crate::assert_mem_size;
6
5
  use crate::config::Config;
7
6
  use crate::diagnostic::Diagnostic;
8
7
  use crate::errors::Errors;
@@ -21,6 +20,7 @@ use crate::model::name::{Name, NameRef, ParentScope, ResolvedName};
21
20
  use crate::model::references::{ConstantReference, MethodRef};
22
21
  use crate::model::string_ref::StringRef;
23
22
  use crate::model::visibility::Visibility;
23
+ use crate::{assert_mem_size, assert_send_sync};
24
24
  use crate::{query, stats};
25
25
 
26
26
  /// An entity whose validity depends on a particular `NameId`.
@@ -94,6 +94,7 @@ pub struct Graph {
94
94
  config: Config,
95
95
  }
96
96
  assert_mem_size!(Graph, 352);
97
+ assert_send_sync!(Graph);
97
98
 
98
99
  impl Graph {
99
100
  #[must_use]
@@ -554,31 +555,6 @@ impl Graph {
554
555
  name_id
555
556
  }
556
557
 
557
- /// Searches for the initial attached object for an arbitrarily nested singleton class.
558
- /// Walks up the owner chain until finding a non-singleton namespace.
559
- ///
560
- /// # Example
561
- /// For `Foo::<Foo>::<<Foo>>`, returns `Foo`
562
- ///
563
- /// # Panics
564
- ///
565
- /// Panics if we attached a singleton class to something that isn't a namespace
566
- #[must_use]
567
- pub fn attached_object<'a>(&'a self, maybe_singleton: &'a Namespace) -> &'a Namespace {
568
- let mut attached_object = maybe_singleton;
569
-
570
- while matches!(attached_object, Namespace::SingletonClass(_)) {
571
- attached_object = self
572
- .declarations
573
- .get(attached_object.owner_id())
574
- .unwrap()
575
- .as_namespace()
576
- .unwrap();
577
- }
578
-
579
- attached_object
580
- }
581
-
582
558
  #[must_use]
583
559
  pub fn get(&self, name: &str) -> Option<Vec<&Definition>> {
584
560
  let declaration_id = declaration_id_from_lookup_name(name);
@@ -676,6 +652,9 @@ impl Graph {
676
652
  ///
677
653
  /// For methods, the latest definition wins. For constants, the latest
678
654
  /// `private_constant`/`public_constant` wins, otherwise `Public`.
655
+ ///
656
+ /// Methods declared via `module_function :bar` return `Private` on the instance
657
+ /// side (`Foo#bar`) and `Public` on the singleton side (`Foo::<Foo>#bar`).
679
658
  #[must_use]
680
659
  pub fn visibility(&self, declaration_id: &DeclarationId) -> Option<Visibility> {
681
660
  let declaration = self.declarations.get(declaration_id)?;
@@ -701,7 +680,13 @@ impl Graph {
701
680
  };
702
681
 
703
682
  let visibility = match definition {
704
- Definition::MethodVisibility(vis) => Some(*vis.visibility()),
683
+ Definition::MethodVisibility(vis) => match *vis.visibility() {
684
+ Visibility::ModuleFunction if vis.flags().is_singleton_method_visibility() => {
685
+ Some(Visibility::Public)
686
+ }
687
+ Visibility::ModuleFunction => Some(Visibility::Private),
688
+ other => Some(other),
689
+ },
705
690
  Definition::Method(method) => Some(*method.visibility()),
706
691
  Definition::AttrAccessor(attr) => Some(*attr.visibility()),
707
692
  Definition::AttrReader(attr) => Some(*attr.visibility()),
@@ -1044,7 +1029,17 @@ impl Graph {
1044
1029
  /// 3. `extend` -- merges the new `LocalGraph` into the now-clean graph
1045
1030
  pub fn consume_document_changes(&mut self, other: LocalGraph) {
1046
1031
  let uri_id = other.uri_id();
1047
- let old_document = self.documents.remove(&uri_id);
1032
+
1033
+ let old_document = match self.documents.entry(uri_id) {
1034
+ Entry::Occupied(entry) => {
1035
+ // No changes to the document, skip invalidation and merging
1036
+ if entry.get().content_hash() == other.document().content_hash() {
1037
+ return;
1038
+ }
1039
+ Some(entry.remove())
1040
+ }
1041
+ Entry::Vacant(_) => None,
1042
+ };
1048
1043
 
1049
1044
  // Skip invalidation during boot indexing (no documents have been resolved yet)
1050
1045
  // or when the document is brand new (no old data to invalidate against).
@@ -4209,4 +4204,21 @@ mod incremental_resolution_tests {
4209
4204
  );
4210
4205
  }
4211
4206
  }
4207
+
4208
+ #[test]
4209
+ fn unchanged_document_does_not_trigger_invalidation() {
4210
+ let mut context = GraphTest::new();
4211
+ context.index_uri("file:///a.rb", "class Foo; end");
4212
+ context.resolve();
4213
+
4214
+ // Re-indexing the same content should not trigger any invalidation or changes
4215
+ context.index_uri("file:///a.rb", "class Foo; end");
4216
+
4217
+ let mut graph = context.into_graph();
4218
+
4219
+ assert!(
4220
+ graph.take_pending_work().is_empty(),
4221
+ "Graph should have no pending work after re-indexing unchanged document"
4222
+ );
4223
+ }
4212
4224
  } // mod incremental_resolution_tests
@@ -1,6 +1,14 @@
1
1
  use std::{marker::PhantomData, num::NonZeroU64, ops::Deref};
2
2
  use xxhash_rust::xxh3;
3
3
 
4
+ /// Creates an ID by hashing fixed-width integer components in little-endian order.
5
+ macro_rules! id_from_parts {
6
+ ($id:ty; $($part:expr),+ $(,)?) => {
7
+ <$id>::from([$(($part).to_le_bytes().as_slice()),+])
8
+ };
9
+ }
10
+ pub(crate) use id_from_parts;
11
+
4
12
  /// Maps a u64 hash to a `NonZeroU64` by replacing 0 with `u64::MAX`.
5
13
  /// The probability of a 64-bit hash being exactly 0 is 2^-64 (~5.4e-20),
6
14
  /// and remapping 0 → MAX just means those two inputs collide — the same
@@ -70,6 +78,18 @@ impl<T> From<&String> for Id<T> {
70
78
  }
71
79
  }
72
80
 
81
+ impl<T, const N: usize> From<[&[u8]; N]> for Id<T> {
82
+ fn from(parts: [&[u8]; N]) -> Self {
83
+ let mut hasher = xxh3::Xxh3Default::new();
84
+
85
+ for part in parts {
86
+ hasher.update(part);
87
+ }
88
+
89
+ Self::new(hasher.digest())
90
+ }
91
+ }
92
+
73
93
  #[cfg(test)]
74
94
  mod tests {
75
95
  use super::*;
@@ -78,6 +98,41 @@ mod tests {
78
98
  pub struct Marker;
79
99
  pub type TestId = Id<Marker>;
80
100
 
101
+ #[test]
102
+ fn from_byte_slices_matches_concatenated_string() {
103
+ assert_eq!(
104
+ TestId::from("foobar"),
105
+ TestId::from([b"foo".as_slice(), b"bar".as_slice()]),
106
+ );
107
+ }
108
+
109
+ #[test]
110
+ fn from_parts_matches_mixed_width_byte_slices() {
111
+ let id = 42_u64;
112
+ let offset = 7_u32;
113
+ let tag = 2_u8;
114
+
115
+ assert_eq!(
116
+ TestId::from([
117
+ id.to_le_bytes().as_slice(),
118
+ offset.to_le_bytes().as_slice(),
119
+ tag.to_le_bytes().as_slice(),
120
+ ]),
121
+ id_from_parts!(TestId; id, offset, tag),
122
+ );
123
+ }
124
+
125
+ #[test]
126
+ fn from_byte_slices_is_deterministic() {
127
+ let parts: [&[u8]; 2] = [&[1, 2, 3], &[4, 5]];
128
+ assert_eq!(TestId::from(parts), TestId::from(parts));
129
+ }
130
+
131
+ #[test]
132
+ fn from_byte_slices_distinguishes_inputs() {
133
+ assert_ne!(TestId::from([b"foo".as_slice()]), TestId::from([b"bar".as_slice()]),);
134
+ }
135
+
81
136
  #[test]
82
137
  fn test_create_hash() {
83
138
  // Same input should produce same hash (deterministic)
@@ -1,6 +1,9 @@
1
1
  use crate::{
2
2
  assert_mem_size,
3
- model::{definitions::Receiver, id::Id},
3
+ model::{
4
+ definitions::Receiver,
5
+ id::{Id, id_from_parts},
6
+ },
4
7
  offset::Offset,
5
8
  };
6
9
 
@@ -27,7 +30,7 @@ assert_mem_size!(DefinitionId, 8);
27
30
 
28
31
  #[must_use]
29
32
  pub fn namespace_definition_id(uri_id: UriId, offset: &Offset, name_id: NameId) -> DefinitionId {
30
- DefinitionId::from(&format!("{}{}{}", *uri_id, offset.start(), *name_id))
33
+ id_from_parts!(DefinitionId; uri_id.get(), offset.start(), name_id.get())
31
34
  }
32
35
 
33
36
  #[must_use]
@@ -37,14 +40,23 @@ pub fn method_definition_id(
37
40
  str_id: StringId,
38
41
  receiver: Option<&Receiver>,
39
42
  ) -> DefinitionId {
40
- let mut formatted_id = format!("{}{}{}", *uri_id, offset.start(), *str_id);
41
- if let Some(receiver) = receiver {
42
- match receiver {
43
- Receiver::SelfReceiver(def_id) => formatted_id.push_str(&def_id.to_string()),
44
- Receiver::ConstantReceiver(name_id) => formatted_id.push_str(&name_id.to_string()),
45
- }
43
+ match receiver {
44
+ Some(Receiver::SelfReceiver(def_id)) => id_from_parts!(
45
+ DefinitionId;
46
+ uri_id.get(),
47
+ offset.start(),
48
+ str_id.get(),
49
+ def_id.get(),
50
+ ),
51
+ Some(Receiver::ConstantReceiver(name_id)) => id_from_parts!(
52
+ DefinitionId;
53
+ uri_id.get(),
54
+ offset.start(),
55
+ str_id.get(),
56
+ name_id.get(),
57
+ ),
58
+ None => id_from_parts!(DefinitionId; uri_id.get(), offset.start(), str_id.get()),
46
59
  }
47
- DefinitionId::from(&formatted_id)
48
60
  }
49
61
 
50
62
  #[derive(PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Clone, Copy)]