Skip to main content

llmy_codegraph/
solidity.rs

1//! Solidity extraction over tree-sitter-solidity: contracts / interfaces /
2//! libraries, callables (functions, constructor, modifiers,
3//! fallback/receive), state variables, inheritance, call sites and
4//! state-variable reads/writes. Writes through storage-pointer aliases are a
5//! known precision loss of the pure-syntax approach.
6
7use std::collections::BTreeSet;
8
9use llmy_types::error::LLMYError;
10use tree_sitter::Node;
11
12use crate::extract::{
13    FileExtraction, GrammarSet, NodeUtil, RawCallSite, RawCallable, RawModule, RawState,
14    RawStateRef, SourceFile,
15};
16use crate::model::{CallableKind, Language, ModuleKind, StateKind};
17
18const CONTAINER_KINDS: [&str; 3] = [
19    "contract_declaration",
20    "interface_declaration",
21    "library_declaration",
22];
23const CALLABLE_KINDS: [&str; 4] = [
24    "function_definition",
25    "constructor_definition",
26    "modifier_definition",
27    "fallback_receive_definition",
28];
29const WRITE_KINDS: [&str; 3] = [
30    "assignment_expression",
31    "augmented_assignment_expression",
32    "update_expression",
33];
34
35pub struct SolidityExtractor;
36
37impl SolidityExtractor {
38    pub fn extract(file: &SourceFile) -> Result<FileExtraction, LLMYError> {
39        let tree = GrammarSet::parse(Language::Solidity, &file.content)?;
40        let root = tree.root_node();
41        let parse_errors = GrammarSet::count_errors(root);
42
43        let modules = root
44            .descendants_of_kinds(&CONTAINER_KINDS, false)
45            .into_iter()
46            .map(|node| Self::extract_container(node, &file.content))
47            .collect();
48
49        Ok(FileExtraction {
50            file: file.relative.clone(),
51            language: Language::Solidity,
52            modules,
53            parse_errors,
54        })
55    }
56
57    fn extract_container(node: Node<'_>, source: &str) -> RawModule {
58        let kind = match node.kind() {
59            "interface_declaration" => ModuleKind::Interface,
60            "library_declaration" => ModuleKind::Library,
61            _ => ModuleKind::Contract,
62        };
63        let name = node
64            .child_of_kind("identifier")
65            .map(|n| n.text_of(source))
66            .unwrap_or_else(|| "<anonymous>".to_string());
67        let parents = node
68            .children_of_kind("inheritance_specifier")
69            .into_iter()
70            .filter_map(|spec| spec.first_identifier(source))
71            .collect();
72
73        let mut states = vec![];
74        let mut callables = vec![];
75        if let Some(body) = node.child_of_kind("contract_body") {
76            for declaration in body.children_of_kind("state_variable_declaration") {
77                let Some(state_name) = declaration
78                    .child_of_kind("identifier")
79                    .map(|n| n.text_of(source))
80                else {
81                    continue;
82                };
83                let type_text = declaration
84                    .child_of_kind("type_name")
85                    .map(|n| n.text_of(source))
86                    .unwrap_or_default();
87                states.push(RawState {
88                    name: state_name,
89                    kind: StateKind::StateVariable,
90                    type_text,
91                    span: declaration.line_span(),
92                });
93            }
94
95            let mut cursor = body.walk();
96            for child in body.children(&mut cursor) {
97                if CALLABLE_KINDS.contains(&child.kind()) {
98                    callables.push(Self::extract_callable(child, source));
99                }
100            }
101        }
102
103        RawModule {
104            name,
105            kind,
106            span: node.line_span(),
107            parents,
108            callables,
109            states,
110        }
111    }
112
113    fn extract_callable(node: Node<'_>, source: &str) -> RawCallable {
114        let (name, kind) = match node.kind() {
115            "constructor_definition" => ("constructor".to_string(), CallableKind::Constructor),
116            "modifier_definition" => (
117                node.child_of_kind("identifier")
118                    .map(|n| n.text_of(source))
119                    .unwrap_or_else(|| "<modifier>".to_string()),
120                CallableKind::Modifier,
121            ),
122            "fallback_receive_definition" => {
123                let head = node.text_of(source);
124                if head.trim_start().starts_with("receive") {
125                    ("receive".to_string(), CallableKind::Receive)
126                } else {
127                    ("fallback".to_string(), CallableKind::Fallback)
128                }
129            }
130            _ => (
131                node.child_of_kind("identifier")
132                    .map(|n| n.text_of(source))
133                    .unwrap_or_else(|| "<function>".to_string()),
134                CallableKind::Function,
135            ),
136        };
137
138        let signature = node.signature_head(&["function_body"], source);
139        let mut calls = vec![];
140
141        // Modifier invocations on the declaration are call edges to the
142        // modifier (or a base constructor).
143        for invocation in node.children_of_kind("modifier_invocation") {
144            if let Some(modifier_name) = invocation.first_identifier(source) {
145                calls.push(RawCallSite {
146                    text: modifier_name.clone(),
147                    name: modifier_name,
148                    qualifier: None,
149                    line: invocation.line_span().start_line,
150                });
151            }
152        }
153
154        let mut state_refs = vec![];
155        if let Some(body) = node.child_of_kind("function_body") {
156            for call in body.descendants_of_kinds(&["call_expression"], true) {
157                if let Some(site) = Self::call_target(call, source) {
158                    calls.push(site);
159                }
160            }
161
162            let locals = Self::collect_locals(node, body, source);
163            state_refs = Self::collect_state_refs(body, source, &locals);
164        }
165
166        RawCallable {
167            name,
168            kind,
169            signature,
170            span: node.line_span(),
171            calls,
172            state_refs,
173        }
174    }
175
176    /// The `(qualifier, name)` of a call expression's target: the callee
177    /// side of `call_expression > expression > (identifier |
178    /// member_expression)`.
179    fn call_target(call: Node<'_>, source: &str) -> Option<RawCallSite> {
180        let target = Self::unwrap_expression(call.named_child(0)?);
181        let line = call.line_span().start_line;
182        match target.kind() {
183            "identifier" => {
184                let name = target.text_of(source);
185                Some(RawCallSite {
186                    text: name.clone(),
187                    name,
188                    qualifier: None,
189                    line,
190                })
191            }
192            "member_expression" => {
193                let mut cursor = target.walk();
194                let identifiers: Vec<Node<'_>> = target
195                    .children(&mut cursor)
196                    .filter(|c| c.is_named())
197                    .collect();
198                let property = identifiers.last()?;
199                if property.kind() != "identifier" {
200                    return None;
201                }
202                let name = property.text_of(source);
203                let qualifier = identifiers
204                    .first()
205                    .filter(|object| object.id() != property.id())
206                    .map(|object| object.text_of(source));
207                Some(RawCallSite {
208                    text: target.text_of(source),
209                    name,
210                    qualifier,
211                    line,
212                })
213            }
214            _ => None,
215        }
216    }
217
218    fn unwrap_expression(node: Node<'_>) -> Node<'_> {
219        let mut current = node;
220        while current.kind() == "expression" {
221            match current.named_child(0) {
222                Some(inner) => current = inner,
223                None => break,
224            }
225        }
226        current
227    }
228
229    /// Names that shadow state inside this callable: parameters, return
230    /// parameters and local variable declarations.
231    fn collect_locals(declaration: Node<'_>, body: Node<'_>, source: &str) -> BTreeSet<String> {
232        let mut locals = BTreeSet::new();
233        for parameter in declaration.descendants_of_kinds(&["parameter"], false) {
234            if let Some(identifier) = parameter.child_of_kind("identifier") {
235                locals.insert(identifier.text_of(source));
236            }
237        }
238        for declaration in body.descendants_of_kinds(&["variable_declaration"], false) {
239            if let Some(identifier) = declaration.child_of_kind("identifier") {
240                locals.insert(identifier.text_of(source));
241            }
242        }
243        locals
244    }
245
246    /// Identifier references in the body, classified read/write. Property
247    /// identifiers of member accesses (`msg.sender`'s `sender`) are skipped;
248    /// resolution against actual state variable names happens later.
249    fn collect_state_refs(
250        body: Node<'_>,
251        source: &str,
252        locals: &BTreeSet<String>,
253    ) -> Vec<RawStateRef> {
254        let mut refs = vec![];
255
256        // Writes: the base identifier of every assignment LHS / update
257        // operand.
258        for write in body.descendants_of_kinds(&WRITE_KINDS, true) {
259            let Some(lhs) = write.named_child(0) else {
260                continue;
261            };
262            if let Some(name) = Self::base_identifier(Self::unwrap_expression(lhs), source)
263                && !locals.contains(&name)
264            {
265                refs.push(RawStateRef {
266                    name,
267                    write: true,
268                    line: write.line_span().start_line,
269                });
270            }
271        }
272
273        // Reads: every remaining identifier that is not a member property.
274        for identifier in body.descendants_of_kinds(&["identifier"], false) {
275            let text = identifier.text_of(source);
276            if locals.contains(&text) {
277                continue;
278            }
279            if let Some(parent) = identifier.parent()
280                && parent.kind() == "member_expression"
281                && parent
282                    .named_child(0)
283                    .map(|first| first.id() != identifier.id())
284                    .unwrap_or(false)
285            {
286                continue;
287            }
288            refs.push(RawStateRef {
289                name: text,
290                write: false,
291                line: identifier.line_span().start_line,
292            });
293        }
294
295        refs
296    }
297
298    /// The storage base of an lvalue: `balances[msg.sender].total` ->
299    /// `balances`.
300    fn base_identifier(node: Node<'_>, source: &str) -> Option<String> {
301        let mut current = node;
302        loop {
303            match current.kind() {
304                "identifier" => return Some(current.text_of(source)),
305                "expression"
306                | "array_access"
307                | "member_expression"
308                | "tuple_expression"
309                | "parenthesized_expression" => {
310                    current = current.named_child(0)?;
311                }
312                _ => return None,
313            }
314        }
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use std::path::PathBuf;
322
323    fn extract(source: &str) -> FileExtraction {
324        SolidityExtractor::extract(&SourceFile {
325            relative: PathBuf::from("test.sol"),
326            content: source.to_string(),
327        })
328        .expect("extract")
329    }
330
331    const SAMPLE: &str = r#"
332pragma solidity ^0.8.0;
333contract Counter is Ownable {
334    uint256 public count;
335    mapping(address => uint256) balances;
336    modifier onlyPositive() { require(count > 0); _; }
337    constructor(uint256 start) { count = start; }
338    function increment() public onlyPositive {
339        count += 1;
340        balances[msg.sender] = count;
341        helper();
342        token.transfer(msg.sender, 1);
343    }
344    function helper() internal view returns (uint256) {
345        uint256 local = count;
346        return local;
347    }
348}
349interface IToken {
350    function transfer(address to, uint256 amount) external returns (bool);
351}
352"#;
353
354    #[test]
355    fn containers_states_and_inheritance_are_extracted() {
356        let extraction = extract(SAMPLE);
357        assert_eq!(extraction.parse_errors, 0);
358        assert_eq!(extraction.modules.len(), 2);
359
360        let counter = &extraction.modules[0];
361        assert_eq!(counter.name, "Counter");
362        assert_eq!(counter.kind, ModuleKind::Contract);
363        assert_eq!(counter.parents, vec!["Ownable".to_string()]);
364        let state_names: Vec<_> = counter.states.iter().map(|s| s.name.as_str()).collect();
365        assert_eq!(state_names, vec!["count", "balances"]);
366        assert_eq!(counter.states[1].type_text, "mapping(address => uint256)");
367
368        let token = &extraction.modules[1];
369        assert_eq!(token.kind, ModuleKind::Interface);
370        assert_eq!(token.callables.len(), 1);
371        assert!(token.callables[0].signature.contains("function transfer"));
372    }
373
374    #[test]
375    fn callables_carry_calls_and_modifier_invocations() {
376        let extraction = extract(SAMPLE);
377        let counter = &extraction.modules[0];
378        let names: Vec<_> = counter.callables.iter().map(|c| c.name.as_str()).collect();
379        assert_eq!(
380            names,
381            vec!["onlyPositive", "constructor", "increment", "helper"]
382        );
383
384        let increment = &counter.callables[2];
385        assert_eq!(increment.kind, CallableKind::Function);
386        let call_names: Vec<_> = increment.calls.iter().map(|c| c.name.as_str()).collect();
387        assert_eq!(call_names, vec!["onlyPositive", "helper", "transfer"]);
388        let transfer = &increment.calls[2];
389        assert_eq!(transfer.qualifier.as_deref(), Some("token"));
390        assert_eq!(transfer.text, "token.transfer");
391    }
392
393    #[test]
394    fn state_refs_classify_reads_and_writes_and_skip_locals() {
395        let extraction = extract(SAMPLE);
396        let counter = &extraction.modules[0];
397
398        let increment = &counter.callables[2];
399        let writes: Vec<_> = increment
400            .state_refs
401            .iter()
402            .filter(|r| r.write)
403            .map(|r| r.name.as_str())
404            .collect();
405        assert!(writes.contains(&"count"), "{writes:?}");
406        assert!(writes.contains(&"balances"), "{writes:?}");
407
408        let helper = &counter.callables[3];
409        assert!(
410            helper
411                .state_refs
412                .iter()
413                .any(|r| r.name == "count" && !r.write)
414        );
415        // `local` is shadowed by the declaration and never a state ref.
416        assert!(!helper.state_refs.iter().any(|r| r.name == "local"));
417        // `msg.sender`'s property never leaks in as a read.
418        assert!(!increment.state_refs.iter().any(|r| r.name == "sender"));
419    }
420}