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        namespace: None,
165    }
166}
167
168/// Declare the structure rules and full-text fields that are missing, and
169/// return the names of the rules created.
170///
171/// Idempotent: existence is checked against `rules()` and `fulltext_pairs()`,
172/// so a second call writes nothing. Call it *after* the props are written — a
173/// rule backfills once, on creation, and by then both the `Symbol` label and
174/// the lists it matches on exist.
175pub fn ensure_rules_and_fulltext(w: &mut Db) -> Result<Vec<String>, CliError> {
176    let existing: BTreeSet<String> = w.rules().into_iter().map(|r| r.name).collect();
177    let mut created = Vec::new();
178    for def in rules() {
179        if existing.contains(&def.name) {
180            continue;
181        }
182        // An `about_<label>` rule is only worth declaring once something can
183        // be on the receiving end of it.
184        if def.src_label == "Note" && !label_present(w, &def.dst_label)? {
185            continue;
186        }
187        let name = def.name.clone();
188        w.create_rule(def)?;
189        created.push(name);
190    }
191    for (label, field) in FULLTEXT {
192        if !w
193            .fulltext_pairs()
194            .contains(&(label.to_string(), field.to_string()))
195        {
196            w.enable_fulltext(label, field)?;
197        }
198    }
199    Ok(created)
200}
201
202/// Whether the graph holds at least one node of `label`. `label` is always one
203/// of [`ABOUT_LABELS`], so it is never user input.
204fn label_present(w: &Db, label: &str) -> Result<bool, CliError> {
205    let rs = w.query(
206        &format!("MATCH (n:{label}) RETURN n.id AS id LIMIT 1"),
207        &BTreeMap::new(),
208    )?;
209    Ok(!rs.is_empty())
210}
211
212/// The `File` keys under a key prefix. `""` is the whole graph, `"vendor/lib/"`
213/// one submodule; keys are repository-relative with `/` separators, which is
214/// exactly what `code-extract` resolves against.
215const FILE_KEYS_QUERY: &str = "MATCH (f:File) WHERE startsWith(f.id, $prefix) RETURN f.id AS id";
216
217/// Every `Symbol` node, with the file it belongs to.
218const SYMBOL_QUERY: &str =
219    "MATCH (s:Symbol) RETURN s.id AS id, s.name AS name, s.file_id AS file_id";
220
221/// Every `File` node's link lists, for the stale-key scan.
222const LINK_LISTS_QUERY: &str =
223    "MATCH (f:File) RETURN f.id AS id, f.imports AS imports, f.mentions AS mentions";
224
225/// Refresh every working-tree file under `prefix`.
226///
227/// Writes in batches of [`BATCH_FILES`] files, one WAL commit per batch. Files
228/// whose stored props already match the working tree are left untouched.
229pub fn refresh_all(
230    w: &mut Db,
231    repo: &Path,
232    prefix: &str,
233    with_docs: bool,
234) -> Result<StructureReport, CliError> {
235    refresh(w, repo, prefix, None, with_docs)
236}
237
238/// Refresh exactly `paths` — those of them that are still files on disk.
239///
240/// The incremental counterpart of [`refresh_all`]: the caller passes the paths
241/// this sync touched, plus every file whose link lists named a path that moved
242/// or vanished. See [`importers_of`].
243pub fn refresh_files(
244    w: &mut Db,
245    repo: &Path,
246    prefix: &str,
247    paths: &[String],
248    with_docs: bool,
249) -> Result<StructureReport, CliError> {
250    refresh(w, repo, prefix, Some(paths), with_docs)
251}
252
253/// The `File` keys whose `imports` or `mentions` list still names one of
254/// `keys`.
255///
256/// Renaming a node moves the key its list-derived edges point at, but not the
257/// list that derived them: the importer's `imports` still holds the old key,
258/// so the edge is stale until that file is extracted again. After a rename or
259/// a delete, feed this to [`refresh_files`] alongside the paths that changed
260/// and the lists — and with them the edges — are rewritten.
261pub fn importers_of(w: &Db, keys: &BTreeSet<String>) -> Result<Vec<String>, CliError> {
262    if keys.is_empty() {
263        return Ok(Vec::new());
264    }
265    let rs = w.query(LINK_LISTS_QUERY, &BTreeMap::new())?;
266    let mut out = BTreeSet::new();
267    for i in 0..rs.len() {
268        let Some(Value::Str(id)) = rs.get(i, "id") else {
269            continue;
270        };
271        let names = |field: &str| {
272            matches!(rs.get(i, field), Some(Value::List(l))
273                if l.iter().any(|v| matches!(v, Value::Str(s) if keys.contains(s))))
274        };
275        if names("imports") || names("mentions") {
276            out.insert(id.clone());
277        }
278    }
279    Ok(out.into_iter().collect())
280}
281
282// ── the refresh pass ────────────────────────────────────────────────────────
283
284/// The working tree as the resolvers see it, built once per refresh.
285///
286/// Every lookup `code-extract` needs is answered from this one listing, so a
287/// resolution never touches the filesystem and never names a path that has no
288/// `File` node behind it.
289#[derive(Default)]
290struct Tree {
291    files: BTreeSet<String>,
292    by_dir: BTreeMap<String, Vec<String>>,
293    by_base: BTreeMap<String, Vec<String>>,
294}
295
296impl Tree {
297    fn build(keys: impl IntoIterator<Item = String>) -> Tree {
298        let mut tree = Tree::default();
299        for key in keys {
300            let (dir, base) = match key.rsplit_once('/') {
301                Some((d, b)) => (d.to_string(), b.to_string()),
302                None => (String::new(), key.clone()),
303            };
304            tree.by_base.entry(base).or_default().push(key.clone());
305            tree.by_dir.entry(dir).or_default().push(key.clone());
306            tree.files.insert(key);
307        }
308        tree
309    }
310
311    fn known(&self, path: &str) -> bool {
312        self.files.contains(path)
313    }
314
315    fn files_in(&self, dir: &str) -> Vec<String> {
316        self.by_dir.get(dir).cloned().unwrap_or_default()
317    }
318
319    fn by_basename(&self, name: &str) -> Vec<String> {
320        self.by_base.get(name).cloned().unwrap_or_default()
321    }
322
323    /// Every name a path call may lead with: each directory name in the tree
324    /// and each file's stem, plus the `-`/`_` spelling of both.
325    ///
326    /// A package directory is conventionally `code-extract` while the path that
327    /// reaches it is `code_extract`, so both go in. Anything not in here and
328    /// not a symbol — `std`, `serde_json`, `Vec` — names something outside the
329    /// tree, and [`resolve_call`] gives a call leading with it no edge.
330    fn roots(&self) -> BTreeSet<String> {
331        let mut out = BTreeSet::new();
332        for path in &self.files {
333            for (at, part) in path.split('/').enumerate() {
334                let last = at + 1 == path.split('/').count();
335                let name = match last {
336                    true => part.rsplit_once('.').map_or(part, |(stem, _)| stem),
337                    false => part,
338                };
339                if name.is_empty() {
340                    continue;
341                }
342                out.insert(name.to_string());
343                if name.contains('-') {
344                    out.insert(name.replace('-', "_"));
345                } else if name.contains('_') {
346                    out.insert(name.replace('_', "-"));
347                }
348            }
349        }
350        out
351    }
352}
353
354/// One symbol, resolved and ready to store.
355struct SymbolWrite {
356    key: String,
357    name: String,
358    kind: &'static str,
359    line_start: u32,
360    line_end: u32,
361    signature: String,
362    doc: String,
363    /// Resolved callee keys, sorted and deduplicated.
364    calls: Vec<String>,
365    /// `"<callee key>\t<line>"`, sorted and deduplicated.
366    call_lines: Vec<String>,
367}
368
369/// One file's resolved facts, ready to diff against what is stored.
370struct FileWrite {
371    path: String,
372    hash: String,
373    lines: u32,
374    lang: &'static str,
375    imports: Vec<String>,
376    import_lines: Vec<String>,
377    mentions: Vec<String>,
378    headings: Vec<String>,
379    body: Option<String>,
380    symbols: Vec<SymbolWrite>,
381}
382
383fn list(items: &[String]) -> Value {
384    Value::List(items.iter().map(|s| Value::Str(s.clone())).collect())
385}
386
387/// A list prop, or `None` when it is empty — an empty list carries no edge, so
388/// the prop is removed rather than stored blank.
389fn some_list(items: &[String]) -> Option<Value> {
390    (!items.is_empty()).then(|| list(items))
391}
392
393impl FileWrite {
394    /// The props this file should carry. `None` means "must not be set", which
395    /// is how a prop — and the edges derived from it — is retracted.
396    fn props(&self) -> Vec<(&'static str, Option<Value>)> {
397        vec![
398            ("hash", Some(Value::Str(self.hash.clone()))),
399            ("lines", Some(Value::Int(i64::from(self.lines)))),
400            ("lang", Some(Value::Str(self.lang.to_string()))),
401            ("symbols_n", Some(Value::Int(self.symbols.len() as i64))),
402            ("imports", some_list(&self.imports)),
403            ("import_lines", some_list(&self.import_lines)),
404            ("mentions", some_list(&self.mentions)),
405            ("headings", some_list(&self.headings)),
406            ("body", self.body.as_ref().map(|b| Value::Str(b.clone()))),
407        ]
408    }
409}
410
411impl SymbolWrite {
412    fn props(&self, file: &str) -> Vec<(&'static str, Option<Value>)> {
413        vec![
414            ("id", Some(Value::Str(self.key.clone()))),
415            ("name", Some(Value::Str(self.name.clone()))),
416            ("kind", Some(Value::Str(self.kind.to_string()))),
417            ("path", Some(Value::Str(file.to_string()))),
418            ("file_id", Some(Value::Str(file.to_string()))),
419            ("line_start", Some(Value::Int(i64::from(self.line_start)))),
420            ("line_end", Some(Value::Int(i64::from(self.line_end)))),
421            ("signature", Some(Value::Str(self.signature.clone()))),
422            ("doc", Some(Value::Str(self.doc.clone()))),
423            ("calls_to", some_list(&self.calls)),
424            ("call_lines", some_list(&self.call_lines)),
425        ]
426    }
427}
428
429/// The symbols one file contributes: the key each is stored under, and its
430/// index into `FileFacts::symbols`. Two definitions that qualify to the same
431/// name would share a key, so the first wins; the rest are dropped.
432fn symbol_keys(path: &str, facts: &FileFacts) -> (Vec<(String, usize)>, bool) {
433    let mut seen = BTreeSet::new();
434    let mut out = Vec::new();
435    let mut capped = false;
436    for (at, sym) in facts.symbols.iter().enumerate() {
437        let key = format!("{path}#{}", sym.name);
438        if !seen.insert(key.clone()) {
439            continue;
440        }
441        if out.len() == MAX_SYMBOLS_PER_FILE {
442            capped = true;
443            break;
444        }
445        out.push((key, at));
446    }
447    (out, capped)
448}
449
450/// Every index name the calls in `facts` can be looked up under.
451///
452/// [`resolve_call`] reaches the symbol index by name and by nothing else, and
453/// [`call_lookup_names`] says which names one callee produces. So an index
454/// holding every definition of exactly this set resolves identically to one
455/// holding the whole tree — including the repository-wide tier, which turns on
456/// a name being defined exactly once and still sees every definition of the
457/// names it is asked about.
458///
459/// The set depends on the files being extracted and on nothing else, which is
460/// what makes a one-file `touch` cost one file's worth of index.
461fn call_names(facts: &BTreeMap<String, FileFacts>) -> BTreeSet<String> {
462    let mut out = BTreeSet::new();
463    for f in facts.values() {
464        for sym in &f.symbols {
465            for call in &sym.calls {
466                out.extend(call_lookup_names(&call.callee));
467            }
468        }
469    }
470    out
471}
472
473fn refresh(
474    w: &mut Db,
475    repo: &Path,
476    prefix: &str,
477    only: Option<&[String]>,
478    with_docs: bool,
479) -> Result<StructureReport, CliError> {
480    // 1. What the graph believes, narrowed to what is on disk right now.
481    let params = BTreeMap::from([("prefix".to_string(), Value::Str(prefix.to_string()))]);
482    let rs = w.query(FILE_KEYS_QUERY, &params)?;
483    let mut candidates = Vec::new();
484    for i in 0..rs.len() {
485        if let Some(Value::Str(id)) = rs.get(i, "id") {
486            if repo.join(id).is_file() {
487                candidates.push(id.clone());
488            }
489        }
490    }
491    let tree = Tree::build(candidates.iter().cloned());
492
493    // 2. The files this pass is responsible for.
494    let targets: Vec<String> = match only {
495        None => candidates,
496        Some(paths) => {
497            let wanted: BTreeSet<&String> = paths.iter().collect();
498            candidates
499                .into_iter()
500                .filter(|p| wanted.contains(p))
501                .collect()
502        }
503    };
504
505    // 3. Extract. The facts are kept: they are both what gets written and what
506    //    the symbol index is built from.
507    let mut facts: BTreeMap<String, FileFacts> = BTreeMap::new();
508    let mut hash_only: BTreeSet<String> = BTreeSet::new();
509    for path in &targets {
510        let Ok(bytes) = std::fs::read(repo.join(path)) else {
511            continue; // unreadable right now; the next sync tries again
512        };
513        if bytes.len() > MAX_FILE_BYTES || is_binary(&bytes) {
514            hash_only.insert(path.clone());
515        }
516        facts.insert(path.clone(), extract(path, &bytes));
517    }
518
519    // 4. Symbols already in the graph: so a call can reach a file this pass is
520    //    not touching, and so orphans — symbols whose file was renamed away or
521    //    deleted, and whose keys can never be right again — can be swept.
522    //
523    //    A pass that was handed a path list narrows both halves. The work here
524    //    used to be the same on a one-file `touch` as on a whole-tree refresh:
525    //    a `has_node` graph read and an index insert for every symbol in the
526    //    repository, which is the part of `touch` that grows with the codebase
527    //    while the useful work stays constant.
528    let looked_up = only.map(|_| call_names(&facts));
529    let responsible: Option<BTreeSet<&str>> =
530        only.map(|paths| paths.iter().map(String::as_str).collect());
531
532    let stored = w.query(SYMBOL_QUERY, &BTreeMap::new())?;
533    let mut by_file: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
534    let mut orphans: Vec<String> = Vec::new();
535    let mut index = SymbolIndex::new();
536    for i in 0..stored.len() {
537        let (Some(Value::Str(id)), Some(Value::Str(file))) =
538            (stored.get(i, "id"), stored.get(i, "file_id"))
539        else {
540            continue;
541        };
542        // `has_node` is a graph read per row, and its answer can only change
543        // what this pass writes for a path the caller named: those are the
544        // files whose symbol set is being rewritten, and the ones the caller
545        // says have moved or gone. A narrowed pass therefore leaves an orphan
546        // it was not told about alone — `refresh_all` sweeps the lot, and
547        // `ingest-git` and `sync` both hand in the keys their commit walk
548        // retired, so nothing is left for a `touch` to find.
549        if responsible
550            .as_ref()
551            .is_none_or(|r| r.contains(file.as_str()))
552        {
553            if !w.has_node(file.as_str()) {
554                orphans.push(id.clone());
555                continue;
556            }
557            by_file.entry(file.clone()).or_default().insert(id.clone());
558        }
559        if facts.contains_key(file) || !tree.known(file) {
560            continue; // superseded by this pass, or not a working-tree file
561        }
562        let Some(Value::Str(name)) = stored.get(i, "name") else {
563            continue;
564        };
565        // A method is filed under its bare name as well as its qualified one,
566        // so a narrowed index has to keep `Store.flush` when something looks
567        // up `flush` — see `indexed_under`.
568        if looked_up.as_ref().is_none_or(|names| {
569            indexed_under(name)
570                .iter()
571                .any(|under| names.contains(under))
572        }) {
573            index.insert(name, id);
574        }
575    }
576
577    // 5. Resolve. Symbol keys first, so every call is looked up in one index
578    //    spanning the whole tree and a callee in an untouched file still hits.
579    let mut report = StructureReport::default();
580    let mut keyed: BTreeMap<&String, Vec<(String, usize)>> = BTreeMap::new();
581    for (path, f) in &facts {
582        let (keys, capped) = symbol_keys(path, f);
583        for (key, at) in &keys {
584            index.insert(&f.symbols[*at].name, key);
585        }
586        report.symbols_capped += usize::from(capped);
587        keyed.insert(path, keys);
588    }
589
590    // What a path call may lead with, computed once for the whole pass.
591    let roots = tree.roots();
592    let pass = Pass {
593        tree: &tree,
594        index: &index,
595        roots: &roots,
596        with_docs,
597    };
598
599    let mut writes: Vec<FileWrite> = Vec::new();
600    for (path, f) in &facts {
601        let write = resolve_file(path, f, &keyed[path], &pass);
602        report.files_scanned += 1;
603        report.symbols += write.symbols.len();
604        report.imports += write.imports.len();
605        report.mentions += write.mentions.len();
606        report.calls += write.symbols.iter().map(|s| s.calls.len()).sum::<usize>();
607        report.skipped_large += usize::from(hash_only.contains(path));
608        writes.push(write);
609    }
610
611    // 6. Write. Orphans go first and in their own commit: their keys must be
612    //    free before a renamed file re-creates its symbols under the new path.
613    if !orphans.is_empty() {
614        let ops = orphans
615            .iter()
616            .map(|key| BatchOp::DeleteNode { key: key.clone() })
617            .collect();
618        commit(w, ops)?;
619    }
620    for chunk in writes.chunks(BATCH_FILES) {
621        let mut ops = Vec::new();
622        for file in chunk {
623            plan_file(w, file, by_file.get(&file.path), &mut ops);
624        }
625        commit(w, ops)?;
626    }
627    Ok(report)
628}
629
630/// Apply one batch as one WAL commit. An empty batch writes nothing.
631fn commit(w: &mut Db, ops: Vec<BatchOp>) -> Result<(), CliError> {
632    if ops.is_empty() {
633        return Ok(());
634    }
635    let (results, sync) = w.commit_group(vec![ops]);
636    for r in results {
637        r?;
638    }
639    match sync {
640        Some(e) => Err(CliError(e.to_string())),
641        None => Ok(()),
642    }
643}
644
645/// Whether the leading bytes look like something other than text. Mirrors the
646/// probe `code-extract` applies, so the count and the extraction agree.
647fn is_binary(bytes: &[u8]) -> bool {
648    bytes[..bytes.len().min(8 * 1024)].contains(&0)
649}
650
651/// Everything one file's resolution needs beyond its own facts. Built once for
652/// the whole pass, except `types`, which is per file.
653struct Pass<'a> {
654    tree: &'a Tree,
655    index: &'a SymbolIndex,
656    /// What a path call may lead with, over the whole working tree.
657    roots: &'a BTreeSet<String>,
658    with_docs: bool,
659}
660
661/// Turn one file's raw facts into resolved keys.
662fn resolve_file(path: &str, f: &FileFacts, keys: &[(String, usize)], pass: &Pass<'_>) -> FileWrite {
663    let (tree, index, with_docs) = (pass.tree, pass.index, pass.with_docs);
664    let known = |p: &str| tree.known(p);
665    let files_in = |d: &str| tree.files_in(d);
666    let by_base = |n: &str| tree.by_basename(n);
667
668    let mut imports = BTreeSet::new();
669    let mut import_lines = BTreeSet::new();
670    for imp in &f.imports {
671        for target in resolve_import(f.lang, path, &imp.raw, &known, &files_in) {
672            if target == path {
673                continue;
674            }
675            import_lines.insert(format!("{target}\t{}", imp.line));
676            imports.insert(target);
677        }
678    }
679
680    let mut mentions = BTreeSet::new();
681    if with_docs {
682        for token in &f.mentions {
683            if let Some(target) = resolve_mention(path, token, &known, &by_base) {
684                if target != path {
685                    mentions.insert(target);
686                }
687            }
688        }
689    }
690
691    // Calls are resolved against this file's own imports, so a callee defined
692    // in another crate is reachable even when its name is not unique across the
693    // repository. The imports are resolved above, in this same pass.
694    let imported: Vec<String> = imports.iter().cloned().collect();
695    let scope = CallScope {
696        imports: &imported,
697        roots: pass.roots,
698    };
699
700    let mut symbols = Vec::with_capacity(keys.len());
701    for (key, at) in keys {
702        let fact = &f.symbols[*at];
703        let mut calls = BTreeSet::new();
704        let mut call_lines = BTreeSet::new();
705        for call in &fact.calls {
706            let Some(target) = resolve_call(path, call, index, &scope) else {
707                continue;
708            };
709            if &target == key {
710                continue; // a definition calling itself is not a graph edge
711            }
712            call_lines.insert(format!("{target}\t{}", call.line));
713            calls.insert(target);
714        }
715        symbols.push(SymbolWrite {
716            key: key.clone(),
717            name: fact.name.clone(),
718            kind: fact.kind,
719            line_start: fact.line_start,
720            line_end: fact.line_end,
721            signature: fact.signature.clone(),
722            doc: fact.doc.clone(),
723            calls: calls.into_iter().collect(),
724            call_lines: call_lines.into_iter().collect(),
725        });
726    }
727
728    FileWrite {
729        path: path.to_string(),
730        hash: f.hash.clone(),
731        lines: f.lines,
732        lang: f.lang.as_str(),
733        imports: imports.into_iter().collect(),
734        import_lines: import_lines.into_iter().collect(),
735        mentions: mentions.into_iter().collect(),
736        headings: if with_docs {
737            f.headings.clone()
738        } else {
739            Vec::new()
740        },
741        body: if with_docs { f.body.clone() } else { None },
742        symbols,
743    }
744}
745
746/// Queue the ops that make one file's stored state match `file`.
747///
748/// Nothing is queued for a field that already holds the right value, so a file
749/// whose bytes have not changed contributes no ops at all.
750fn plan_file(w: &Db, file: &FileWrite, held: Option<&BTreeSet<String>>, ops: &mut Vec<BatchOp>) {
751    for (field, want) in file.props() {
752        diff_prop(w, &file.path, field, want, ops);
753    }
754
755    let wanted: BTreeSet<&String> = file.symbols.iter().map(|s| &s.key).collect();
756    for key in held.into_iter().flatten() {
757        if !wanted.contains(key) {
758            ops.push(BatchOp::DeleteNode { key: key.clone() });
759        }
760    }
761    for sym in &file.symbols {
762        let props = sym.props(&file.path);
763        match w.node_ref(&sym.key).map(|n| n.label().to_string()) {
764            // A repository may contain a file whose path is literally another
765            // file's symbol key — `#` is a legal character in a path. The node
766            // that got there first keeps it: writing symbol props onto someone
767            // else's `File` node would corrupt it, and there is no second key
768            // to put the symbol under.
769            Some(label) if label != "Symbol" => continue,
770            Some(_) => {
771                for (field, want) in props {
772                    diff_prop(w, &sym.key, field, want, ops);
773                }
774            }
775            None => ops.push(BatchOp::InsertNode {
776                label: "Symbol".into(),
777                key: sym.key.clone(),
778                props: props
779                    .into_iter()
780                    .filter_map(|(f, v)| v.map(|v| (f.to_string(), v)))
781                    .collect(),
782            }),
783        }
784    }
785}
786
787/// Queue a set or a remove for one field, or nothing when it already agrees.
788fn diff_prop(w: &Db, key: &str, field: &str, want: Option<Value>, ops: &mut Vec<BatchOp>) {
789    let current = w.node_ref(key).and_then(|n| n.prop(field));
790    if current == want {
791        return;
792    }
793    match want {
794        Some(value) => ops.push(BatchOp::SetProp {
795            key: key.to_string(),
796            field: field.to_string(),
797            value,
798        }),
799        None => ops.push(BatchOp::RemoveProp {
800            key: key.to_string(),
801            field: field.to_string(),
802        }),
803    }
804}
805
806#[cfg(test)]
807mod tests {
808    use super::*;
809
810    #[test]
811    fn rules_cover_every_derived_structure_edge() {
812        let names: Vec<String> = rules().into_iter().map(|r| r.name).collect();
813        for want in [
814            DEFINES_RULE,
815            "imports",
816            "calls",
817            "mentions",
818            "concept_sources",
819            "about_author",
820            "about_concept",
821            "about_file",
822            "about_note",
823            "about_symbol",
824        ] {
825            assert!(names.contains(&want.to_string()), "missing rule {want}");
826        }
827        for def in rules() {
828            assert_eq!(
829                def.max_edges,
830                Some(default_max_edges(&def.predicate)),
831                "{} must state its fan-out",
832                def.name
833            );
834        }
835    }
836
837    #[test]
838    fn the_tree_answers_every_lookup_the_resolvers_need() {
839        let tree = Tree::build([
840            "src/lib.rs".to_string(),
841            "src/net/mod.rs".to_string(),
842            "README.md".to_string(),
843        ]);
844        assert!(tree.known("src/lib.rs"));
845        assert!(!tree.known("src/gone.rs"));
846        assert_eq!(tree.files_in("src"), vec!["src/lib.rs".to_string()]);
847        assert_eq!(tree.files_in("nope"), Vec::<String>::new());
848        assert_eq!(
849            tree.by_basename("mod.rs"),
850            vec!["src/net/mod.rs".to_string()]
851        );
852        assert_eq!(tree.files_in(""), vec!["README.md".to_string()]);
853    }
854
855    #[test]
856    fn binary_probe_matches_the_extractors() {
857        assert!(!is_binary(b"pub fn a() {}"));
858        assert!(is_binary(b"pub fn a() {}\0"));
859        assert!(!is_binary(b""));
860    }
861}