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