Skip to main content

cli/
structure.rs

1//! Structure ingest: the working tree, read as graph.
2//!
3//! [`ingest_git`](crate::ingest_git) builds the *history* half of a codebase
4//! graph — who touched what, when. This module builds the *present* half from
5//! the files on disk: what each file defines, what it imports, what its
6//! definitions call, and what the prose says about it.
7//!
8//! It never writes an edge itself. Every relationship is stored as a list
9//! property and left to a rule, so the engine owns retraction: rewrite a
10//! file's `imports` and the stale `IMPORTS` edges retract in the same commit.
11//!
12//! | Written on | Prop | Derives |
13//! |---|---|---|
14//! | `File` | `imports: [File key]` | `IMPORTS` File → File |
15//! | `File` | `mentions: [File key]` | `MENTIONS` File → File |
16//! | `Symbol` | `calls_to: [Symbol key]` | `CALLS` Symbol → Symbol |
17//! | `Symbol` | `file_id: File key` | `DEFINES` Symbol → File |
18//!
19//! Beside each list sits its evidence: `import_lines` and `call_lines` hold
20//! `"<key>\t<line>"` strings, so a tool can quote the line a link came from.
21//! One entry per call *site*, not one per callee: a symbol that calls another
22//! twelve times contributes twelve entries and one `calls_to` element, which is
23//! what lets `context` name every line a change would have to visit.
24//!
25//! # Resolving a call
26//!
27//! A file's imports are resolved before its calls, and the resolved list is fed
28//! to [`resolve_call`]: a definition living in a file this one imports beats a
29//! same-named definition anywhere else in the tree. Without that a call could
30//! only cross a crate boundary when the callee's name happened to be unique
31//! across the whole repository.
32//!
33//! That repository-wide tier is also the only one a call written on a receiver
34//! never reaches. `.collect()` and `.ok()` name methods on types from outside
35//! the tree, and uniqueness would bind them to any single same-named function
36//! it found. A method call resolves through the local and imported tiers or not
37//! at all — and it reaches a method there through the bare name the index files
38//! every `Type.method` under, gated on a receiver that says which type it is.
39//!
40//! # What is read
41//!
42//! Only the working tree. The candidates are the `File` nodes git already put
43//! in the graph — so exclusion patterns have been applied — narrowed to those
44//! that exist on disk right now. A path that lives only in history keeps
45//! whatever git recorded about it and is skipped here.
46//!
47//! # What is written
48//!
49//! Only differences. Each file's stored props are compared field by field
50//! against the freshly extracted ones, and its `Symbol` nodes against the
51//! symbols just found in it. A file whose bytes have not changed produces no
52//! write at all, which is what makes a re-run byte-identical.
53use crate::CliError;
54use code_extract::{
55    call_lookup_names, extract, indexed_under, resolve_call, resolve_import, resolve_mention,
56    CallScope, FileFacts, SymbolIndex, MAX_FILE_BYTES,
57};
58use core_api::repograph::rules::{about_rule, concept_sources_rule, ABOUT_LABELS};
59use core_api::{default_max_edges, BatchOp, Predicate, RuleDef, Value};
60use std::collections::{BTreeMap, BTreeSet};
61use std::path::Path;
62
63/// `GraphDb` over the real filesystem, named without spelling out `RealFs` —
64/// which `core-api` does not re-export. Both an open [`core_api::GraphDb`] and
65/// a [`core_api::WriteGuard`] (through its `Deref`) are one of these.
66pub type Db = <core_api::WriteGuard<'static> as std::ops::Deref>::Target;
67
68/// Most `Symbol` nodes kept for one file. A generated or vendored file can
69/// define tens of thousands; past this the file is still hashed and its
70/// imports still resolve, it just stops contributing definitions.
71pub const MAX_SYMBOLS_PER_FILE: usize = 2_000;
72
73/// Files per write batch, and so per WAL commit.
74const BATCH_FILES: usize = 500;
75
76/// Name of the `Symbol.file_id` foreign-key rule. Identical to the name
77/// zero-config FK inference would choose, so the two never both create it —
78/// and declaring it here is what makes the edge `DEFINES` rather than the
79/// `FILE` that inference would derive from the field name.
80pub const DEFINES_RULE: &str = "auto_fk_symbol_file_id";
81
82/// `(label, field)` pairs this module indexes for full-text search.
83///
84/// `Note` and `Concept` are indexed here rather than where they are written
85/// (`remember` and the semantic-pass `ingest_json`) because a store synced
86/// after either wrote is still expected to gain the index — `remember` also
87/// ensures its own `Note.text` pair, in case it is the very first write.
88pub const FULLTEXT: [(&str, &str); 8] = [
89    ("Concept", "name"),
90    ("Concept", "summary"),
91    ("File", "body"),
92    ("File", "headings"),
93    ("File", "path"),
94    ("Note", "text"),
95    ("Symbol", "doc"),
96    ("Symbol", "name"),
97];
98
99/// What one refresh saw. Counts cover every file *scanned*, including those
100/// that needed no write, so the numbers are stable across re-runs.
101#[derive(Debug, Default, Clone, PartialEq, Eq)]
102pub struct StructureReport {
103    /// Files read from disk and extracted.
104    pub files_scanned: usize,
105    /// Symbols found across those files, after the per-file cap.
106    pub symbols: usize,
107    /// Resolved import targets.
108    pub imports: usize,
109    /// Resolved documentation mentions.
110    pub mentions: usize,
111    /// Resolved calls between symbols.
112    pub calls: usize,
113    /// Files reduced to hash, language and line count because they are over
114    /// [`MAX_FILE_BYTES`] or are not text.
115    pub skipped_large: usize,
116    /// Files that hit [`MAX_SYMBOLS_PER_FILE`].
117    pub symbols_capped: usize,
118}
119
120/// Every rule this module declares, in creation order.
121///
122/// Exported so a test — or a store built some other way — can recreate exactly
123/// the rule set these props expect. [`ensure_rules_and_fulltext`] creates an
124/// `about_<label>` rule only when its destination label is present in the
125/// graph; every other rule here is unconditional. The `about_<label>` and
126/// `concept_sources` definitions themselves come from
127/// [`core_api::repograph::rules`], which `remember` also builds them from —
128/// one definition, so a note written by `remember` and one backfilled by a
129/// sync agree on exactly the same rule.
130#[must_use]
131pub fn rules() -> Vec<RuleDef> {
132    let mut out = vec![
133        key_rule(DEFINES_RULE, "Symbol", "File", "file_id", "DEFINES"),
134        key_rule("imports", "File", "File", "imports", "IMPORTS"),
135        key_rule("calls", "Symbol", "Symbol", "calls_to", "CALLS"),
136        key_rule("mentions", "File", "File", "mentions", "MENTIONS"),
137        concept_sources_rule(),
138    ];
139    for label in ABOUT_LABELS {
140        out.push(about_rule(label));
141    }
142    out
143}
144
145/// A `KeyMatch` rule with the engine's default fan-out for the predicate,
146/// stated rather than left implicit — the convention across this crate.
147fn key_rule(name: &str, src: &str, dst: &str, field: &str, edge: &str) -> RuleDef {
148    let predicate = Predicate::KeyMatch {
149        field: field.into(),
150    };
151    let max_edges = Some(default_max_edges(&predicate));
152    RuleDef {
153        name: name.into(),
154        src_label: src.into(),
155        dst_label: dst.into(),
156        predicate,
157        edge_type: edge.into(),
158        weight_prop: None,
159        max_edges,
160        approximate: false,
161        via_label: None,
162        via_edge: None,
163        via_dir: None,
164    }
165}
166
167/// Declare the structure rules and full-text fields that are missing, and
168/// return the names of the rules created.
169///
170/// Idempotent: existence is checked against `rules()` and `fulltext_pairs()`,
171/// so a second call writes nothing. Call it *after* the props are written — a
172/// rule backfills once, on creation, and by then both the `Symbol` label and
173/// the lists it matches on exist.
174pub fn ensure_rules_and_fulltext(w: &mut Db) -> Result<Vec<String>, CliError> {
175    let existing: BTreeSet<String> = w.rules().into_iter().map(|r| r.name).collect();
176    let mut created = Vec::new();
177    for def in rules() {
178        if existing.contains(&def.name) {
179            continue;
180        }
181        // An `about_<label>` rule is only worth declaring once something can
182        // be on the receiving end of it.
183        if def.src_label == "Note" && !label_present(w, &def.dst_label)? {
184            continue;
185        }
186        let name = def.name.clone();
187        w.create_rule(def)?;
188        created.push(name);
189    }
190    for (label, field) in FULLTEXT {
191        if !w
192            .fulltext_pairs()
193            .contains(&(label.to_string(), field.to_string()))
194        {
195            w.enable_fulltext(label, field)?;
196        }
197    }
198    Ok(created)
199}
200
201/// Whether the graph holds at least one node of `label`. `label` is always one
202/// of [`ABOUT_LABELS`], so it is never user input.
203fn label_present(w: &Db, label: &str) -> Result<bool, CliError> {
204    let rs = w.query(
205        &format!("MATCH (n:{label}) RETURN n.id AS id LIMIT 1"),
206        &BTreeMap::new(),
207    )?;
208    Ok(!rs.is_empty())
209}
210
211/// The `File` keys under a key prefix. `""` is the whole graph, `"vendor/lib/"`
212/// one submodule; keys are repository-relative with `/` separators, which is
213/// exactly what `code-extract` resolves against.
214const FILE_KEYS_QUERY: &str = "MATCH (f:File) WHERE startsWith(f.id, $prefix) RETURN f.id AS id";
215
216/// Every `Symbol` node, with the file it belongs to.
217const SYMBOL_QUERY: &str =
218    "MATCH (s:Symbol) RETURN s.id AS id, s.name AS name, s.file_id AS file_id";
219
220/// Every `File` node's link lists, for the stale-key scan.
221const LINK_LISTS_QUERY: &str =
222    "MATCH (f:File) RETURN f.id AS id, f.imports AS imports, f.mentions AS mentions";
223
224/// Refresh every working-tree file under `prefix`.
225///
226/// Writes in batches of [`BATCH_FILES`] files, one WAL commit per batch. Files
227/// whose stored props already match the working tree are left untouched.
228pub fn refresh_all(
229    w: &mut Db,
230    repo: &Path,
231    prefix: &str,
232    with_docs: bool,
233) -> Result<StructureReport, CliError> {
234    refresh(w, repo, prefix, None, with_docs)
235}
236
237/// Refresh exactly `paths` — those of them that are still files on disk.
238///
239/// The incremental counterpart of [`refresh_all`]: the caller passes the paths
240/// this sync touched, plus every file whose link lists named a path that moved
241/// or vanished. See [`importers_of`].
242pub fn refresh_files(
243    w: &mut Db,
244    repo: &Path,
245    prefix: &str,
246    paths: &[String],
247    with_docs: bool,
248) -> Result<StructureReport, CliError> {
249    refresh(w, repo, prefix, Some(paths), with_docs)
250}
251
252/// The `File` keys whose `imports` or `mentions` list still names one of
253/// `keys`.
254///
255/// Renaming a node moves the key its list-derived edges point at, but not the
256/// list that derived them: the importer's `imports` still holds the old key,
257/// so the edge is stale until that file is extracted again. After a rename or
258/// a delete, feed this to [`refresh_files`] alongside the paths that changed
259/// and the lists — and with them the edges — are rewritten.
260pub fn importers_of(w: &Db, keys: &BTreeSet<String>) -> Result<Vec<String>, CliError> {
261    if keys.is_empty() {
262        return Ok(Vec::new());
263    }
264    let rs = w.query(LINK_LISTS_QUERY, &BTreeMap::new())?;
265    let mut out = BTreeSet::new();
266    for i in 0..rs.len() {
267        let Some(Value::Str(id)) = rs.get(i, "id") else {
268            continue;
269        };
270        let names = |field: &str| {
271            matches!(rs.get(i, field), Some(Value::List(l))
272                if l.iter().any(|v| matches!(v, Value::Str(s) if keys.contains(s))))
273        };
274        if names("imports") || names("mentions") {
275            out.insert(id.clone());
276        }
277    }
278    Ok(out.into_iter().collect())
279}
280
281// ── the refresh pass ────────────────────────────────────────────────────────
282
283/// The working tree as the resolvers see it, built once per refresh.
284///
285/// Every lookup `code-extract` needs is answered from this one listing, so a
286/// resolution never touches the filesystem and never names a path that has no
287/// `File` node behind it.
288#[derive(Default)]
289struct Tree {
290    files: BTreeSet<String>,
291    by_dir: BTreeMap<String, Vec<String>>,
292    by_base: BTreeMap<String, Vec<String>>,
293}
294
295impl Tree {
296    fn build(keys: impl IntoIterator<Item = String>) -> Tree {
297        let mut tree = Tree::default();
298        for key in keys {
299            let (dir, base) = match key.rsplit_once('/') {
300                Some((d, b)) => (d.to_string(), b.to_string()),
301                None => (String::new(), key.clone()),
302            };
303            tree.by_base.entry(base).or_default().push(key.clone());
304            tree.by_dir.entry(dir).or_default().push(key.clone());
305            tree.files.insert(key);
306        }
307        tree
308    }
309
310    fn known(&self, path: &str) -> bool {
311        self.files.contains(path)
312    }
313
314    fn files_in(&self, dir: &str) -> Vec<String> {
315        self.by_dir.get(dir).cloned().unwrap_or_default()
316    }
317
318    fn by_basename(&self, name: &str) -> Vec<String> {
319        self.by_base.get(name).cloned().unwrap_or_default()
320    }
321
322    /// Every name a path call may lead with: each directory name in the tree
323    /// and each file's stem, plus the `-`/`_` spelling of both.
324    ///
325    /// A package directory is conventionally `code-extract` while the path that
326    /// reaches it is `code_extract`, so both go in. Anything not in here and
327    /// not a symbol — `std`, `serde_json`, `Vec` — names something outside the
328    /// tree, and [`resolve_call`] gives a call leading with it no edge.
329    fn roots(&self) -> BTreeSet<String> {
330        let mut out = BTreeSet::new();
331        for path in &self.files {
332            for (at, part) in path.split('/').enumerate() {
333                let last = at + 1 == path.split('/').count();
334                let name = match last {
335                    true => part.rsplit_once('.').map_or(part, |(stem, _)| stem),
336                    false => part,
337                };
338                if name.is_empty() {
339                    continue;
340                }
341                out.insert(name.to_string());
342                if name.contains('-') {
343                    out.insert(name.replace('-', "_"));
344                } else if name.contains('_') {
345                    out.insert(name.replace('_', "-"));
346                }
347            }
348        }
349        out
350    }
351}
352
353/// One symbol, resolved and ready to store.
354struct SymbolWrite {
355    key: String,
356    name: String,
357    kind: &'static str,
358    line_start: u32,
359    line_end: u32,
360    signature: String,
361    doc: String,
362    /// Resolved callee keys, sorted and deduplicated.
363    calls: Vec<String>,
364    /// `"<callee key>\t<line>"`, sorted and deduplicated.
365    call_lines: Vec<String>,
366}
367
368/// One file's resolved facts, ready to diff against what is stored.
369struct FileWrite {
370    path: String,
371    hash: String,
372    lines: u32,
373    lang: &'static str,
374    imports: Vec<String>,
375    import_lines: Vec<String>,
376    mentions: Vec<String>,
377    headings: Vec<String>,
378    body: Option<String>,
379    symbols: Vec<SymbolWrite>,
380}
381
382fn list(items: &[String]) -> Value {
383    Value::List(items.iter().map(|s| Value::Str(s.clone())).collect())
384}
385
386/// A list prop, or `None` when it is empty — an empty list carries no edge, so
387/// the prop is removed rather than stored blank.
388fn some_list(items: &[String]) -> Option<Value> {
389    (!items.is_empty()).then(|| list(items))
390}
391
392impl FileWrite {
393    /// The props this file should carry. `None` means "must not be set", which
394    /// is how a prop — and the edges derived from it — is retracted.
395    fn props(&self) -> Vec<(&'static str, Option<Value>)> {
396        vec![
397            ("hash", Some(Value::Str(self.hash.clone()))),
398            ("lines", Some(Value::Int(i64::from(self.lines)))),
399            ("lang", Some(Value::Str(self.lang.to_string()))),
400            ("symbols_n", Some(Value::Int(self.symbols.len() as i64))),
401            ("imports", some_list(&self.imports)),
402            ("import_lines", some_list(&self.import_lines)),
403            ("mentions", some_list(&self.mentions)),
404            ("headings", some_list(&self.headings)),
405            ("body", self.body.as_ref().map(|b| Value::Str(b.clone()))),
406        ]
407    }
408}
409
410impl SymbolWrite {
411    fn props(&self, file: &str) -> Vec<(&'static str, Option<Value>)> {
412        vec![
413            ("id", Some(Value::Str(self.key.clone()))),
414            ("name", Some(Value::Str(self.name.clone()))),
415            ("kind", Some(Value::Str(self.kind.to_string()))),
416            ("path", Some(Value::Str(file.to_string()))),
417            ("file_id", Some(Value::Str(file.to_string()))),
418            ("line_start", Some(Value::Int(i64::from(self.line_start)))),
419            ("line_end", Some(Value::Int(i64::from(self.line_end)))),
420            ("signature", Some(Value::Str(self.signature.clone()))),
421            ("doc", Some(Value::Str(self.doc.clone()))),
422            ("calls_to", some_list(&self.calls)),
423            ("call_lines", some_list(&self.call_lines)),
424        ]
425    }
426}
427
428/// The symbols one file contributes: the key each is stored under, and its
429/// index into `FileFacts::symbols`. Two definitions that qualify to the same
430/// name would share a key, so the first wins; the rest are dropped.
431fn symbol_keys(path: &str, facts: &FileFacts) -> (Vec<(String, usize)>, bool) {
432    let mut seen = BTreeSet::new();
433    let mut out = Vec::new();
434    let mut capped = false;
435    for (at, sym) in facts.symbols.iter().enumerate() {
436        let key = format!("{path}#{}", sym.name);
437        if !seen.insert(key.clone()) {
438            continue;
439        }
440        if out.len() == MAX_SYMBOLS_PER_FILE {
441            capped = true;
442            break;
443        }
444        out.push((key, at));
445    }
446    (out, capped)
447}
448
449/// Every index name the calls in `facts` can be looked up under.
450///
451/// [`resolve_call`] reaches the symbol index by name and by nothing else, and
452/// [`call_lookup_names`] says which names one callee produces. So an index
453/// holding every definition of exactly this set resolves identically to one
454/// holding the whole tree — including the repository-wide tier, which turns on
455/// a name being defined exactly once and still sees every definition of the
456/// names it is asked about.
457///
458/// The set depends on the files being extracted and on nothing else, which is
459/// what makes a one-file `touch` cost one file's worth of index.
460fn call_names(facts: &BTreeMap<String, FileFacts>) -> BTreeSet<String> {
461    let mut out = BTreeSet::new();
462    for f in facts.values() {
463        for sym in &f.symbols {
464            for call in &sym.calls {
465                out.extend(call_lookup_names(&call.callee));
466            }
467        }
468    }
469    out
470}
471
472fn refresh(
473    w: &mut Db,
474    repo: &Path,
475    prefix: &str,
476    only: Option<&[String]>,
477    with_docs: bool,
478) -> Result<StructureReport, CliError> {
479    // 1. What the graph believes, narrowed to what is on disk right now.
480    let params = BTreeMap::from([("prefix".to_string(), Value::Str(prefix.to_string()))]);
481    let rs = w.query(FILE_KEYS_QUERY, &params)?;
482    let mut candidates = Vec::new();
483    for i in 0..rs.len() {
484        if let Some(Value::Str(id)) = rs.get(i, "id") {
485            if repo.join(id).is_file() {
486                candidates.push(id.clone());
487            }
488        }
489    }
490    let tree = Tree::build(candidates.iter().cloned());
491
492    // 2. The files this pass is responsible for.
493    let targets: Vec<String> = match only {
494        None => candidates,
495        Some(paths) => {
496            let wanted: BTreeSet<&String> = paths.iter().collect();
497            candidates
498                .into_iter()
499                .filter(|p| wanted.contains(p))
500                .collect()
501        }
502    };
503
504    // 3. Extract. The facts are kept: they are both what gets written and what
505    //    the symbol index is built from.
506    let mut facts: BTreeMap<String, FileFacts> = BTreeMap::new();
507    let mut hash_only: BTreeSet<String> = BTreeSet::new();
508    for path in &targets {
509        let Ok(bytes) = std::fs::read(repo.join(path)) else {
510            continue; // unreadable right now; the next sync tries again
511        };
512        if bytes.len() > MAX_FILE_BYTES || is_binary(&bytes) {
513            hash_only.insert(path.clone());
514        }
515        facts.insert(path.clone(), extract(path, &bytes));
516    }
517
518    // 4. Symbols already in the graph: so a call can reach a file this pass is
519    //    not touching, and so orphans — symbols whose file was renamed away or
520    //    deleted, and whose keys can never be right again — can be swept.
521    //
522    //    A pass that was handed a path list narrows both halves. The work here
523    //    used to be the same on a one-file `touch` as on a whole-tree refresh:
524    //    a `has_node` graph read and an index insert for every symbol in the
525    //    repository, which is the part of `touch` that grows with the codebase
526    //    while the useful work stays constant.
527    let looked_up = only.map(|_| call_names(&facts));
528    let responsible: Option<BTreeSet<&str>> =
529        only.map(|paths| paths.iter().map(String::as_str).collect());
530
531    let stored = w.query(SYMBOL_QUERY, &BTreeMap::new())?;
532    let mut by_file: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
533    let mut orphans: Vec<String> = Vec::new();
534    let mut index = SymbolIndex::new();
535    for i in 0..stored.len() {
536        let (Some(Value::Str(id)), Some(Value::Str(file))) =
537            (stored.get(i, "id"), stored.get(i, "file_id"))
538        else {
539            continue;
540        };
541        // `has_node` is a graph read per row, and its answer can only change
542        // what this pass writes for a path the caller named: those are the
543        // files whose symbol set is being rewritten, and the ones the caller
544        // says have moved or gone. A narrowed pass therefore leaves an orphan
545        // it was not told about alone — `refresh_all` sweeps the lot, and
546        // `ingest-git` and `sync` both hand in the keys their commit walk
547        // retired, so nothing is left for a `touch` to find.
548        if responsible
549            .as_ref()
550            .is_none_or(|r| r.contains(file.as_str()))
551        {
552            if !w.has_node(file.as_str()) {
553                orphans.push(id.clone());
554                continue;
555            }
556            by_file.entry(file.clone()).or_default().insert(id.clone());
557        }
558        if facts.contains_key(file) || !tree.known(file) {
559            continue; // superseded by this pass, or not a working-tree file
560        }
561        let Some(Value::Str(name)) = stored.get(i, "name") else {
562            continue;
563        };
564        // A method is filed under its bare name as well as its qualified one,
565        // so a narrowed index has to keep `Store.flush` when something looks
566        // up `flush` — see `indexed_under`.
567        if looked_up.as_ref().is_none_or(|names| {
568            indexed_under(name)
569                .iter()
570                .any(|under| names.contains(under))
571        }) {
572            index.insert(name, id);
573        }
574    }
575
576    // 5. Resolve. Symbol keys first, so every call is looked up in one index
577    //    spanning the whole tree and a callee in an untouched file still hits.
578    let mut report = StructureReport::default();
579    let mut keyed: BTreeMap<&String, Vec<(String, usize)>> = BTreeMap::new();
580    for (path, f) in &facts {
581        let (keys, capped) = symbol_keys(path, f);
582        for (key, at) in &keys {
583            index.insert(&f.symbols[*at].name, key);
584        }
585        report.symbols_capped += usize::from(capped);
586        keyed.insert(path, keys);
587    }
588
589    // What a path call may lead with, computed once for the whole pass.
590    let roots = tree.roots();
591    let pass = Pass {
592        tree: &tree,
593        index: &index,
594        roots: &roots,
595        with_docs,
596    };
597
598    let mut writes: Vec<FileWrite> = Vec::new();
599    for (path, f) in &facts {
600        let write = resolve_file(path, f, &keyed[path], &pass);
601        report.files_scanned += 1;
602        report.symbols += write.symbols.len();
603        report.imports += write.imports.len();
604        report.mentions += write.mentions.len();
605        report.calls += write.symbols.iter().map(|s| s.calls.len()).sum::<usize>();
606        report.skipped_large += usize::from(hash_only.contains(path));
607        writes.push(write);
608    }
609
610    // 6. Write. Orphans go first and in their own commit: their keys must be
611    //    free before a renamed file re-creates its symbols under the new path.
612    if !orphans.is_empty() {
613        let ops = orphans
614            .iter()
615            .map(|key| BatchOp::DeleteNode { key: key.clone() })
616            .collect();
617        commit(w, ops)?;
618    }
619    for chunk in writes.chunks(BATCH_FILES) {
620        let mut ops = Vec::new();
621        for file in chunk {
622            plan_file(w, file, by_file.get(&file.path), &mut ops);
623        }
624        commit(w, ops)?;
625    }
626    Ok(report)
627}
628
629/// Apply one batch as one WAL commit. An empty batch writes nothing.
630fn commit(w: &mut Db, ops: Vec<BatchOp>) -> Result<(), CliError> {
631    if ops.is_empty() {
632        return Ok(());
633    }
634    let (results, sync) = w.commit_group(vec![ops]);
635    for r in results {
636        r?;
637    }
638    match sync {
639        Some(e) => Err(CliError(e.to_string())),
640        None => Ok(()),
641    }
642}
643
644/// Whether the leading bytes look like something other than text. Mirrors the
645/// probe `code-extract` applies, so the count and the extraction agree.
646fn is_binary(bytes: &[u8]) -> bool {
647    bytes[..bytes.len().min(8 * 1024)].contains(&0)
648}
649
650/// Everything one file's resolution needs beyond its own facts. Built once for
651/// the whole pass, except `types`, which is per file.
652struct Pass<'a> {
653    tree: &'a Tree,
654    index: &'a SymbolIndex,
655    /// What a path call may lead with, over the whole working tree.
656    roots: &'a BTreeSet<String>,
657    with_docs: bool,
658}
659
660/// Turn one file's raw facts into resolved keys.
661fn resolve_file(path: &str, f: &FileFacts, keys: &[(String, usize)], pass: &Pass<'_>) -> FileWrite {
662    let (tree, index, with_docs) = (pass.tree, pass.index, pass.with_docs);
663    let known = |p: &str| tree.known(p);
664    let files_in = |d: &str| tree.files_in(d);
665    let by_base = |n: &str| tree.by_basename(n);
666
667    let mut imports = BTreeSet::new();
668    let mut import_lines = BTreeSet::new();
669    for imp in &f.imports {
670        for target in resolve_import(f.lang, path, &imp.raw, &known, &files_in) {
671            if target == path {
672                continue;
673            }
674            import_lines.insert(format!("{target}\t{}", imp.line));
675            imports.insert(target);
676        }
677    }
678
679    let mut mentions = BTreeSet::new();
680    if with_docs {
681        for token in &f.mentions {
682            if let Some(target) = resolve_mention(path, token, &known, &by_base) {
683                if target != path {
684                    mentions.insert(target);
685                }
686            }
687        }
688    }
689
690    // Calls are resolved against this file's own imports, so a callee defined
691    // in another crate is reachable even when its name is not unique across the
692    // repository. The imports are resolved above, in this same pass.
693    let imported: Vec<String> = imports.iter().cloned().collect();
694    let scope = CallScope {
695        imports: &imported,
696        roots: pass.roots,
697    };
698
699    let mut symbols = Vec::with_capacity(keys.len());
700    for (key, at) in keys {
701        let fact = &f.symbols[*at];
702        let mut calls = BTreeSet::new();
703        let mut call_lines = BTreeSet::new();
704        for call in &fact.calls {
705            let Some(target) = resolve_call(path, call, index, &scope) else {
706                continue;
707            };
708            if &target == key {
709                continue; // a definition calling itself is not a graph edge
710            }
711            call_lines.insert(format!("{target}\t{}", call.line));
712            calls.insert(target);
713        }
714        symbols.push(SymbolWrite {
715            key: key.clone(),
716            name: fact.name.clone(),
717            kind: fact.kind,
718            line_start: fact.line_start,
719            line_end: fact.line_end,
720            signature: fact.signature.clone(),
721            doc: fact.doc.clone(),
722            calls: calls.into_iter().collect(),
723            call_lines: call_lines.into_iter().collect(),
724        });
725    }
726
727    FileWrite {
728        path: path.to_string(),
729        hash: f.hash.clone(),
730        lines: f.lines,
731        lang: f.lang.as_str(),
732        imports: imports.into_iter().collect(),
733        import_lines: import_lines.into_iter().collect(),
734        mentions: mentions.into_iter().collect(),
735        headings: if with_docs {
736            f.headings.clone()
737        } else {
738            Vec::new()
739        },
740        body: if with_docs { f.body.clone() } else { None },
741        symbols,
742    }
743}
744
745/// Queue the ops that make one file's stored state match `file`.
746///
747/// Nothing is queued for a field that already holds the right value, so a file
748/// whose bytes have not changed contributes no ops at all.
749fn plan_file(w: &Db, file: &FileWrite, held: Option<&BTreeSet<String>>, ops: &mut Vec<BatchOp>) {
750    for (field, want) in file.props() {
751        diff_prop(w, &file.path, field, want, ops);
752    }
753
754    let wanted: BTreeSet<&String> = file.symbols.iter().map(|s| &s.key).collect();
755    for key in held.into_iter().flatten() {
756        if !wanted.contains(key) {
757            ops.push(BatchOp::DeleteNode { key: key.clone() });
758        }
759    }
760    for sym in &file.symbols {
761        let props = sym.props(&file.path);
762        match w.node_ref(&sym.key).map(|n| n.label().to_string()) {
763            // A repository may contain a file whose path is literally another
764            // file's symbol key — `#` is a legal character in a path. The node
765            // that got there first keeps it: writing symbol props onto someone
766            // else's `File` node would corrupt it, and there is no second key
767            // to put the symbol under.
768            Some(label) if label != "Symbol" => continue,
769            Some(_) => {
770                for (field, want) in props {
771                    diff_prop(w, &sym.key, field, want, ops);
772                }
773            }
774            None => ops.push(BatchOp::InsertNode {
775                label: "Symbol".into(),
776                key: sym.key.clone(),
777                props: props
778                    .into_iter()
779                    .filter_map(|(f, v)| v.map(|v| (f.to_string(), v)))
780                    .collect(),
781            }),
782        }
783    }
784}
785
786/// Queue a set or a remove for one field, or nothing when it already agrees.
787fn diff_prop(w: &Db, key: &str, field: &str, want: Option<Value>, ops: &mut Vec<BatchOp>) {
788    let current = w.node_ref(key).and_then(|n| n.prop(field));
789    if current == want {
790        return;
791    }
792    match want {
793        Some(value) => ops.push(BatchOp::SetProp {
794            key: key.to_string(),
795            field: field.to_string(),
796            value,
797        }),
798        None => ops.push(BatchOp::RemoveProp {
799            key: key.to_string(),
800            field: field.to_string(),
801        }),
802    }
803}
804
805#[cfg(test)]
806mod tests {
807    use super::*;
808
809    #[test]
810    fn rules_cover_every_derived_structure_edge() {
811        let names: Vec<String> = rules().into_iter().map(|r| r.name).collect();
812        for want in [
813            DEFINES_RULE,
814            "imports",
815            "calls",
816            "mentions",
817            "concept_sources",
818            "about_author",
819            "about_concept",
820            "about_file",
821            "about_note",
822            "about_symbol",
823        ] {
824            assert!(names.contains(&want.to_string()), "missing rule {want}");
825        }
826        for def in rules() {
827            assert_eq!(
828                def.max_edges,
829                Some(default_max_edges(&def.predicate)),
830                "{} must state its fan-out",
831                def.name
832            );
833        }
834    }
835
836    #[test]
837    fn the_tree_answers_every_lookup_the_resolvers_need() {
838        let tree = Tree::build([
839            "src/lib.rs".to_string(),
840            "src/net/mod.rs".to_string(),
841            "README.md".to_string(),
842        ]);
843        assert!(tree.known("src/lib.rs"));
844        assert!(!tree.known("src/gone.rs"));
845        assert_eq!(tree.files_in("src"), vec!["src/lib.rs".to_string()]);
846        assert_eq!(tree.files_in("nope"), Vec::<String>::new());
847        assert_eq!(
848            tree.by_basename("mod.rs"),
849            vec!["src/net/mod.rs".to_string()]
850        );
851        assert_eq!(tree.files_in(""), vec!["README.md".to_string()]);
852    }
853
854    #[test]
855    fn binary_probe_matches_the_extractors() {
856        assert!(!is_binary(b"pub fn a() {}"));
857        assert!(is_binary(b"pub fn a() {}\0"));
858        assert!(!is_binary(b""));
859    }
860}