Skip to main content

llmy_codegraph/
builder.rs

1//! Builds a [`CodeGraph`] from a project root: file discovery (gitignore
2//! aware), Move dialect detection per package, extraction, and by-name
3//! resolution of call sites and state references. Resolution is syntactic:
4//! same-module candidates win, a qualifier that names a module scopes the
5//! search, everything else stays ambiguous or external — explicitly.
6
7use std::collections::{BTreeMap, BTreeSet};
8use std::path::{Path, PathBuf};
9
10use color_eyre::eyre::eyre;
11use ignore::WalkBuilder;
12use llmy_types::error::LLMYError;
13
14use crate::extract::{FileExtraction, SourceFile};
15use crate::model::{
16    CallEdge, Callable, CalleeRef, CodeGraph, InheritEdge, Language, Module, ParentRef, StateEdge,
17    StateItem,
18};
19use crate::move_lang::{MoveAptosExtractor, MoveSuiExtractor};
20use crate::rust_lang::RustExtractor;
21use crate::solidity::SolidityExtractor;
22
23const SOLIDITY_BUILTINS: [&str; 22] = [
24    "require",
25    "assert",
26    "revert",
27    "keccak256",
28    "sha256",
29    "ripemd160",
30    "ecrecover",
31    "addmod",
32    "mulmod",
33    "selfdestruct",
34    "blockhash",
35    "gasleft",
36    "payable",
37    "type",
38    "address",
39    "uint",
40    "uint256",
41    "int",
42    "bytes",
43    "bytes32",
44    "string",
45    "bool",
46];
47const RUST_BUILTINS: [&str; 6] = ["Ok", "Err", "Some", "None", "vec", "panic"];
48const MOVE_BUILTINS: [&str; 4] = ["assert", "abort", "freeze", "vector"];
49
50/// The result of one indexing pass.
51#[derive(Debug, Clone)]
52pub struct BuildResult {
53    pub graph: CodeGraph,
54    /// Files that were parsed, with their per-file parse error counts.
55    pub files: Vec<(PathBuf, Language, usize)>,
56    /// Fingerprint of the indexed inputs, for cache staleness checks.
57    pub fingerprint: String,
58}
59
60impl BuildResult {
61    pub fn total_parse_errors(&self) -> usize {
62        self.files.iter().map(|(_, _, errors)| errors).sum()
63    }
64}
65
66pub struct CodeGraphBuilder {
67    root: PathBuf,
68}
69
70impl CodeGraphBuilder {
71    pub fn new(root: PathBuf) -> Self {
72        Self { root }
73    }
74
75    pub async fn build(&self) -> Result<BuildResult, LLMYError> {
76        let sources = self.discover().await?;
77        let mut extractions = vec![];
78        let mut files = vec![];
79        let mut fingerprint_entries = vec![];
80
81        for (source, language) in sources {
82            let extraction = match language {
83                Language::Solidity => SolidityExtractor::extract(&source)?,
84                Language::Rust => RustExtractor::extract(&source)?,
85                Language::MoveAptos => MoveAptosExtractor::extract(&source)?,
86                Language::MoveSui => MoveSuiExtractor::extract(&source)?,
87            };
88            if extraction.parse_errors > 0 {
89                tracing::warn!(
90                    "{} produced {} parse errors in {}",
91                    language.render(),
92                    extraction.parse_errors,
93                    source.relative.display()
94                );
95            }
96            fingerprint_entries.push(format!(
97                "{}:{}",
98                source.relative.display(),
99                source.content.len()
100            ));
101            files.push((source.relative.clone(), language, extraction.parse_errors));
102            extractions.push(extraction);
103        }
104
105        fingerprint_entries.sort();
106        let graph = GraphAssembler::assemble(extractions);
107        Ok(BuildResult {
108            graph,
109            files,
110            fingerprint: fingerprint_entries.join("\n"),
111        })
112    }
113
114    /// Current fingerprint of the root without extracting anything — cheap
115    /// staleness probe for the cache.
116    pub async fn fingerprint(&self) -> Result<String, LLMYError> {
117        let sources = self.discover().await?;
118        let mut entries: Vec<String> = sources
119            .iter()
120            .map(|(source, _)| format!("{}:{}", source.relative.display(), source.content.len()))
121            .collect();
122        entries.sort();
123        Ok(entries.join("\n"))
124    }
125
126    async fn discover(&self) -> Result<Vec<(SourceFile, Language)>, LLMYError> {
127        let root = self
128            .root
129            .canonicalize()
130            .map_err(|e| eyre!("cannot canonicalize {}: {}", self.root.display(), e))?;
131
132        let mut move_dialects: BTreeMap<PathBuf, Language> = BTreeMap::new();
133        let mut out = vec![];
134        for entry in WalkBuilder::new(&root).build() {
135            let entry = match entry {
136                Ok(entry) => entry,
137                Err(error) => {
138                    tracing::debug!("codegraph walk error: {}", error);
139                    continue;
140                }
141            };
142            if !entry.file_type().map(|t| t.is_file()).unwrap_or(false) {
143                continue;
144            }
145            let path = entry.path();
146            let language = match path.extension().and_then(|e| e.to_str()) {
147                Some("sol") => Language::Solidity,
148                Some("rs") => Language::Rust,
149                Some("move") => self.move_dialect(path, &mut move_dialects).await,
150                _ => continue,
151            };
152            let content = match tokio::fs::read_to_string(path).await {
153                Ok(content) => content,
154                Err(error) => {
155                    tracing::debug!("skipping unreadable {}: {}", path.display(), error);
156                    continue;
157                }
158            };
159            let relative = path.strip_prefix(&root).unwrap_or(path).to_path_buf();
160            out.push((SourceFile { relative, content }, language));
161        }
162        out.sort_by(|a, b| a.0.relative.cmp(&b.0.relative));
163        Ok(out)
164    }
165
166    /// Dialect of a `.move` file: the nearest `Move.toml` decides (a Sui
167    /// framework dependency or a `sui` edition marks Sui), cached per
168    /// directory. Without a manifest the Aptos grammar is assumed — the
169    /// caller sees parse error counts either way.
170    async fn move_dialect(&self, file: &Path, cache: &mut BTreeMap<PathBuf, Language>) -> Language {
171        let mut dir = file.parent();
172        while let Some(current) = dir {
173            if let Some(cached) = cache.get(current) {
174                return *cached;
175            }
176            let manifest = current.join("Move.toml");
177            if manifest.is_file() {
178                let dialect = match tokio::fs::read_to_string(&manifest).await {
179                    Ok(content) => {
180                        let lowered = content.to_lowercase();
181                        if lowered.contains("sui") {
182                            Language::MoveSui
183                        } else {
184                            Language::MoveAptos
185                        }
186                    }
187                    Err(_) => Language::MoveAptos,
188                };
189                cache.insert(current.to_path_buf(), dialect);
190                return dialect;
191            }
192            dir = current.parent();
193        }
194        Language::MoveAptos
195    }
196}
197
198/// Turns per-file extractions into one resolved [`CodeGraph`].
199struct GraphAssembler {
200    graph: CodeGraph,
201    /// callable name -> ids, for call resolution.
202    callables_by_name: BTreeMap<String, Vec<i64>>,
203    /// module name -> ids.
204    modules_by_name: BTreeMap<String, Vec<i64>>,
205}
206
207impl GraphAssembler {
208    fn assemble(extractions: Vec<FileExtraction>) -> CodeGraph {
209        let mut assembler = Self {
210            graph: CodeGraph::default(),
211            callables_by_name: BTreeMap::new(),
212            modules_by_name: BTreeMap::new(),
213        };
214        // Pass one: nodes with globally assigned ids. Raw call sites and
215        // state refs are kept alongside for pass two.
216        let mut pending_calls: Vec<(i64, crate::extract::RawCallSite, Language)> = vec![];
217        let mut pending_states: Vec<(i64, i64, crate::extract::RawStateRef, Language)> = vec![];
218        let mut pending_parents: Vec<(i64, String)> = vec![];
219
220        let mut next_module = 1i64;
221        let mut next_callable = 1i64;
222        let mut next_state = 1i64;
223
224        for extraction in extractions {
225            for raw_module in extraction.modules {
226                let module_id = next_module;
227                next_module += 1;
228                assembler.graph.modules.insert(
229                    module_id,
230                    Module {
231                        id: module_id,
232                        language: extraction.language,
233                        kind: raw_module.kind,
234                        name: raw_module.name.clone(),
235                        file: extraction.file.clone(),
236                        span: raw_module.span,
237                    },
238                );
239                assembler
240                    .modules_by_name
241                    .entry(raw_module.name.clone())
242                    .or_default()
243                    .push(module_id);
244                for parent in raw_module.parents {
245                    pending_parents.push((module_id, parent));
246                }
247                for raw_state in raw_module.states {
248                    let state_id = next_state;
249                    next_state += 1;
250                    assembler.graph.states.insert(
251                        state_id,
252                        StateItem {
253                            id: state_id,
254                            module_id,
255                            kind: raw_state.kind,
256                            name: raw_state.name,
257                            type_text: raw_state.type_text,
258                            file: extraction.file.clone(),
259                            span: raw_state.span,
260                        },
261                    );
262                }
263                for raw_callable in raw_module.callables {
264                    let callable_id = next_callable;
265                    next_callable += 1;
266                    assembler.graph.callables.insert(
267                        callable_id,
268                        Callable {
269                            id: callable_id,
270                            module_id,
271                            kind: raw_callable.kind,
272                            name: raw_callable.name.clone(),
273                            signature: raw_callable.signature,
274                            file: extraction.file.clone(),
275                            span: raw_callable.span,
276                        },
277                    );
278                    assembler
279                        .callables_by_name
280                        .entry(raw_callable.name)
281                        .or_default()
282                        .push(callable_id);
283                    for call in raw_callable.calls {
284                        pending_calls.push((callable_id, call, extraction.language));
285                    }
286                    for state_ref in raw_callable.state_refs {
287                        pending_states.push((
288                            callable_id,
289                            module_id,
290                            state_ref,
291                            extraction.language,
292                        ));
293                    }
294                }
295            }
296        }
297
298        for (module_id, parent_name) in pending_parents {
299            let parent = match assembler.modules_by_name.get(&parent_name) {
300                Some(ids) if ids.len() == 1 => ParentRef::Resolved(ids[0]),
301                Some(ids) if !ids.is_empty() => ParentRef::Resolved(ids[0]),
302                _ => ParentRef::External(parent_name),
303            };
304            assembler
305                .graph
306                .inherit_edges
307                .push(InheritEdge { module_id, parent });
308        }
309
310        assembler.resolve_calls(pending_calls);
311        assembler.resolve_states(pending_states);
312        assembler.graph
313    }
314
315    fn builtin(language: Language, name: &str) -> bool {
316        match language {
317            Language::Solidity => SOLIDITY_BUILTINS.contains(&name),
318            Language::Rust => RUST_BUILTINS.contains(&name),
319            Language::MoveAptos | Language::MoveSui => MOVE_BUILTINS.contains(&name),
320        }
321    }
322
323    fn resolve_calls(&mut self, pending: Vec<(i64, crate::extract::RawCallSite, Language)>) {
324        let mut seen: BTreeSet<(i64, String, usize)> = BTreeSet::new();
325        for (caller_id, site, language) in pending {
326            if Self::builtin(language, &site.name) {
327                continue;
328            }
329            if !seen.insert((caller_id, site.text.clone(), site.line)) {
330                continue;
331            }
332            let caller_module = self
333                .graph
334                .callables
335                .get(&caller_id)
336                .map(|c| c.module_id)
337                .unwrap_or(0);
338
339            let mut candidates: Vec<i64> = self
340                .callables_by_name
341                .get(&site.name)
342                .cloned()
343                .unwrap_or_default();
344
345            // A qualifier that names a known module scopes the candidates to
346            // that module (`token.transfer` with a `Token` variable does not
347            // match this — only real module names do).
348            if let Some(qualifier) = &site.qualifier {
349                let qualified_modules: BTreeSet<i64> = self
350                    .modules_by_name
351                    .iter()
352                    .filter(|(name, _)| {
353                        name.as_str() == qualifier || name.eq_ignore_ascii_case(qualifier)
354                    })
355                    .flat_map(|(_, ids)| ids.iter().copied())
356                    .collect();
357                if !qualified_modules.is_empty() {
358                    let scoped: Vec<i64> = candidates
359                        .iter()
360                        .copied()
361                        .filter(|id| {
362                            self.graph
363                                .callables
364                                .get(id)
365                                .map(|c| qualified_modules.contains(&c.module_id))
366                                .unwrap_or(false)
367                        })
368                        .collect();
369                    if !scoped.is_empty() {
370                        candidates = scoped;
371                    }
372                }
373            }
374
375            // Same-module (or inherited) candidates shadow project-wide ones.
376            if candidates.len() > 1 {
377                let mut visible = self.graph.ancestors_of(caller_module);
378                visible.insert(caller_module);
379                let local: Vec<i64> = candidates
380                    .iter()
381                    .copied()
382                    .filter(|id| {
383                        self.graph
384                            .callables
385                            .get(id)
386                            .map(|c| visible.contains(&c.module_id))
387                            .unwrap_or(false)
388                    })
389                    .collect();
390                if !local.is_empty() {
391                    candidates = local;
392                }
393            }
394
395            let callee = match candidates.len() {
396                0 => {
397                    // Unqualified method-style noise in Rust (mostly stdlib
398                    // methods) is not worth an external edge.
399                    if language == Language::Rust && site.qualifier.is_some() {
400                        continue;
401                    }
402                    CalleeRef::External(site.text.clone())
403                }
404                1 => CalleeRef::Resolved(candidates[0]),
405                _ => CalleeRef::Ambiguous(candidates),
406            };
407            self.graph.call_edges.push(CallEdge {
408                caller_id,
409                callee,
410                callee_text: site.text,
411                line: site.line,
412            });
413        }
414    }
415
416    fn resolve_states(&mut self, pending: Vec<(i64, i64, crate::extract::RawStateRef, Language)>) {
417        let mut seen: BTreeSet<(i64, i64, bool)> = BTreeSet::new();
418        for (callable_id, module_id, state_ref, language) in pending {
419            // Move objects/resources may live in another module of the
420            // project; Solidity/Rust state is scoped to the module (plus
421            // inherited contracts for Solidity).
422            let project_wide = matches!(language, Language::MoveAptos | Language::MoveSui);
423            let matched: Vec<i64> = if project_wide {
424                self.graph
425                    .states
426                    .values()
427                    .filter(|s| s.name == state_ref.name)
428                    .map(|s| s.id)
429                    .collect()
430            } else {
431                self.graph
432                    .visible_states(module_id)
433                    .into_iter()
434                    .filter(|s| s.name == state_ref.name)
435                    .map(|s| s.id)
436                    .collect()
437            };
438            for state_id in matched {
439                let access = if state_ref.write {
440                    crate::model::AccessKind::Write
441                } else {
442                    crate::model::AccessKind::Read
443                };
444                if seen.insert((callable_id, state_id, state_ref.write)) {
445                    self.graph.state_edges.push(StateEdge {
446                        callable_id,
447                        state_id,
448                        access,
449                        line: state_ref.line,
450                    });
451                }
452            }
453        }
454    }
455}