1use 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#[derive(Debug, Clone)]
52pub struct BuildResult {
53 pub graph: CodeGraph,
54 pub files: Vec<(PathBuf, Language, usize)>,
56 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 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 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
198struct GraphAssembler {
200 graph: CodeGraph,
201 callables_by_name: BTreeMap<String, Vec<i64>>,
203 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 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 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 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 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 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}