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