use crate::CliError;
use code_extract::{
call_lookup_names, extract, indexed_under, resolve_call, resolve_import, resolve_mention,
CallScope, FileFacts, SymbolIndex, MAX_FILE_BYTES,
};
use core_api::repograph::rules::{about_rule, concept_sources_rule, ABOUT_LABELS};
use core_api::{default_max_edges, BatchOp, Predicate, RuleDef, Value};
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
pub type Db = <core_api::WriteGuard<'static> as std::ops::Deref>::Target;
pub const MAX_SYMBOLS_PER_FILE: usize = 2_000;
const BATCH_FILES: usize = 500;
pub const DEFINES_RULE: &str = "auto_fk_symbol_file_id";
pub const FULLTEXT: [(&str, &str); 8] = [
("Concept", "name"),
("Concept", "summary"),
("File", "body"),
("File", "headings"),
("File", "path"),
("Note", "text"),
("Symbol", "doc"),
("Symbol", "name"),
];
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct StructureReport {
pub files_scanned: usize,
pub symbols: usize,
pub imports: usize,
pub mentions: usize,
pub calls: usize,
pub skipped_large: usize,
pub symbols_capped: usize,
}
#[must_use]
pub fn rules() -> Vec<RuleDef> {
let mut out = vec![
key_rule(DEFINES_RULE, "Symbol", "File", "file_id", "DEFINES"),
key_rule("imports", "File", "File", "imports", "IMPORTS"),
key_rule("calls", "Symbol", "Symbol", "calls_to", "CALLS"),
key_rule("mentions", "File", "File", "mentions", "MENTIONS"),
concept_sources_rule(),
];
for label in ABOUT_LABELS {
out.push(about_rule(label));
}
out
}
fn key_rule(name: &str, src: &str, dst: &str, field: &str, edge: &str) -> RuleDef {
let predicate = Predicate::KeyMatch {
field: field.into(),
};
let max_edges = Some(default_max_edges(&predicate));
RuleDef {
name: name.into(),
src_label: src.into(),
dst_label: dst.into(),
predicate,
edge_type: edge.into(),
weight_prop: None,
max_edges,
approximate: false,
via_label: None,
via_edge: None,
via_dir: None,
}
}
pub fn ensure_rules_and_fulltext(w: &mut Db) -> Result<Vec<String>, CliError> {
let existing: BTreeSet<String> = w.rules().into_iter().map(|r| r.name).collect();
let mut created = Vec::new();
for def in rules() {
if existing.contains(&def.name) {
continue;
}
if def.src_label == "Note" && !label_present(w, &def.dst_label)? {
continue;
}
let name = def.name.clone();
w.create_rule(def)?;
created.push(name);
}
for (label, field) in FULLTEXT {
if !w
.fulltext_pairs()
.contains(&(label.to_string(), field.to_string()))
{
w.enable_fulltext(label, field)?;
}
}
Ok(created)
}
fn label_present(w: &Db, label: &str) -> Result<bool, CliError> {
let rs = w.query(
&format!("MATCH (n:{label}) RETURN n.id AS id LIMIT 1"),
&BTreeMap::new(),
)?;
Ok(!rs.is_empty())
}
const FILE_KEYS_QUERY: &str = "MATCH (f:File) WHERE startsWith(f.id, $prefix) RETURN f.id AS id";
const SYMBOL_QUERY: &str =
"MATCH (s:Symbol) RETURN s.id AS id, s.name AS name, s.file_id AS file_id";
const LINK_LISTS_QUERY: &str =
"MATCH (f:File) RETURN f.id AS id, f.imports AS imports, f.mentions AS mentions";
pub fn refresh_all(
w: &mut Db,
repo: &Path,
prefix: &str,
with_docs: bool,
) -> Result<StructureReport, CliError> {
refresh(w, repo, prefix, None, with_docs)
}
pub fn refresh_files(
w: &mut Db,
repo: &Path,
prefix: &str,
paths: &[String],
with_docs: bool,
) -> Result<StructureReport, CliError> {
refresh(w, repo, prefix, Some(paths), with_docs)
}
pub fn importers_of(w: &Db, keys: &BTreeSet<String>) -> Result<Vec<String>, CliError> {
if keys.is_empty() {
return Ok(Vec::new());
}
let rs = w.query(LINK_LISTS_QUERY, &BTreeMap::new())?;
let mut out = BTreeSet::new();
for i in 0..rs.len() {
let Some(Value::Str(id)) = rs.get(i, "id") else {
continue;
};
let names = |field: &str| {
matches!(rs.get(i, field), Some(Value::List(l))
if l.iter().any(|v| matches!(v, Value::Str(s) if keys.contains(s))))
};
if names("imports") || names("mentions") {
out.insert(id.clone());
}
}
Ok(out.into_iter().collect())
}
#[derive(Default)]
struct Tree {
files: BTreeSet<String>,
by_dir: BTreeMap<String, Vec<String>>,
by_base: BTreeMap<String, Vec<String>>,
}
impl Tree {
fn build(keys: impl IntoIterator<Item = String>) -> Tree {
let mut tree = Tree::default();
for key in keys {
let (dir, base) = match key.rsplit_once('/') {
Some((d, b)) => (d.to_string(), b.to_string()),
None => (String::new(), key.clone()),
};
tree.by_base.entry(base).or_default().push(key.clone());
tree.by_dir.entry(dir).or_default().push(key.clone());
tree.files.insert(key);
}
tree
}
fn known(&self, path: &str) -> bool {
self.files.contains(path)
}
fn files_in(&self, dir: &str) -> Vec<String> {
self.by_dir.get(dir).cloned().unwrap_or_default()
}
fn by_basename(&self, name: &str) -> Vec<String> {
self.by_base.get(name).cloned().unwrap_or_default()
}
fn roots(&self) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for path in &self.files {
for (at, part) in path.split('/').enumerate() {
let last = at + 1 == path.split('/').count();
let name = match last {
true => part.rsplit_once('.').map_or(part, |(stem, _)| stem),
false => part,
};
if name.is_empty() {
continue;
}
out.insert(name.to_string());
if name.contains('-') {
out.insert(name.replace('-', "_"));
} else if name.contains('_') {
out.insert(name.replace('_', "-"));
}
}
}
out
}
}
struct SymbolWrite {
key: String,
name: String,
kind: &'static str,
line_start: u32,
line_end: u32,
signature: String,
doc: String,
calls: Vec<String>,
call_lines: Vec<String>,
}
struct FileWrite {
path: String,
hash: String,
lines: u32,
lang: &'static str,
imports: Vec<String>,
import_lines: Vec<String>,
mentions: Vec<String>,
headings: Vec<String>,
body: Option<String>,
symbols: Vec<SymbolWrite>,
}
fn list(items: &[String]) -> Value {
Value::List(items.iter().map(|s| Value::Str(s.clone())).collect())
}
fn some_list(items: &[String]) -> Option<Value> {
(!items.is_empty()).then(|| list(items))
}
impl FileWrite {
fn props(&self) -> Vec<(&'static str, Option<Value>)> {
vec![
("hash", Some(Value::Str(self.hash.clone()))),
("lines", Some(Value::Int(i64::from(self.lines)))),
("lang", Some(Value::Str(self.lang.to_string()))),
("symbols_n", Some(Value::Int(self.symbols.len() as i64))),
("imports", some_list(&self.imports)),
("import_lines", some_list(&self.import_lines)),
("mentions", some_list(&self.mentions)),
("headings", some_list(&self.headings)),
("body", self.body.as_ref().map(|b| Value::Str(b.clone()))),
]
}
}
impl SymbolWrite {
fn props(&self, file: &str) -> Vec<(&'static str, Option<Value>)> {
vec![
("id", Some(Value::Str(self.key.clone()))),
("name", Some(Value::Str(self.name.clone()))),
("kind", Some(Value::Str(self.kind.to_string()))),
("path", Some(Value::Str(file.to_string()))),
("file_id", Some(Value::Str(file.to_string()))),
("line_start", Some(Value::Int(i64::from(self.line_start)))),
("line_end", Some(Value::Int(i64::from(self.line_end)))),
("signature", Some(Value::Str(self.signature.clone()))),
("doc", Some(Value::Str(self.doc.clone()))),
("calls_to", some_list(&self.calls)),
("call_lines", some_list(&self.call_lines)),
]
}
}
fn symbol_keys(path: &str, facts: &FileFacts) -> (Vec<(String, usize)>, bool) {
let mut seen = BTreeSet::new();
let mut out = Vec::new();
let mut capped = false;
for (at, sym) in facts.symbols.iter().enumerate() {
let key = format!("{path}#{}", sym.name);
if !seen.insert(key.clone()) {
continue;
}
if out.len() == MAX_SYMBOLS_PER_FILE {
capped = true;
break;
}
out.push((key, at));
}
(out, capped)
}
fn call_names(facts: &BTreeMap<String, FileFacts>) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for f in facts.values() {
for sym in &f.symbols {
for call in &sym.calls {
out.extend(call_lookup_names(&call.callee));
}
}
}
out
}
fn refresh(
w: &mut Db,
repo: &Path,
prefix: &str,
only: Option<&[String]>,
with_docs: bool,
) -> Result<StructureReport, CliError> {
let params = BTreeMap::from([("prefix".to_string(), Value::Str(prefix.to_string()))]);
let rs = w.query(FILE_KEYS_QUERY, ¶ms)?;
let mut candidates = Vec::new();
for i in 0..rs.len() {
if let Some(Value::Str(id)) = rs.get(i, "id") {
if repo.join(id).is_file() {
candidates.push(id.clone());
}
}
}
let tree = Tree::build(candidates.iter().cloned());
let targets: Vec<String> = match only {
None => candidates,
Some(paths) => {
let wanted: BTreeSet<&String> = paths.iter().collect();
candidates
.into_iter()
.filter(|p| wanted.contains(p))
.collect()
}
};
let mut facts: BTreeMap<String, FileFacts> = BTreeMap::new();
let mut hash_only: BTreeSet<String> = BTreeSet::new();
for path in &targets {
let Ok(bytes) = std::fs::read(repo.join(path)) else {
continue; };
if bytes.len() > MAX_FILE_BYTES || is_binary(&bytes) {
hash_only.insert(path.clone());
}
facts.insert(path.clone(), extract(path, &bytes));
}
let looked_up = only.map(|_| call_names(&facts));
let responsible: Option<BTreeSet<&str>> =
only.map(|paths| paths.iter().map(String::as_str).collect());
let stored = w.query(SYMBOL_QUERY, &BTreeMap::new())?;
let mut by_file: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
let mut orphans: Vec<String> = Vec::new();
let mut index = SymbolIndex::new();
for i in 0..stored.len() {
let (Some(Value::Str(id)), Some(Value::Str(file))) =
(stored.get(i, "id"), stored.get(i, "file_id"))
else {
continue;
};
if responsible
.as_ref()
.is_none_or(|r| r.contains(file.as_str()))
{
if !w.has_node(file.as_str()) {
orphans.push(id.clone());
continue;
}
by_file.entry(file.clone()).or_default().insert(id.clone());
}
if facts.contains_key(file) || !tree.known(file) {
continue; }
let Some(Value::Str(name)) = stored.get(i, "name") else {
continue;
};
if looked_up.as_ref().is_none_or(|names| {
indexed_under(name)
.iter()
.any(|under| names.contains(under))
}) {
index.insert(name, id);
}
}
let mut report = StructureReport::default();
let mut keyed: BTreeMap<&String, Vec<(String, usize)>> = BTreeMap::new();
for (path, f) in &facts {
let (keys, capped) = symbol_keys(path, f);
for (key, at) in &keys {
index.insert(&f.symbols[*at].name, key);
}
report.symbols_capped += usize::from(capped);
keyed.insert(path, keys);
}
let roots = tree.roots();
let pass = Pass {
tree: &tree,
index: &index,
roots: &roots,
with_docs,
};
let mut writes: Vec<FileWrite> = Vec::new();
for (path, f) in &facts {
let write = resolve_file(path, f, &keyed[path], &pass);
report.files_scanned += 1;
report.symbols += write.symbols.len();
report.imports += write.imports.len();
report.mentions += write.mentions.len();
report.calls += write.symbols.iter().map(|s| s.calls.len()).sum::<usize>();
report.skipped_large += usize::from(hash_only.contains(path));
writes.push(write);
}
if !orphans.is_empty() {
let ops = orphans
.iter()
.map(|key| BatchOp::DeleteNode { key: key.clone() })
.collect();
commit(w, ops)?;
}
for chunk in writes.chunks(BATCH_FILES) {
let mut ops = Vec::new();
for file in chunk {
plan_file(w, file, by_file.get(&file.path), &mut ops);
}
commit(w, ops)?;
}
Ok(report)
}
fn commit(w: &mut Db, ops: Vec<BatchOp>) -> Result<(), CliError> {
if ops.is_empty() {
return Ok(());
}
let (results, sync) = w.commit_group(vec![ops]);
for r in results {
r?;
}
match sync {
Some(e) => Err(CliError(e.to_string())),
None => Ok(()),
}
}
fn is_binary(bytes: &[u8]) -> bool {
bytes[..bytes.len().min(8 * 1024)].contains(&0)
}
struct Pass<'a> {
tree: &'a Tree,
index: &'a SymbolIndex,
roots: &'a BTreeSet<String>,
with_docs: bool,
}
fn resolve_file(path: &str, f: &FileFacts, keys: &[(String, usize)], pass: &Pass<'_>) -> FileWrite {
let (tree, index, with_docs) = (pass.tree, pass.index, pass.with_docs);
let known = |p: &str| tree.known(p);
let files_in = |d: &str| tree.files_in(d);
let by_base = |n: &str| tree.by_basename(n);
let mut imports = BTreeSet::new();
let mut import_lines = BTreeSet::new();
for imp in &f.imports {
for target in resolve_import(f.lang, path, &imp.raw, &known, &files_in) {
if target == path {
continue;
}
import_lines.insert(format!("{target}\t{}", imp.line));
imports.insert(target);
}
}
let mut mentions = BTreeSet::new();
if with_docs {
for token in &f.mentions {
if let Some(target) = resolve_mention(path, token, &known, &by_base) {
if target != path {
mentions.insert(target);
}
}
}
}
let imported: Vec<String> = imports.iter().cloned().collect();
let scope = CallScope {
imports: &imported,
roots: pass.roots,
};
let mut symbols = Vec::with_capacity(keys.len());
for (key, at) in keys {
let fact = &f.symbols[*at];
let mut calls = BTreeSet::new();
let mut call_lines = BTreeSet::new();
for call in &fact.calls {
let Some(target) = resolve_call(path, call, index, &scope) else {
continue;
};
if &target == key {
continue; }
call_lines.insert(format!("{target}\t{}", call.line));
calls.insert(target);
}
symbols.push(SymbolWrite {
key: key.clone(),
name: fact.name.clone(),
kind: fact.kind,
line_start: fact.line_start,
line_end: fact.line_end,
signature: fact.signature.clone(),
doc: fact.doc.clone(),
calls: calls.into_iter().collect(),
call_lines: call_lines.into_iter().collect(),
});
}
FileWrite {
path: path.to_string(),
hash: f.hash.clone(),
lines: f.lines,
lang: f.lang.as_str(),
imports: imports.into_iter().collect(),
import_lines: import_lines.into_iter().collect(),
mentions: mentions.into_iter().collect(),
headings: if with_docs {
f.headings.clone()
} else {
Vec::new()
},
body: if with_docs { f.body.clone() } else { None },
symbols,
}
}
fn plan_file(w: &Db, file: &FileWrite, held: Option<&BTreeSet<String>>, ops: &mut Vec<BatchOp>) {
for (field, want) in file.props() {
diff_prop(w, &file.path, field, want, ops);
}
let wanted: BTreeSet<&String> = file.symbols.iter().map(|s| &s.key).collect();
for key in held.into_iter().flatten() {
if !wanted.contains(key) {
ops.push(BatchOp::DeleteNode { key: key.clone() });
}
}
for sym in &file.symbols {
let props = sym.props(&file.path);
match w.node_ref(&sym.key).map(|n| n.label().to_string()) {
Some(label) if label != "Symbol" => continue,
Some(_) => {
for (field, want) in props {
diff_prop(w, &sym.key, field, want, ops);
}
}
None => ops.push(BatchOp::InsertNode {
label: "Symbol".into(),
key: sym.key.clone(),
props: props
.into_iter()
.filter_map(|(f, v)| v.map(|v| (f.to_string(), v)))
.collect(),
}),
}
}
}
fn diff_prop(w: &Db, key: &str, field: &str, want: Option<Value>, ops: &mut Vec<BatchOp>) {
let current = w.node_ref(key).and_then(|n| n.prop(field));
if current == want {
return;
}
match want {
Some(value) => ops.push(BatchOp::SetProp {
key: key.to_string(),
field: field.to_string(),
value,
}),
None => ops.push(BatchOp::RemoveProp {
key: key.to_string(),
field: field.to_string(),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rules_cover_every_derived_structure_edge() {
let names: Vec<String> = rules().into_iter().map(|r| r.name).collect();
for want in [
DEFINES_RULE,
"imports",
"calls",
"mentions",
"concept_sources",
"about_author",
"about_concept",
"about_file",
"about_note",
"about_symbol",
] {
assert!(names.contains(&want.to_string()), "missing rule {want}");
}
for def in rules() {
assert_eq!(
def.max_edges,
Some(default_max_edges(&def.predicate)),
"{} must state its fan-out",
def.name
);
}
}
#[test]
fn the_tree_answers_every_lookup_the_resolvers_need() {
let tree = Tree::build([
"src/lib.rs".to_string(),
"src/net/mod.rs".to_string(),
"README.md".to_string(),
]);
assert!(tree.known("src/lib.rs"));
assert!(!tree.known("src/gone.rs"));
assert_eq!(tree.files_in("src"), vec!["src/lib.rs".to_string()]);
assert_eq!(tree.files_in("nope"), Vec::<String>::new());
assert_eq!(
tree.by_basename("mod.rs"),
vec!["src/net/mod.rs".to_string()]
);
assert_eq!(tree.files_in(""), vec!["README.md".to_string()]);
}
#[test]
fn binary_probe_matches_the_extractors() {
assert!(!is_binary(b"pub fn a() {}"));
assert!(is_binary(b"pub fn a() {}\0"));
assert!(!is_binary(b""));
}
}