Skip to main content

llmy_codegraph/
move_lang.rs

1//! Move extraction, one extractor per dialect: Aptos Move (vendored
2//! aptos-labs grammar; global storage accessed via `move_to` /
3//! `borrow_global*`) and Sui Move (vendored tzakian grammar; objects with
4//! `key` flowing through function parameters). The dialects differ enough —
5//! syntax and storage model both — that sharing one extractor would hide
6//! more than it saves.
7
8use llmy_types::error::LLMYError;
9use tree_sitter::Node;
10
11use crate::extract::{
12    FileExtraction, GrammarSet, NodeUtil, RawCallSite, RawCallable, RawModule, RawState,
13    RawStateRef, SourceFile,
14};
15use crate::model::{CallableKind, Language, ModuleKind, StateKind};
16
17pub struct MoveAptosExtractor;
18
19impl MoveAptosExtractor {
20    pub fn extract(file: &SourceFile) -> Result<FileExtraction, LLMYError> {
21        let tree = GrammarSet::parse(Language::MoveAptos, &file.content)?;
22        let root = tree.root_node();
23        let parse_errors = GrammarSet::count_errors(root);
24        let source = &file.content;
25
26        let modules = root
27            .descendants_of_kinds(&["module_declaration"], false)
28            .into_iter()
29            .map(|node| Self::extract_module(node, source))
30            .collect();
31
32        Ok(FileExtraction {
33            file: file.relative.clone(),
34            language: Language::MoveAptos,
35            modules,
36            parse_errors,
37        })
38    }
39
40    fn extract_module(node: Node<'_>, source: &str) -> RawModule {
41        let name = node
42            .child_of_kind("module_identity")
43            .and_then(|identity| {
44                identity
45                    .children_of_kind("identifier")
46                    .last()
47                    .map(|n| n.text_of(source))
48            })
49            .unwrap_or_else(|| "<module>".to_string());
50
51        let mut states = vec![];
52        let mut callables = vec![];
53        if let Some(body) = node.child_of_kind("module_body") {
54            for declaration in body.children_of_kind("struct_declaration") {
55                let Some(struct_name) = declaration
56                    .child_of_kind("identifier")
57                    .map(|n| n.text_of(source))
58                else {
59                    continue;
60                };
61                let abilities: Vec<String> = declaration
62                    .descendants_of_kinds(&["ability"], false)
63                    .into_iter()
64                    .map(|a| a.text_of(source))
65                    .collect();
66                if !abilities.iter().any(|a| a == "key") {
67                    continue;
68                }
69                states.push(RawState {
70                    name: struct_name,
71                    kind: StateKind::MoveResource,
72                    type_text: format!("struct has {}", abilities.join(", ")),
73                    span: declaration.line_span(),
74                });
75            }
76
77            for function in body.children_of_kind("function_declaration") {
78                callables.push(Self::extract_function(function, source));
79            }
80        }
81
82        RawModule {
83            name,
84            kind: ModuleKind::Module,
85            span: node.line_span(),
86            parents: vec![],
87            callables,
88            states,
89        }
90    }
91
92    fn extract_function(function: Node<'_>, source: &str) -> RawCallable {
93        let name = function
94            .child_of_kind("identifier")
95            .map(|n| n.text_of(source))
96            .unwrap_or_else(|| "<function>".to_string());
97        let kind = if function.child_of_kind("entry_modifier").is_some() {
98            CallableKind::Entry
99        } else {
100            CallableKind::Function
101        };
102        let signature = function.signature_head(&["block"], source);
103
104        let mut calls = vec![];
105        let mut state_refs = vec![];
106        if let Some(body) = function.child_of_kind("block") {
107            for call in body.descendants_of_kinds(&["call_expression"], true) {
108                let Some(chain) = call.child_of_kind("name_access_chain") else {
109                    continue;
110                };
111                let parts: Vec<String> = chain
112                    .children_of_kind("identifier")
113                    .into_iter()
114                    .map(|n| n.text_of(source))
115                    .collect();
116                let Some(call_name) = parts.last().cloned() else {
117                    continue;
118                };
119                let line = call.line_span().start_line;
120
121                // Global storage intrinsics become state references on the
122                // resource named in the type argument.
123                let storage_write = match call_name.as_str() {
124                    "move_to" | "move_from" | "borrow_global_mut" => Some(true),
125                    "borrow_global" | "exists" => Some(false),
126                    _ => None,
127                };
128                if let Some(write) = storage_write {
129                    if let Some(resource) = call
130                        .child_of_kind("type_arguments")
131                        .and_then(|args| args.first_identifier(source))
132                    {
133                        state_refs.push(RawStateRef {
134                            name: resource,
135                            write,
136                            line,
137                        });
138                    } else if call_name == "move_to" {
139                        // `move_to(account, Counter { .. })` — the resource is
140                        // the packed struct in the second argument.
141                        if let Some(packed) = call.child_of_kind("arg_list").and_then(|args| {
142                            args.descendants_of_kinds(&["pack_expression"], false)
143                                .first()
144                                .and_then(|p| p.first_identifier(source))
145                        }) {
146                            state_refs.push(RawStateRef {
147                                name: packed,
148                                write: true,
149                                line,
150                            });
151                        }
152                    }
153                    continue;
154                }
155
156                let qualifier = (parts.len() > 1).then(|| parts[0].clone());
157                calls.push(RawCallSite {
158                    text: chain.text_of(source),
159                    name: call_name,
160                    qualifier,
161                    line,
162                });
163            }
164        }
165
166        RawCallable {
167            name,
168            kind,
169            signature,
170            span: function.line_span(),
171            calls,
172            state_refs,
173        }
174    }
175}
176
177pub struct MoveSuiExtractor;
178
179impl MoveSuiExtractor {
180    pub fn extract(file: &SourceFile) -> Result<FileExtraction, LLMYError> {
181        let tree = GrammarSet::parse(Language::MoveSui, &file.content)?;
182        let root = tree.root_node();
183        let parse_errors = GrammarSet::count_errors(root);
184        let source = &file.content;
185
186        let modules = root
187            .descendants_of_kinds(&["module_definition"], false)
188            .into_iter()
189            .map(|node| Self::extract_module(node, source))
190            .collect();
191
192        Ok(FileExtraction {
193            file: file.relative.clone(),
194            language: Language::MoveSui,
195            modules,
196            parse_errors,
197        })
198    }
199
200    fn extract_module(node: Node<'_>, source: &str) -> RawModule {
201        let name = node
202            .child_of_kind("module_identity")
203            .and_then(|identity| {
204                identity
205                    .children_of_kind("module_identifier")
206                    .last()
207                    .map(|n| n.text_of(source))
208            })
209            .unwrap_or_else(|| "<module>".to_string());
210
211        let mut states = vec![];
212        let mut callables = vec![];
213        if let Some(body) = node.child_of_kind("module_body") {
214            for declaration in body.children_of_kind("struct_definition") {
215                let Some(struct_name) = declaration
216                    .child_of_kind("struct_identifier")
217                    .map(|n| n.text_of(source))
218                else {
219                    continue;
220                };
221                let abilities: Vec<String> = declaration
222                    .descendants_of_kinds(&["ability"], false)
223                    .into_iter()
224                    .map(|a| a.text_of(source))
225                    .collect();
226                if !abilities.iter().any(|a| a == "key") {
227                    continue;
228                }
229                states.push(RawState {
230                    name: struct_name,
231                    kind: StateKind::SuiObject,
232                    type_text: format!("struct has {}", abilities.join(", ")),
233                    span: declaration.line_span(),
234                });
235            }
236
237            for function in body.descendants_of_kinds(
238                &[
239                    "function_definition",
240                    "native_function_definition",
241                    "macro_function_definition",
242                ],
243                false,
244            ) {
245                callables.push(Self::extract_function(function, source));
246            }
247        }
248
249        RawModule {
250            name,
251            kind: ModuleKind::Module,
252            span: node.line_span(),
253            parents: vec![],
254            callables,
255            states,
256        }
257    }
258
259    fn extract_function(function: Node<'_>, source: &str) -> RawCallable {
260        let name = function
261            .child_of_kind("function_identifier")
262            .map(|n| n.text_of(source))
263            .unwrap_or_else(|| "<function>".to_string());
264        let is_entry = function
265            .children_of_kind("modifier")
266            .into_iter()
267            .any(|m| m.text_of(source) == "entry");
268        let kind = if is_entry {
269            CallableKind::Entry
270        } else {
271            CallableKind::Function
272        };
273        let signature = function.signature_head(&["block"], source);
274
275        // Objects flow through parameters: `&mut T` (or by value) writes,
276        // `&T` reads. Non-object type names simply resolve to nothing later.
277        let mut state_refs = vec![];
278        if let Some(parameters) = function.child_of_kind("function_parameters") {
279            for parameter in parameters.children_of_kind("function_parameter") {
280                let mut cursor = parameter.walk();
281                let Some(type_node) = parameter
282                    .children(&mut cursor)
283                    .filter(|c| c.is_named())
284                    .nth(1)
285                else {
286                    continue;
287                };
288                let line = parameter.line_span().start_line;
289                match type_node.kind() {
290                    "ref_type" => {
291                        let mutable = type_node.child_of_kind("mut_ref").is_some();
292                        if let Some(type_name) = type_node
293                            .child_of_kind("apply_type")
294                            .and_then(|t| Self::last_identifier(t, source))
295                        {
296                            state_refs.push(RawStateRef {
297                                name: type_name,
298                                write: mutable,
299                                line,
300                            });
301                        }
302                    }
303                    "apply_type" => {
304                        if let Some(type_name) = Self::last_identifier(type_node, source) {
305                            state_refs.push(RawStateRef {
306                                name: type_name,
307                                write: true,
308                                line,
309                            });
310                        }
311                    }
312                    _ => {}
313                }
314            }
315        }
316
317        let mut calls = vec![];
318        if let Some(body) = function.child_of_kind("block") {
319            for call in body.descendants_of_kinds(&["call_expression"], true) {
320                let Some(access) = call
321                    .child_of_kind("name_expression")
322                    .and_then(|n| n.child_of_kind("module_access"))
323                else {
324                    continue;
325                };
326                let mut cursor = access.walk();
327                let parts: Vec<(String, String)> = access
328                    .children(&mut cursor)
329                    .filter(|c| c.is_named())
330                    .map(|c| (c.kind().to_string(), c.text_of(source)))
331                    .collect();
332                let Some((_, call_name)) = parts
333                    .iter()
334                    .rev()
335                    .find(|(kind, _)| kind == "identifier")
336                    .cloned()
337                else {
338                    continue;
339                };
340                let qualifier = parts
341                    .iter()
342                    .find(|(kind, _)| kind == "module_identifier")
343                    .map(|(_, text)| text.clone());
344                calls.push(RawCallSite {
345                    text: access.text_of(source),
346                    name: call_name,
347                    qualifier,
348                    line: call.line_span().start_line,
349                });
350            }
351        }
352
353        RawCallable {
354            name,
355            kind,
356            signature,
357            span: function.line_span(),
358            calls,
359            state_refs,
360        }
361    }
362
363    fn last_identifier(node: Node<'_>, source: &str) -> Option<String> {
364        node.descendants_of_kinds(&["identifier"], false)
365            .last()
366            .map(|n| n.text_of(source))
367    }
368}
369
370#[cfg(test)]
371mod tests {
372    use super::*;
373    use std::path::PathBuf;
374
375    const APTOS: &str = r#"
376module counter_addr::counter {
377    use std::signer;
378
379    struct Counter has key {
380        value: u64,
381    }
382
383    public entry fun initialize(account: &signer) {
384        move_to(account, Counter { value: 0 });
385    }
386
387    public entry fun increment(account: &signer) acquires Counter {
388        let counter = borrow_global_mut<Counter>(signer::address_of(account));
389        counter.value = counter.value + 1;
390        helper(counter.value);
391    }
392
393    fun helper(v: u64): u64 {
394        let snapshot = borrow_global<Counter>(@counter_addr);
395        v + snapshot.value
396    }
397}
398"#;
399
400    const SUI: &str = r#"
401module counter::counter {
402    use sui::transfer;
403
404    public struct Counter has key {
405        id: UID,
406        value: u64,
407    }
408
409    public entry fun create(ctx: &mut TxContext) {
410        let counter = Counter { id: object::new(ctx), value: 0 };
411        transfer::share_object(counter);
412    }
413
414    public entry fun increment(counter: &mut Counter) {
415        counter.value = counter.value + 1;
416        helper(counter);
417    }
418
419    fun helper(counter: &Counter): u64 {
420        counter.value
421    }
422}
423"#;
424
425    #[test]
426    fn aptos_resources_calls_and_storage_ops_are_extracted() {
427        let extraction = MoveAptosExtractor::extract(&SourceFile {
428            relative: PathBuf::from("sources/counter.move"),
429            content: APTOS.to_string(),
430        })
431        .expect("extract");
432        assert_eq!(extraction.parse_errors, 0);
433
434        let module = &extraction.modules[0];
435        assert_eq!(module.name, "counter");
436        assert_eq!(module.states.len(), 1);
437        assert_eq!(module.states[0].name, "Counter");
438        assert_eq!(module.states[0].kind, StateKind::MoveResource);
439
440        let names: Vec<_> = module.callables.iter().map(|c| c.name.as_str()).collect();
441        assert_eq!(names, vec!["initialize", "increment", "helper"]);
442        assert_eq!(module.callables[0].kind, CallableKind::Entry);
443        assert_eq!(module.callables[2].kind, CallableKind::Function);
444
445        // move_to without type args still resolves via the packed struct.
446        let initialize = &module.callables[0];
447        assert!(
448            initialize
449                .state_refs
450                .iter()
451                .any(|r| r.name == "Counter" && r.write)
452        );
453
454        let increment = &module.callables[1];
455        assert!(
456            increment
457                .state_refs
458                .iter()
459                .any(|r| r.name == "Counter" && r.write)
460        );
461        assert!(increment.calls.iter().any(|c| c.name == "helper"));
462        assert!(
463            increment
464                .calls
465                .iter()
466                .any(|c| c.name == "address_of" && c.qualifier.as_deref() == Some("signer"))
467        );
468        // Storage intrinsics never appear as call edges.
469        assert!(
470            !increment
471                .calls
472                .iter()
473                .any(|c| c.name == "borrow_global_mut")
474        );
475
476        let helper = &module.callables[2];
477        assert!(
478            helper
479                .state_refs
480                .iter()
481                .any(|r| r.name == "Counter" && !r.write)
482        );
483    }
484
485    #[test]
486    fn sui_objects_flow_through_parameters() {
487        let extraction = MoveSuiExtractor::extract(&SourceFile {
488            relative: PathBuf::from("sources/counter.move"),
489            content: SUI.to_string(),
490        })
491        .expect("extract");
492        assert_eq!(extraction.parse_errors, 0);
493
494        let module = &extraction.modules[0];
495        assert_eq!(module.name, "counter");
496        assert_eq!(module.states.len(), 1);
497        assert_eq!(module.states[0].kind, StateKind::SuiObject);
498
499        let names: Vec<_> = module.callables.iter().map(|c| c.name.as_str()).collect();
500        assert_eq!(names, vec!["create", "increment", "helper"]);
501        assert_eq!(module.callables[1].kind, CallableKind::Entry);
502
503        let increment = &module.callables[1];
504        assert!(
505            increment
506                .state_refs
507                .iter()
508                .any(|r| r.name == "Counter" && r.write)
509        );
510        assert!(increment.calls.iter().any(|c| c.name == "helper"));
511
512        let helper = &module.callables[2];
513        assert!(
514            helper
515                .state_refs
516                .iter()
517                .any(|r| r.name == "Counter" && !r.write)
518        );
519
520        let create = &module.callables[0];
521        assert!(
522            create
523                .calls
524                .iter()
525                .any(|c| c.name == "share_object" && c.qualifier.as_deref() == Some("transfer"))
526        );
527    }
528}