Skip to main content

hearth_graph/
build.rs

1use std::path::Path;
2
3use compact_str::CompactString;
4use xxhash_rust::xxh3::xxh3_64;
5
6use crate::{
7    CancelSignal, FileAnalysis, FileSymbols, LanguageRegistry, ParserPool, SymbolIndex,
8    analyze_source, extract_symbols,
9};
10
11const PREFILTER_CANCEL_POLL_INTERVAL: usize = 128;
12
13/// Source access supplied by the host that owns the repository or file store.
14///
15/// The index-build driver performs all source access through this trait and
16/// never reads the filesystem directly.
17pub trait SourceLoader: Sync {
18    /// Verifies that the source root is available for a build.
19    fn verify(&self) -> Result<(), String>;
20
21    /// Returns the byte length of a regular file, or `None` when it cannot be
22    /// probed or is not a regular file.
23    fn probe(&self, path: &str) -> Option<u64>;
24
25    /// Loads UTF-8 source text, or returns `None` when reading fails.
26    fn load(&self, path: &str) -> Option<String>;
27}
28
29/// Limits applied while building a symbol index.
30#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31pub struct BuildOptions {
32    /// Maximum byte length of an individual source file.
33    pub max_file_bytes: u64,
34    /// Maximum number of indexing workers.
35    pub max_workers: usize,
36}
37
38impl Default for BuildOptions {
39    fn default() -> Self {
40        Self {
41            max_file_bytes: 2 * 1024 * 1024,
42            max_workers: 8,
43        }
44    }
45}
46
47/// Outcome of a cancellable symbol-index build.
48#[derive(Debug)]
49// Keep the specified public `Completed(SymbolIndex)` shape rather than adding
50// allocation and changing the API solely to reduce the enum's stack size.
51#[allow(clippy::large_enum_variant)]
52pub enum IndexBuild {
53    /// Every indexable path was scanned.
54    Completed(SymbolIndex),
55    /// Cancellation stopped the build before it could publish an index.
56    Cancelled {
57        /// Number of indexable files whose load was attempted.
58        scanned_files: usize,
59    },
60    /// The source root was invalid or an indexing worker panicked.
61    Failed {
62        /// Human-readable reason the build could not complete.
63        message: String,
64    },
65}
66
67/// Outcome of a cancellable source-analysis build.
68#[derive(Debug)]
69pub enum AnalyzeBuild {
70    /// Every analyzable path was scanned.
71    Completed {
72        /// Successfully loaded file analyses, sorted by path.
73        files: Vec<FileAnalysis>,
74        /// Number of analyzable files whose load was attempted.
75        scanned_files: usize,
76    },
77    /// Cancellation stopped analysis before results could be published.
78    Cancelled {
79        /// Number of analyzable files whose load was attempted.
80        scanned_files: usize,
81    },
82    /// The source root was invalid or an analysis worker panicked.
83    Failed {
84        /// Human-readable reason the analysis could not complete.
85        message: String,
86    },
87}
88
89/// Builds a symbol index by loading and parsing the supplied paths in parallel.
90///
91/// Unsupported, oversized, missing, and unreadable files are skipped without
92/// failing the build. Unlike octorus, every successfully loaded supported file
93/// gets an index entry even when it contains no symbols (deviation D2).
94/// Consequently, the indexed file count equals successful loads, while
95/// [`SymbolIndex::scanned_file_count`] additionally counts files whose load
96/// failed after a successful probe.
97///
98/// Duplicate entries in `paths` collapse last-wins through the index's
99/// upsert (divergence D1); octorus kept duplicate rows. `scanned_files`
100/// still counts every input occurrence.
101pub fn build_index(
102    registry: &LanguageRegistry,
103    loader: &dyn SourceLoader,
104    paths: &[String],
105    cancel: &dyn CancelSignal,
106    options: &BuildOptions,
107) -> IndexBuild {
108    // Keep the symbols-only prefilter and worker here. Running full analysis
109    // would change scanned accounting for import-only registrations and could
110    // make an unrelated custom import extractor affect symbol-index builds.
111    match drive_paths(
112        registry,
113        loader,
114        paths,
115        cancel,
116        options,
117        supports_symbols,
118        analyze_symbols_only,
119    ) {
120        DriverBuild::Completed {
121            files,
122            scanned_files,
123        } => {
124            let files = files
125                .into_iter()
126                .map(|analysis| FileSymbols {
127                    path: analysis.path,
128                    content_hash: analysis.content_hash,
129                    symbols: analysis.symbols,
130                })
131                .collect();
132            let mut index = SymbolIndex::from_files(files, registry.generation());
133            index.set_scanned_files(scanned_files);
134            IndexBuild::Completed(index)
135        }
136        DriverBuild::Cancelled { scanned_files } => IndexBuild::Cancelled { scanned_files },
137        DriverBuild::Failed { message } => IndexBuild::Failed { message },
138        DriverBuild::Panicked => IndexBuild::Failed {
139            message: "symbol indexing worker panicked; retry the build".to_owned(),
140        },
141    }
142}
143
144/// Analyzes symbols and imports for the supplied paths in parallel.
145///
146/// Paths are prefiltered when their registered language supports either
147/// symbols or imports. Duplicate inputs are all scanned and retained; the
148/// returned vector is stably sorted by path, preserving duplicate input order.
149pub fn analyze_paths(
150    registry: &LanguageRegistry,
151    loader: &dyn SourceLoader,
152    paths: &[String],
153    cancel: &dyn CancelSignal,
154    options: &BuildOptions,
155) -> AnalyzeBuild {
156    match drive_paths(
157        registry,
158        loader,
159        paths,
160        cancel,
161        options,
162        supports_analysis,
163        analyze_source,
164    ) {
165        DriverBuild::Completed {
166            files,
167            scanned_files,
168        } => AnalyzeBuild::Completed {
169            files,
170            scanned_files,
171        },
172        DriverBuild::Cancelled { scanned_files } => AnalyzeBuild::Cancelled { scanned_files },
173        DriverBuild::Failed { message } => AnalyzeBuild::Failed { message },
174        DriverBuild::Panicked => AnalyzeBuild::Failed {
175            message: "source analysis worker panicked; retry the analysis".to_owned(),
176        },
177    }
178}
179
180type SupportPredicate = fn(&LanguageRegistry, &Path) -> bool;
181type Analyzer = fn(&str, &str, u64, &mut ParserPool<'_>) -> FileAnalysis;
182
183enum DriverBuild {
184    Completed {
185        files: Vec<FileAnalysis>,
186        scanned_files: usize,
187    },
188    Cancelled {
189        scanned_files: usize,
190    },
191    Failed {
192        message: String,
193    },
194    Panicked,
195}
196
197struct ChunkOutcome {
198    files: Vec<FileAnalysis>,
199    scanned: usize,
200    stopped_early: bool,
201}
202
203#[allow(clippy::too_many_arguments)]
204fn drive_paths(
205    registry: &LanguageRegistry,
206    loader: &dyn SourceLoader,
207    paths: &[String],
208    cancel: &dyn CancelSignal,
209    options: &BuildOptions,
210    supports: SupportPredicate,
211    analyzer: Analyzer,
212) -> DriverBuild {
213    if let Err(message) = loader.verify() {
214        return DriverBuild::Failed { message };
215    }
216
217    if cancel.is_cancelled() {
218        return DriverBuild::Cancelled { scanned_files: 0 };
219    }
220
221    let mut analyzable = Vec::new();
222    for (position, path) in paths.iter().enumerate() {
223        if position != 0 && position % PREFILTER_CANCEL_POLL_INTERVAL == 0 && cancel.is_cancelled()
224        {
225            return DriverBuild::Cancelled { scanned_files: 0 };
226        }
227        if supports(registry, Path::new(path))
228            && loader
229                .probe(path)
230                .is_some_and(|length| length <= options.max_file_bytes)
231        {
232            analyzable.push(path);
233        }
234    }
235
236    let workers = std::thread::available_parallelism()
237        .map(|parallelism| parallelism.get())
238        .unwrap_or(1)
239        .clamp(1, options.max_workers.max(1))
240        .min(analyzable.len().max(1));
241    let chunk_size = analyzable.len().div_ceil(workers).max(1);
242
243    let outcomes: Vec<std::thread::Result<ChunkOutcome>> = std::thread::scope(|scope| {
244        let handles: Vec<_> = analyzable
245            .chunks(chunk_size)
246            .map(|chunk| {
247                scope.spawn(move || analyze_chunk(registry, loader, chunk, cancel, analyzer))
248            })
249            .collect();
250        handles.into_iter().map(|handle| handle.join()).collect()
251    });
252
253    let mut files = Vec::new();
254    let mut scanned_files = 0;
255    let mut stopped_early = false;
256    for outcome in outcomes {
257        let Ok(outcome) = outcome else {
258            return DriverBuild::Panicked;
259        };
260        files.extend(outcome.files);
261        scanned_files += outcome.scanned;
262        stopped_early |= outcome.stopped_early;
263    }
264
265    if stopped_early {
266        return DriverBuild::Cancelled { scanned_files };
267    }
268
269    files.sort_by(|a, b| a.path.cmp(&b.path));
270    DriverBuild::Completed {
271        files,
272        scanned_files,
273    }
274}
275
276fn analyze_chunk(
277    registry: &LanguageRegistry,
278    loader: &dyn SourceLoader,
279    paths: &[&String],
280    cancel: &dyn CancelSignal,
281    analyzer: Analyzer,
282) -> ChunkOutcome {
283    let mut pool = ParserPool::new(registry);
284    let mut outcome = ChunkOutcome {
285        files: Vec::new(),
286        scanned: 0,
287        stopped_early: false,
288    };
289
290    for path in paths {
291        if cancel.is_cancelled() {
292            outcome.stopped_early = true;
293            return outcome;
294        }
295        outcome.scanned += 1;
296        let Some(source) = loader.load(path) else {
297            continue;
298        };
299        let content_hash = xxh3_64(source.as_bytes());
300        outcome
301            .files
302            .push(analyzer(&source, path, content_hash, &mut pool));
303    }
304
305    outcome
306}
307
308fn supports_symbols(registry: &LanguageRegistry, path: &Path) -> bool {
309    registry.supports_symbols(path)
310}
311
312fn supports_analysis(registry: &LanguageRegistry, path: &Path) -> bool {
313    registry.supports_symbols(path) || registry.supports_imports(path)
314}
315
316fn analyze_symbols_only(
317    source: &str,
318    path: &str,
319    content_hash: u64,
320    pool: &mut ParserPool<'_>,
321) -> FileAnalysis {
322    FileAnalysis {
323        path: CompactString::from(path),
324        content_hash,
325        language: None,
326        symbols: extract_symbols(source, path, pool),
327        imports: Vec::new(),
328        has_opaque_imports: false,
329    }
330}