Skip to main content

code_moniker_workspace/code/
index.rs

1// code-moniker: ignore-file[smell-clone-reflex]
2// Code index refresh and graph diffing clone stable IDs into owned snapshots/diffs.
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use std::time::{Duration, Instant};
7
8use code_moniker_core::core::moniker::Moniker;
9use rayon::prelude::*;
10use rustc_hash::FxHashMap;
11
12use crate::code::{def_kind, is_navigable_def, last_name, ref_kind};
13use crate::environment::SourceFileSet;
14use crate::lines::LineIndex;
15use crate::snapshot::{
16	CodeIndex, CodeIndexTimings, ExtractionMeasurement, RecordTable, ReferenceId, ReferenceRecord,
17	SourceCatalog, SourceFileRecord, SourceId, SourceUnit, SymbolId, SymbolInventoryIndex,
18	SymbolRecord, WorkspaceCancellation, WorkspaceFailure, WorkspaceResource, WorkspaceResult,
19};
20use crate::source::{
21	CodeIndexMaterial, IndexedSourceFile, LocalResourceCache, SourceCatalogMaterial,
22};
23
24use crate::source::LocalIdentityResolver;
25
26pub trait CodeIndexPort {
27	fn build_index(&mut self, catalog: &SourceCatalog) -> WorkspaceResult<CodeIndex>;
28	fn build_index_cancellable(
29		&mut self,
30		catalog: &SourceCatalog,
31		cancellation: &WorkspaceCancellation,
32	) -> WorkspaceResult<CodeIndex> {
33		cancellation.check(WorkspaceResource::CodeIndex)?;
34		let index = self.build_index(catalog)?;
35		cancellation.check(WorkspaceResource::CodeIndex)?;
36		Ok(index)
37	}
38	fn refresh_paths(
39		&mut self,
40		current: &CodeIndex,
41		paths: &[PathBuf],
42	) -> WorkspaceResult<CodeIndexRefresh>;
43	fn refresh_catalog_paths(
44		&mut self,
45		current: &CodeIndex,
46		catalog: &SourceCatalog,
47		paths: &[PathBuf],
48	) -> WorkspaceResult<CodeIndexRefresh>;
49}
50
51#[derive(Clone, Debug, Eq, PartialEq)]
52pub struct CodeIndexRefresh {
53	pub index: CodeIndex,
54	pub changed_sources: Vec<SourceId>,
55	pub graph_diff: CodeIndexGraphDiff,
56}
57
58#[derive(Clone, Debug, Default, Eq, PartialEq)]
59pub struct CodeIndexGraphDiff {
60	/// Symbols added by the refresh.
61	pub added_symbols: Vec<SymbolId>,
62	/// Existing symbols whose linkage-relevant fields changed.
63	pub modified_symbols: Vec<SymbolId>,
64	/// Added or linkage-modified symbols used to refresh graph consumers.
65	pub changed_symbols: Vec<SymbolId>,
66	/// Symbols removed by the refresh.
67	pub removed_symbols: Vec<SymbolId>,
68	pub modified_symbol_identities: Vec<String>,
69	/// Existing symbols whose inventory-only fields changed.
70	///
71	/// These changes invalidate inventory consumers such as workspace statistics,
72	/// but do not require linkage reconstruction.
73	pub modified_inventory_symbols: Vec<SymbolId>,
74	pub modified_inventory_symbol_identities: Vec<String>,
75	pub removed_symbol_identities: Vec<String>,
76	pub changed_references: Vec<ReferenceId>,
77	pub removed_references: Vec<ReferenceId>,
78	pub removed_reference_kinds: Vec<String>,
79	pub symbol_id_remaps: Vec<(SymbolId, SymbolId)>,
80	pub reference_id_remaps: Vec<(ReferenceId, ReferenceId)>,
81	pub unchanged_symbols: usize,
82	pub unchanged_references: usize,
83}
84
85impl CodeIndexGraphDiff {
86	pub fn changed_symbol_count(&self) -> usize {
87		self.changed_linkage_symbol_count() + self.changed_inventory_symbol_count()
88	}
89
90	pub fn changed_linkage_symbol_count(&self) -> usize {
91		self.changed_symbols.len() + self.removed_symbols.len()
92	}
93
94	pub fn changed_inventory_symbol_count(&self) -> usize {
95		self.modified_inventory_symbols.len()
96	}
97
98	pub fn changed_reference_count(&self) -> usize {
99		self.changed_references.len() + self.removed_references.len()
100	}
101}
102
103#[derive(Clone, Debug, Default, Eq, PartialEq)]
104pub struct LocalCodeIndexOptions {
105	pub cache_dir: Option<PathBuf>,
106	pub detailed_telemetry: bool,
107}
108
109impl LocalCodeIndexOptions {
110	pub fn new(cache_dir: Option<PathBuf>) -> Self {
111		Self {
112			cache_dir,
113			detailed_telemetry: false,
114		}
115	}
116
117	pub fn with_detailed_telemetry(mut self, enabled: bool) -> Self {
118		self.detailed_telemetry = enabled;
119		self
120	}
121}
122
123pub struct LocalCodeIndex {
124	options: LocalCodeIndexOptions,
125	cache: LocalResourceCache,
126}
127
128impl LocalCodeIndex {
129	pub fn new(options: LocalCodeIndexOptions, cache: LocalResourceCache) -> Self {
130		Self { options, cache }
131	}
132
133	pub fn build_index_from_extracted(
134		&mut self,
135		sources: SourceFileSet,
136		identity: LocalIdentityResolver,
137		files: Vec<IndexedSourceFile>,
138	) -> WorkspaceResult<(SourceCatalog, CodeIndex)> {
139		build_local_code_index_from_extracted(&self.cache, sources, identity, files)
140	}
141}
142
143impl CodeIndexPort for LocalCodeIndex {
144	fn build_index(&mut self, catalog: &SourceCatalog) -> WorkspaceResult<CodeIndex> {
145		build_local_code_index(
146			&self.cache,
147			&self.options,
148			catalog,
149			&WorkspaceCancellation::default(),
150		)
151	}
152
153	fn build_index_cancellable(
154		&mut self,
155		catalog: &SourceCatalog,
156		cancellation: &WorkspaceCancellation,
157	) -> WorkspaceResult<CodeIndex> {
158		build_local_code_index(&self.cache, &self.options, catalog, cancellation)
159	}
160
161	fn refresh_paths(
162		&mut self,
163		current: &CodeIndex,
164		paths: &[PathBuf],
165	) -> WorkspaceResult<CodeIndexRefresh> {
166		refresh_local_code_index(&self.cache, &self.options, current, None, paths)
167	}
168
169	fn refresh_catalog_paths(
170		&mut self,
171		current: &CodeIndex,
172		catalog: &SourceCatalog,
173		paths: &[PathBuf],
174	) -> WorkspaceResult<CodeIndexRefresh> {
175		refresh_local_code_index(&self.cache, &self.options, current, Some(catalog), paths)
176	}
177}
178
179fn build_local_code_index(
180	cache: &LocalResourceCache,
181	options: &LocalCodeIndexOptions,
182	catalog: &SourceCatalog,
183	cancellation: &WorkspaceCancellation,
184) -> WorkspaceResult<CodeIndex> {
185	cancellation.check(WorkspaceResource::CodeIndex)?;
186	let total_timer = Instant::now();
187	let source_material = source_material(cache, catalog)?;
188	let generation = cache.next_generation();
189	let extract_timer = Instant::now();
190	let files = extract_source_files(
191		&source_material,
192		options.cache_dir.as_deref(),
193		cancellation,
194		options.detailed_telemetry,
195	)?;
196	let extract_sources = extract_timer.elapsed();
197	let extraction = if options.detailed_telemetry {
198		extraction_measurements(&files, None)
199	} else {
200		Default::default()
201	};
202	cancellation.check(WorkspaceResource::CodeIndex)?;
203	let semantic_timer = Instant::now();
204	let (symbols, references, material) =
205		build_semantic_index(source_material, files, cancellation)?;
206	let semantic_index = semantic_timer.elapsed();
207	let mut sources = source_records(&material);
208	let identity_scheme = material.identity.scheme().to_string();
209	cache.insert_index(generation, material);
210	sources.shrink_to_fit();
211	let inventory = Arc::new(SymbolInventoryIndex::build(generation, &sources, &symbols));
212	Ok(CodeIndex {
213		generation,
214		catalog_generation: catalog.generation,
215		identity_scheme,
216		sources,
217		symbols,
218		references,
219		inventory,
220		timings: CodeIndexTimings {
221			extract_sources,
222			semantic_index,
223			total: total_timer.elapsed(),
224			extraction,
225		},
226	})
227}
228
229fn build_code_index_from_extracted(
230	cache: &LocalResourceCache,
231	catalog: &SourceCatalog,
232	source_material: SourceCatalogMaterial,
233	files: Vec<Arc<IndexedSourceFile>>,
234) -> WorkspaceResult<CodeIndex> {
235	let generation = cache.next_generation();
236	let cancellation = WorkspaceCancellation::default();
237	let (symbols, references, material) =
238		build_semantic_index(source_material, files, &cancellation)?;
239	let mut sources = source_records(&material);
240	let identity_scheme = material.identity.scheme().to_string();
241	cache.insert_index(generation, material);
242	sources.shrink_to_fit();
243	let inventory = Arc::new(SymbolInventoryIndex::build(generation, &sources, &symbols));
244	Ok(CodeIndex {
245		generation,
246		catalog_generation: catalog.generation,
247		identity_scheme,
248		sources,
249		symbols,
250		references,
251		inventory,
252		timings: CodeIndexTimings {
253			extract_sources: Duration::ZERO,
254			semantic_index: Duration::ZERO,
255			total: Duration::ZERO,
256			extraction: Vec::new(),
257		},
258	})
259}
260
261fn build_local_code_index_from_extracted(
262	cache: &LocalResourceCache,
263	sources: SourceFileSet,
264	identity: LocalIdentityResolver,
265	files: Vec<IndexedSourceFile>,
266) -> WorkspaceResult<(SourceCatalog, CodeIndex)> {
267	validate_extracted_files(&sources, &identity, &files)?;
268	let catalog_generation = cache.next_generation();
269	let units = sources
270		.files
271		.iter()
272		.enumerate()
273		.map(|(file_idx, file)| {
274			SourceUnit::with_language(
275				identity.source_id(file_idx, &file.rel_path),
276				crate::path_util::portable_path(&file.rel_path),
277				file.lang.tag(),
278			)
279		})
280		.collect();
281	let catalog = SourceCatalog::new(catalog_generation, units);
282	let source_material = SourceCatalogMaterial {
283		sources,
284		identity,
285		memory_sources: BTreeMap::new(),
286		memory_slots: BTreeSet::new(),
287		memory_revisions: BTreeMap::new(),
288	};
289	cache.insert_sources(catalog_generation, source_material.clone());
290	let index = build_code_index_from_extracted(
291		cache,
292		&catalog,
293		source_material,
294		files.into_iter().map(Arc::new).collect(),
295	)?;
296	Ok((catalog, index))
297}
298
299fn validate_extracted_files(
300	sources: &SourceFileSet,
301	identity: &LocalIdentityResolver,
302	files: &[IndexedSourceFile],
303) -> WorkspaceResult<()> {
304	if sources.files.len() != files.len() {
305		return Err(WorkspaceFailure::new(
306			WorkspaceResource::CodeIndex,
307			format!(
308				"extracted file count {} does not match source catalog count {}",
309				files.len(),
310				sources.files.len()
311			),
312		));
313	}
314	for (file_idx, (source, extracted)) in sources.files.iter().zip(files).enumerate() {
315		let expected_id = identity.source_id(file_idx, &source.rel_path);
316		let matches = source.source == extracted.source_root
317			&& expected_id == extracted.source_id
318			&& source.path == extracted.path
319			&& source.rel_path == extracted.rel_path
320			&& source.anchor == extracted.anchor
321			&& source.lang == extracted.lang
322			&& extracted.identity == *identity;
323		if !matches {
324			return Err(WorkspaceFailure::new(
325				WorkspaceResource::CodeIndex,
326				format!(
327					"extracted file {} does not match source catalog slot {file_idx}",
328					extracted.rel_path.display()
329				),
330			));
331		}
332	}
333	Ok(())
334}
335
336fn refresh_local_code_index(
337	cache: &LocalResourceCache,
338	options: &LocalCodeIndexOptions,
339	current: &CodeIndex,
340	extended_catalog: Option<&SourceCatalog>,
341	paths: &[PathBuf],
342) -> WorkspaceResult<CodeIndexRefresh> {
343	let total_timer = Instant::now();
344	let current_material = cache.index_material(current.generation).ok_or_else(|| {
345		WorkspaceFailure::new(
346			WorkspaceResource::CodeIndex,
347			"code index material is unavailable",
348		)
349	})?;
350	let source_catalog = match extended_catalog {
351		Some(catalog) => cache.source_material(catalog.generation).ok_or_else(|| {
352			WorkspaceFailure::new(
353				WorkspaceResource::CodeIndex,
354				"extended source catalog material is unavailable",
355			)
356		})?,
357		None => current_material.source_catalog.clone(),
358	};
359	let mut files = current_material.files.clone();
360	let mut changed_sources = Vec::new();
361	let mut changed_file_indexes = BTreeSet::new();
362	let extract_timer = Instant::now();
363	let extraction_parent = options
364		.detailed_telemetry
365		.then(tracing::Span::current)
366		.unwrap_or_else(tracing::Span::none);
367	refresh_retired_slots(RetiredSlotRefresh {
368		previous_catalog: &current_material.source_catalog,
369		source_catalog: &source_catalog,
370		cache_dir: options.cache_dir.as_deref(),
371		files: &mut files,
372		changed_sources: &mut changed_sources,
373		changed_file_indexes: &mut changed_file_indexes,
374		extraction_parent: &extraction_parent,
375		detailed_telemetry: options.detailed_telemetry,
376	})?;
377	for file_idx in files.len()..source_catalog.sources.files.len() {
378		let file = &source_catalog.sources.files[file_idx];
379		let indexed = extract_source_file(
380			&source_catalog,
381			file_idx,
382			&file.path.clone(),
383			options.cache_dir.as_deref(),
384			&extraction_parent,
385			options.detailed_telemetry,
386		)?;
387		push_unique_source(&mut changed_sources, indexed.source_id);
388		changed_file_indexes.insert(file_idx);
389		files.push(Arc::new(indexed));
390	}
391	for path in paths {
392		let Some(source) = source_catalog.resolve_source(path) else {
393			continue;
394		};
395		let Some(file_idx) = source.eager_index else {
396			continue;
397		};
398		if changed_file_indexes.contains(&file_idx) {
399			continue;
400		}
401		let indexed = extract_source_file(
402			&source_catalog,
403			file_idx,
404			&source.path,
405			options.cache_dir.as_deref(),
406			&extraction_parent,
407			options.detailed_telemetry,
408		)?;
409		if let Some(slot) = files.get_mut(file_idx) {
410			push_unique_source(&mut changed_sources, indexed.source_id);
411			changed_file_indexes.insert(file_idx);
412			*slot = Arc::new(indexed);
413		}
414	}
415	let extract_sources = extract_timer.elapsed();
416	if changed_sources.is_empty() {
417		return Ok(CodeIndexRefresh {
418			index: current.clone(),
419			changed_sources,
420			graph_diff: CodeIndexGraphDiff::default(),
421		});
422	}
423	let semantic_timer = Instant::now();
424	let extraction = if options.detailed_telemetry {
425		extraction_measurements(&files, Some(&changed_file_indexes))
426	} else {
427		Default::default()
428	};
429	let material = material_from_files(source_catalog, files, &WorkspaceCancellation::default())?;
430	let sources = source_records(&material);
431	let graph_diff = graph_diff(current_material.as_ref(), &material, &changed_file_indexes);
432	let mut symbols = current.symbols.clone();
433	let mut references = current.references.clone();
434	for file_idx in &changed_file_indexes {
435		let (file_symbols, file_references) =
436			records_for_file(*file_idx, &material.files[*file_idx]);
437		symbols.replace(*file_idx, Arc::from(file_symbols));
438		references.replace(*file_idx, Arc::from(file_references));
439	}
440	let semantic_index = semantic_timer.elapsed();
441	let generation = cache.next_generation();
442	let identity_scheme = material.identity.scheme().to_string();
443	cache_refreshed_index(cache, current, generation, material, &graph_diff);
444	let inventory = Arc::new(current.inventory.refresh(
445		generation,
446		&sources,
447		&symbols,
448		&changed_file_indexes,
449	));
450	Ok(CodeIndexRefresh {
451		index: CodeIndex {
452			generation,
453			catalog_generation: extended_catalog
454				.map(|catalog| catalog.generation)
455				.unwrap_or(current.catalog_generation),
456			identity_scheme,
457			sources,
458			symbols,
459			references,
460			inventory,
461			timings: CodeIndexTimings {
462				extract_sources,
463				semantic_index,
464				total: total_timer.elapsed(),
465				extraction,
466			},
467		},
468		changed_sources,
469		graph_diff,
470	})
471}
472
473fn cache_refreshed_index(
474	cache: &LocalResourceCache,
475	current: &CodeIndex,
476	generation: crate::snapshot::ResourceGeneration,
477	material: CodeIndexMaterial,
478	graph_diff: &CodeIndexGraphDiff,
479) {
480	cache.insert_index(generation, material);
481	cache.insert_index_diff(generation, current.generation, graph_diff.clone());
482}
483
484struct RetiredSlotRefresh<'a> {
485	previous_catalog: &'a SourceCatalogMaterial,
486	source_catalog: &'a SourceCatalogMaterial,
487	cache_dir: Option<&'a Path>,
488	files: &'a mut Vec<Arc<IndexedSourceFile>>,
489	changed_sources: &'a mut Vec<SourceId>,
490	changed_file_indexes: &'a mut BTreeSet<usize>,
491	extraction_parent: &'a tracing::Span,
492	detailed_telemetry: bool,
493}
494
495fn refresh_retired_slots(refresh: RetiredSlotRefresh<'_>) -> WorkspaceResult<()> {
496	let slots = refresh
497		.files
498		.len()
499		.min(refresh.source_catalog.sources.files.len());
500	for file_idx in 0..slots {
501		let was_retired = refresh.previous_catalog.sources.files[file_idx].retired;
502		let is_retired = refresh.source_catalog.sources.files[file_idx].retired;
503		if was_retired == is_retired {
504			continue;
505		}
506		let indexed = if is_retired {
507			tombstone_file(&refresh.files[file_idx])
508		} else {
509			extract_source_file(
510				refresh.source_catalog,
511				file_idx,
512				&refresh.source_catalog.sources.files[file_idx].path.clone(),
513				refresh.cache_dir,
514				refresh.extraction_parent,
515				refresh.detailed_telemetry,
516			)?
517		};
518		push_unique_source(refresh.changed_sources, indexed.source_id);
519		refresh.changed_file_indexes.insert(file_idx);
520		refresh.files[file_idx] = Arc::new(indexed);
521	}
522	Ok(())
523}
524
525fn tombstone_file(previous: &IndexedSourceFile) -> IndexedSourceFile {
526	IndexedSourceFile {
527		source_root: previous.source_root,
528		source_id: previous.source_id,
529		source_uri: previous.source_uri.clone(),
530		identity: previous.identity.clone(),
531		path: previous.path.clone(),
532		rel_path: previous.rel_path.clone(),
533		anchor: previous.anchor.clone(),
534		lang: previous.lang,
535		graph: code_moniker_core::core::code_graph::CodeGraph::from_records(Vec::new(), Vec::new()),
536		source: String::new(),
537		extraction_cache: "retired",
538		extraction_duration: Duration::ZERO,
539	}
540}
541
542fn source_material(
543	cache: &LocalResourceCache,
544	catalog: &SourceCatalog,
545) -> WorkspaceResult<SourceCatalogMaterial> {
546	cache.source_material(catalog.generation).ok_or_else(|| {
547		WorkspaceFailure::new(
548			WorkspaceResource::CodeIndex,
549			"source catalog material is unavailable",
550		)
551	})
552}
553
554fn extract_source_files(
555	source_material: &SourceCatalogMaterial,
556	cache_dir: Option<&std::path::Path>,
557	cancellation: &WorkspaceCancellation,
558	detailed_telemetry: bool,
559) -> WorkspaceResult<Vec<Arc<IndexedSourceFile>>> {
560	let parent = detailed_telemetry
561		.then(tracing::Span::current)
562		.unwrap_or_else(tracing::Span::none);
563	source_material
564		.sources
565		.files
566		.par_iter()
567		.enumerate()
568		.map(|(file_idx, file)| {
569			cancellation.check(WorkspaceResource::CodeIndex)?;
570			extract_source_file(
571				source_material,
572				file_idx,
573				&file.path,
574				cache_dir,
575				&parent,
576				detailed_telemetry,
577			)
578			.map(Arc::new)
579		})
580		.collect()
581}
582
583fn extract_source_file(
584	source_material: &SourceCatalogMaterial,
585	file_idx: usize,
586	path: &Path,
587	cache_dir: Option<&Path>,
588	parent: &tracing::Span,
589	detailed_telemetry: bool,
590) -> WorkspaceResult<IndexedSourceFile> {
591	let file = source_material.sources.files.get(file_idx).ok_or_else(|| {
592		WorkspaceFailure::new(
593			WorkspaceResource::CodeIndex,
594			format!("source file index {file_idx} is unavailable"),
595		)
596	})?;
597	let root = source_material
598		.sources
599		.roots
600		.get(file.source)
601		.ok_or_else(|| {
602			WorkspaceFailure::new(
603				WorkspaceResource::CodeIndex,
604				format!("source root {} is unavailable", file.source),
605			)
606		})?;
607	let ctx = file.extraction_context(root);
608	let started = detailed_telemetry.then(Instant::now);
609	let span = if detailed_telemetry {
610		tracing::info_span!(
611			parent: parent,
612			"workspace.extract_file",
613			file.path = %file.rel_path.display(),
614			file.language = file.lang.tag(),
615			file.source_bytes = tracing::field::Empty,
616			cache.result = tracing::field::Empty,
617			graph.definitions = tracing::field::Empty,
618			graph.references = tracing::field::Empty,
619		)
620	} else {
621		tracing::Span::none()
622	};
623	let _entered = span.enter();
624	let (graph, source, cache_status) = match source_material.memory_source(path) {
625		Some(source) => (
626			crate::environment::extract_source_with(file.lang, source, &file.anchor, &ctx),
627			source.to_owned(),
628			"memory",
629		),
630		None => {
631			let (graph, extracted_source, cache_outcome) =
632				crate::cache::load_or_extract_workspace_result(
633					path,
634					&file.anchor,
635					file.lang,
636					cache_dir,
637					&ctx,
638				)
639				.map_err(|err| {
640					WorkspaceFailure::new(
641						WorkspaceResource::CodeIndex,
642						format!("cannot extract {}: {err}", path.display()),
643					)
644				})?;
645			let source = match extracted_source {
646				Some(source) => source,
647				None => crate::cache::read_source_lossy(path).map_err(|err| {
648					WorkspaceFailure::new(
649						WorkspaceResource::CodeIndex,
650						format!("cannot read {}: {err}", path.display()),
651					)
652				})?,
653			};
654			(graph, source, cache_outcome.as_str())
655		}
656	};
657	let elapsed = started.map_or(Duration::ZERO, |started| started.elapsed());
658	if detailed_telemetry {
659		span.record("file.source_bytes", source.len());
660		span.record("cache.result", cache_status);
661		span.record("graph.definitions", graph.def_count());
662		span.record("graph.references", graph.ref_count());
663	}
664	Ok(IndexedSourceFile {
665		source_root: file.source,
666		source_id: source_material
667			.source_id_for_file(file_idx)
668			.ok_or_else(|| {
669				WorkspaceFailure::new(
670					WorkspaceResource::CodeIndex,
671					format!("source id is unavailable for {}", file.rel_path.display()),
672				)
673			})?,
674		source_uri: source_material
675			.source_uri_for_path(&file.path)
676			.ok_or_else(|| {
677				WorkspaceFailure::new(
678					WorkspaceResource::CodeIndex,
679					format!("source uri is unavailable for {}", file.path.display()),
680				)
681			})?,
682		identity: source_material.identity.clone(),
683		path: file.path.clone(),
684		rel_path: file.rel_path.clone(),
685		anchor: file.anchor.clone(),
686		lang: file.lang,
687		graph,
688		source,
689		extraction_cache: cache_status,
690		extraction_duration: elapsed,
691	})
692}
693
694fn extraction_measurements(
695	files: &[Arc<IndexedSourceFile>],
696	selected: Option<&BTreeSet<usize>>,
697) -> Vec<ExtractionMeasurement> {
698	let mut groups = BTreeMap::<(&'static str, &'static str), ExtractionMeasurement>::new();
699	for (file_idx, file) in files.iter().enumerate() {
700		if selected.is_some_and(|selected| !selected.contains(&file_idx)) {
701			continue;
702		}
703		let language = file.lang.tag();
704		let cache = file.extraction_cache;
705		let entry = groups
706			.entry((language, cache))
707			.or_insert_with(|| ExtractionMeasurement {
708				language,
709				cache,
710				..ExtractionMeasurement::default()
711			});
712		entry.files += 1;
713		entry.source_bytes += file.source.len();
714		entry.duration += file.extraction_duration;
715	}
716	groups.into_values().collect()
717}
718
719fn build_semantic_index(
720	source_material: SourceCatalogMaterial,
721	files: Vec<Arc<IndexedSourceFile>>,
722	cancellation: &WorkspaceCancellation,
723) -> WorkspaceResult<(
724	RecordTable<SymbolRecord>,
725	RecordTable<ReferenceRecord>,
726	CodeIndexMaterial,
727)> {
728	let mut symbol_shards = Vec::with_capacity(files.len());
729	let mut reference_shards = Vec::with_capacity(files.len());
730	for (file_idx, file) in files.iter().enumerate() {
731		cancellation.check(WorkspaceResource::CodeIndex)?;
732		let (symbols, references) = records_for_file(file_idx, file);
733		symbol_shards.push(Arc::from(symbols));
734		reference_shards.push(Arc::from(references));
735	}
736	let material = material_from_files(source_material, files, cancellation)?;
737	Ok((
738		RecordTable::from_shards(symbol_shards),
739		RecordTable::from_shards(reference_shards),
740		material,
741	))
742}
743
744fn material_from_files(
745	source_material: SourceCatalogMaterial,
746	mut files: Vec<Arc<IndexedSourceFile>>,
747	cancellation: &WorkspaceCancellation,
748) -> WorkspaceResult<CodeIndexMaterial> {
749	let symbol_count = files.iter().map(|file| file.graph.def_count()).sum();
750	let mut symbols_by_moniker = rustc_hash::FxHashMap::default();
751	symbols_by_moniker.reserve(symbol_count);
752	for (file_idx, file) in files.iter().enumerate() {
753		cancellation.check(WorkspaceResource::CodeIndex)?;
754		for (def_idx, def) in file.graph.defs().enumerate() {
755			symbols_by_moniker.insert(
756				def.moniker.clone(),
757				file.identity.symbol_id(file_idx, def_idx),
758			);
759		}
760	}
761	symbols_by_moniker.shrink_to_fit();
762	files.shrink_to_fit();
763	let identity = source_material.identity.clone();
764	Ok(CodeIndexMaterial {
765		source_catalog: source_material,
766		files,
767		identity,
768		symbols_by_moniker,
769	})
770}
771
772fn graph_diff(
773	previous: &CodeIndexMaterial,
774	next: &CodeIndexMaterial,
775	changed_files: &BTreeSet<usize>,
776) -> CodeIndexGraphDiff {
777	let mut diff = CodeIndexGraphDiff::default();
778	for file_idx in changed_files {
779		let Some(next_file) = next.files.get(*file_idx) else {
780			continue;
781		};
782		let (previous_symbols, previous_references) = match previous.files.get(*file_idx) {
783			Some(previous_file) => records_for_file(*file_idx, previous_file),
784			None => (Vec::new(), Vec::new()),
785		};
786		let (next_symbols, next_references) = records_for_file(*file_idx, next_file);
787		diff_symbols(&previous_symbols, &next_symbols, &mut diff);
788		diff_references(
789			&previous_references,
790			previous,
791			&next_references,
792			next,
793			&mut diff,
794		);
795	}
796	diff
797}
798
799fn records_for_file(
800	file_idx: usize,
801	file: &IndexedSourceFile,
802) -> (Vec<SymbolRecord>, Vec<ReferenceRecord>) {
803	let line_index = LineIndex::new(&file.source);
804	let mut symbols = Vec::with_capacity(file.graph.def_count());
805	collect_symbols(file_idx, file, &line_index, &mut symbols);
806	let mut reference_identity_pool = TargetIdentityPool::default();
807	let mut references = Vec::with_capacity(file.graph.ref_count());
808	collect_references(
809		file_idx,
810		file,
811		&line_index,
812		&mut references,
813		&mut reference_identity_pool,
814	);
815	(symbols, references)
816}
817
818fn diff_symbols(previous: &[SymbolRecord], next: &[SymbolRecord], diff: &mut CodeIndexGraphDiff) {
819	let mut next_by_key = symbol_record_indexes(next);
820	for previous_symbol in previous {
821		let key = symbol_key(previous_symbol);
822		let Some(next_idx) = pop_index(&mut next_by_key, &key) else {
823			diff.removed_symbols.push(previous_symbol.id);
824			diff.removed_symbol_identities
825				.push(previous_symbol.identity.to_string());
826			continue;
827		};
828		let next_symbol = &next[next_idx];
829		if symbol_linkage_fields_changed(previous_symbol, next_symbol) {
830			diff.modified_symbols.push(next_symbol.id);
831			diff.modified_symbol_identities
832				.push(next_symbol.identity.to_string());
833			diff.changed_symbols.push(next_symbol.id);
834			continue;
835		}
836		let inventory_changed = symbol_inventory_fields_changed(previous_symbol, next_symbol);
837		if inventory_changed {
838			diff.modified_inventory_symbols.push(next_symbol.id);
839			diff.modified_inventory_symbol_identities
840				.push(next_symbol.identity.to_string());
841		}
842		if previous_symbol.id != next_symbol.id {
843			diff.symbol_id_remaps
844				.push((previous_symbol.id, next_symbol.id));
845		}
846		if inventory_changed {
847			continue;
848		}
849		diff.unchanged_symbols += 1;
850	}
851	for indexes in next_by_key.into_values() {
852		for idx in indexes {
853			diff.added_symbols.push(next[idx].id);
854			diff.changed_symbols.push(next[idx].id);
855		}
856	}
857}
858
859fn diff_references(
860	previous: &[ReferenceRecord],
861	previous_material: &CodeIndexMaterial,
862	next: &[ReferenceRecord],
863	next_material: &CodeIndexMaterial,
864	diff: &mut CodeIndexGraphDiff,
865) {
866	let mut next_by_key = reference_record_indexes(next, next_material);
867	for previous_reference in previous {
868		let Some(key) = reference_key(previous_reference, previous_material) else {
869			diff.removed_references.push(previous_reference.id);
870			diff.removed_reference_kinds
871				.push(previous_reference.kind.clone());
872			continue;
873		};
874		let Some(next_idx) = pop_index(&mut next_by_key, &key) else {
875			diff.removed_references.push(previous_reference.id);
876			diff.removed_reference_kinds
877				.push(previous_reference.kind.clone());
878			continue;
879		};
880		let next_reference = &next[next_idx];
881		if previous_reference.id != next_reference.id {
882			diff.reference_id_remaps
883				.push((previous_reference.id, next_reference.id));
884		}
885		diff.unchanged_references += 1;
886	}
887	for indexes in next_by_key.into_values() {
888		for idx in indexes {
889			diff.changed_references.push(next[idx].id);
890		}
891	}
892}
893
894fn symbol_record_indexes(records: &[SymbolRecord]) -> FxHashMap<Arc<str>, Vec<usize>> {
895	let mut by_key = FxHashMap::<Arc<str>, Vec<usize>>::default();
896	for (idx, record) in records.iter().enumerate() {
897		by_key.entry(symbol_key(record)).or_default().push(idx);
898	}
899	by_key
900}
901
902fn reference_record_indexes(
903	records: &[ReferenceRecord],
904	material: &CodeIndexMaterial,
905) -> FxHashMap<ReferenceKey, Vec<usize>> {
906	let mut by_key = FxHashMap::<ReferenceKey, Vec<usize>>::default();
907	for (idx, record) in records.iter().enumerate() {
908		if let Some(key) = reference_key(record, material) {
909			by_key.entry(key).or_default().push(idx);
910		}
911	}
912	by_key
913}
914
915fn pop_index<K: Eq + std::hash::Hash>(
916	by_key: &mut FxHashMap<K, Vec<usize>>,
917	key: &K,
918) -> Option<usize> {
919	let indexes = by_key.get_mut(key)?;
920	let idx = indexes.remove(0);
921	if indexes.is_empty() {
922		by_key.remove(key);
923	}
924	Some(idx)
925}
926
927fn symbol_key(symbol: &SymbolRecord) -> Arc<str> {
928	Arc::clone(&symbol.identity)
929}
930
931fn symbol_linkage_fields_changed(previous: &SymbolRecord, next: &SymbolRecord) -> bool {
932	previous.identity != next.identity
933		|| previous.name != next.name
934		|| previous.kind != next.kind
935		|| previous.visibility != next.visibility
936		|| previous.signature != next.signature
937		|| previous.call_name != next.call_name
938		|| previous.call_arity != next.call_arity
939		|| previous.navigable != next.navigable
940}
941
942fn symbol_inventory_fields_changed(previous: &SymbolRecord, next: &SymbolRecord) -> bool {
943	previous.line_range != next.line_range || previous.parent != next.parent
944}
945
946#[derive(Clone, Debug, Eq, Hash, PartialEq)]
947struct ReferenceKey {
948	source_symbol_identity: String,
949	target_identity: String,
950	kind: String,
951	call_name: Option<String>,
952	call_arity: Option<usize>,
953	confidence: Option<String>,
954	receiver: Option<String>,
955	alias: Option<String>,
956}
957
958fn reference_key(
959	reference: &ReferenceRecord,
960	material: &CodeIndexMaterial,
961) -> Option<ReferenceKey> {
962	let source_symbol_identity = material
963		.symbol_moniker(&reference.source_symbol)
964		.map(|moniker| material.identity.moniker_uri(moniker))?;
965	Some(ReferenceKey {
966		source_symbol_identity,
967		target_identity: reference.target_identity.to_string(),
968		kind: reference.kind.clone(),
969		call_name: reference.call_name.clone(),
970		call_arity: reference.call_arity,
971		confidence: reference.confidence.clone(),
972		receiver: reference.receiver.clone(),
973		alias: reference.alias.clone(),
974	})
975}
976
977fn push_unique_source(sources: &mut Vec<SourceId>, source: SourceId) {
978	if !sources.iter().any(|existing| existing == &source) {
979		sources.push(source);
980	}
981}
982
983fn collect_symbols(
984	file_idx: usize,
985	file: &IndexedSourceFile,
986	line_index: &LineIndex,
987	symbols: &mut Vec<SymbolRecord>,
988) {
989	for (def_idx, def) in file.graph.defs().enumerate() {
990		let id = file.identity.symbol_id(file_idx, def_idx);
991		let parent = def
992			.parent
993			.map(|parent_idx| file.identity.symbol_id(file_idx, parent_idx));
994		symbols.push(SymbolRecord {
995			id,
996			source: file.source_id,
997			identity: Arc::from(file.identity.moniker_uri(&def.moniker)),
998			name: last_name(&def.moniker),
999			kind: def_kind(def),
1000			visibility: def_visibility(def),
1001			signature: String::from_utf8_lossy(&def.signature).to_string(),
1002			call_name: (!def.call_name.is_empty())
1003				.then(|| String::from_utf8_lossy(&def.call_name).to_string()),
1004			call_arity: def.call_arity,
1005			navigable: is_navigable_def(file.lang, def),
1006			line_range: def
1007				.position
1008				.map(|(start, end)| line_index.line_range(start, end)),
1009			parent,
1010		});
1011	}
1012}
1013
1014fn def_visibility(def: &code_moniker_core::core::code_graph::DefRecord) -> String {
1015	std::str::from_utf8(&def.visibility)
1016		.unwrap_or("")
1017		.to_string()
1018}
1019
1020fn collect_references(
1021	file_idx: usize,
1022	file: &IndexedSourceFile,
1023	line_index: &LineIndex,
1024	references: &mut Vec<ReferenceRecord>,
1025	reference_identity_pool: &mut TargetIdentityPool,
1026) {
1027	for (ref_idx, reference) in file.graph.refs().enumerate() {
1028		let id = file.identity.reference_id(file_idx, ref_idx);
1029		let source_symbol = file.identity.symbol_id(file_idx, reference.source);
1030		let target_identity = reference_identity_pool.intern(&file.identity, &reference.target);
1031		references.push(
1032			ReferenceRecord::new(
1033				id,
1034				file.source_id,
1035				source_symbol,
1036				target_identity,
1037				ref_kind(reference),
1038				reference
1039					.position
1040					.map(|(start, end)| line_index.line_range(start, end)),
1041			)
1042			.with_call_metadata(ref_attr(&reference.call_name), reference.call_arity)
1043			.with_metadata(
1044				ref_attr(&reference.confidence),
1045				ref_attr(&reference.receiver_hint),
1046				ref_attr(&reference.alias),
1047			),
1048		);
1049	}
1050}
1051
1052#[derive(Default)]
1053struct TargetIdentityPool {
1054	values: rustc_hash::FxHashMap<Moniker, Arc<str>>,
1055}
1056
1057impl TargetIdentityPool {
1058	fn intern(&mut self, identity: &LocalIdentityResolver, target: &Moniker) -> Arc<str> {
1059		if let Some(existing) = self.values.get(target) {
1060			return Arc::clone(existing);
1061		}
1062		let shared = Arc::<str>::from(identity.moniker_uri(target));
1063		self.values.insert(target.clone(), Arc::clone(&shared));
1064		shared
1065	}
1066}
1067
1068fn source_records(material: &CodeIndexMaterial) -> Vec<SourceFileRecord> {
1069	material
1070		.files
1071		.iter()
1072		.map(|file| SourceFileRecord {
1073			id: file.source_id,
1074			uri: file.source_uri.clone(),
1075			source_root: file.source_root,
1076			path: file.path.display().to_string(),
1077			rel_path: crate::path_util::portable_path(&file.rel_path),
1078			anchor: crate::path_util::portable_path(&file.anchor),
1079			language: file.lang.tag().to_string(),
1080			text: if material.source_catalog.is_memory_slot(&file.path) {
1081				file.source.to_owned()
1082			} else {
1083				String::new()
1084			},
1085		})
1086		.collect()
1087}
1088
1089fn ref_attr(bytes: &[u8]) -> Option<String> {
1090	if bytes.is_empty() {
1091		return None;
1092	}
1093	std::str::from_utf8(bytes)
1094		.ok()
1095		.filter(|value| !value.is_empty())
1096		.map(ToOwned::to_owned)
1097}
1098
1099#[cfg(test)]
1100mod tests {
1101	use super::*;
1102
1103	#[test]
1104	fn line_range_changes_are_inventory_deltas_not_linkage_deltas() {
1105		let mut previous =
1106			SymbolRecord::new(SymbolId::at(0, 0), SourceId::at(0), "Invoice", "class");
1107		previous.identity = Arc::from("code+moniker://./lang:java/class:Invoice");
1108		previous.line_range = Some((4, 4));
1109		let mut next = previous.clone();
1110		next.line_range = Some((4, 13));
1111		let mut diff = CodeIndexGraphDiff::default();
1112
1113		diff_symbols(&[previous], &[next.clone()], &mut diff);
1114
1115		assert_eq!(diff.modified_inventory_symbols, vec![next.id]);
1116		assert_eq!(
1117			diff.modified_inventory_symbol_identities,
1118			vec![next.identity.to_string()]
1119		);
1120		assert!(diff.modified_symbols.is_empty());
1121		assert!(diff.modified_symbol_identities.is_empty());
1122		assert_eq!(diff.unchanged_symbols, 0);
1123		assert_eq!(diff.changed_symbol_count(), 1);
1124		assert_eq!(diff.changed_linkage_symbol_count(), 0);
1125	}
1126}