Skip to main content

wm_tools/expansion/
code.rs

1//! Code structure graph — `code.graph`, `code.query`, `code.affected_by`,
2//! `fragment.search`.
3//!
4//! Port of the v26 `CodeStructureGraph` surface: scan a project, extract
5//! symbols (functions, classes, methods, imports) and edges (calls,
6//! imports, inheritance), then answer structural questions:
7//!
8//! - `code.graph` — build (or refresh) the graph for a project root
9//! - `code.query` — natural-language queries: "what calls X", "what does X
10//!   call", "path from A to B", "explain X", "god nodes", "search X"
11//! - `code.affected_by` — everything transitively affected by changing a
12//!   symbol (reverse call-graph BFS)
13//! - `fragment.search` — locate the file/line fragments mentioning a query
14//!
15//! Extraction is regex-based per language (no parser dependency), bounded
16//! by file count and file size, and the graph is shared across calls so a
17//! single `code.graph` build serves many queries.
18
19#![forbid(unsafe_code)]
20
21use async_trait::async_trait;
22
23use serde_json::{Value, json};
24use std::collections::{HashMap, VecDeque};
25use std::sync::Arc;
26use std::sync::Mutex;
27use wm_core::{Context, EffectRow, Gana, Resource, Tool, ToolStats};
28
29/// Maximum files scanned per build.
30const DEFAULT_MAX_FILES: usize = 50_000;
31/// Maximum size of a single source file read.
32const MAX_FILE_BYTES: u64 = 1_000_000;
33/// Directories always skipped.
34const SKIP_DIRS: &[&str] = &[
35    "target",
36    "node_modules",
37    ".git",
38    "build",
39    "dist",
40    "vendor",
41    ".venv",
42    "__pycache__",
43    "coverage",
44];
45
46/// A symbol in the code graph.
47#[derive(Debug, Clone)]
48pub struct CodeNode {
49    /// Unique node id: `file:name:type`.
50    pub id: String,
51    /// Symbol name.
52    pub name: String,
53    /// function / class / method / import / module / struct / enum / const.
54    pub node_type: String,
55    /// Source file (relative to project root).
56    pub file: String,
57    /// 1-based line where the symbol appears.
58    pub line: usize,
59    /// Language inferred from the file extension.
60    pub language: String,
61}
62
63/// A directed relationship between two nodes.
64#[derive(Debug, Clone)]
65pub struct CodeEdge {
66    pub source_id: String,
67    pub target_id: String,
68    /// calls / imports / inherits
69    pub edge_type: String,
70}
71
72/// The shared code structure graph.
73#[derive(Debug, Default)]
74pub struct CodeGraph {
75    nodes: Vec<CodeNode>,
76    edges: Vec<CodeEdge>,
77    project_root: String,
78    built: bool,
79}
80
81impl CodeGraph {
82    /// Create an empty graph.
83    #[must_use]
84    pub fn new() -> Self {
85        Self::default()
86    }
87
88    /// Whether a build has been performed.
89    #[must_use]
90    pub const fn is_built(&self) -> bool {
91        self.built
92    }
93
94    /// The project root the graph was built from.
95    #[must_use]
96    pub fn root(&self) -> &str {
97        &self.project_root
98    }
99
100    /// Scan `project_root` and rebuild the graph.
101    pub fn build(&mut self, project_root: &str, max_files: usize) -> Result<usize, String> {
102        let root = std::path::Path::new(project_root);
103        if !root.is_dir() {
104            return Err(format!("project_root is not a directory: {project_root}"));
105        }
106        let mut files: Vec<std::path::PathBuf> = Vec::new();
107        let mut walked = 0usize;
108        collect_files(root, root, max_files, &mut files, &mut walked)?;
109
110        let mut nodes: Vec<CodeNode> = Vec::new();
111        let mut edges: Vec<CodeEdge> = Vec::new();
112        // Pass 1: extract all symbols from every file. Per-file symbol sets
113        // are kept (with file contents) so pass 2 can build edges with
114        // cross-file name resolution.
115        struct FileInfo {
116            rel: String,
117            language: String,
118            contents: String,
119        }
120        let mut files_info: Vec<FileInfo> = Vec::new();
121
122        for file in &files {
123            let rel = file
124                .strip_prefix(root)
125                .unwrap_or(file)
126                .to_string_lossy()
127                .replace('\\', "/");
128            let language = language_for(&rel);
129            if language.is_empty() {
130                continue;
131            }
132            let contents = read_bounded(file);
133            let Some(contents) = contents else { continue };
134
135            // Extract symbols per language.
136            let (patterns, inline) = symbol_patterns(&language);
137            for (line_no, line) in contents.lines().enumerate() {
138                let line_no = line_no + 1;
139                for (pat, ty) in &patterns {
140                    for cap in regex_captures(pat, line) {
141                        let name = cap.clone();
142                        let id = format!("{rel}:{name}:{ty}");
143                        nodes.push(CodeNode {
144                            id: id.clone(),
145                            name: name.clone(),
146                            node_type: (*ty).to_string(),
147                            file: rel.clone(),
148                            line: line_no,
149                            language: language.clone(),
150                        });
151                        // Inheritance edges: class A(B) — resolved in pass 2.
152                        if *ty == "class" {
153                            for parent in inherit_targets(line) {
154                                if !parent.is_empty() {
155                                    edges.push(CodeEdge {
156                                        source_id: id.clone(),
157                                        target_id: format!("{rel}:{parent}:class"),
158                                        edge_type: "inherits".into(),
159                                    });
160                                }
161                            }
162                        }
163                    }
164                }
165                for (pat, ty) in &inline {
166                    for cap in regex_captures(pat, line) {
167                        let name = cap.clone();
168                        nodes.push(CodeNode {
169                            id: format!("{rel}:{name}:{ty}"),
170                            name,
171                            node_type: (*ty).to_string(),
172                            file: rel.clone(),
173                            line: line_no,
174                            language: language.clone(),
175                        });
176                    }
177                }
178            }
179            files_info.push(FileInfo {
180                rel: rel.clone(),
181                language,
182                contents,
183            });
184        }
185
186        // Pass 2: call edges with cross-file resolution. Every call site
187        // `name(` on a line connects its enclosing function to every node
188        // named `name` (any file), same-file candidates first.
189        let name_index: HashMap<&str, Vec<usize>> = {
190            let mut index: HashMap<&str, Vec<usize>> = HashMap::new();
191            for (i, node) in nodes.iter().enumerate() {
192                index.entry(node.name.as_str()).or_default().push(i);
193            }
194            index
195        };
196        for info in &files_info {
197            for (line_no, line) in info.contents.lines().enumerate() {
198                let Some(caller) = enclosing_function(&info.contents, line_no) else {
199                    continue;
200                };
201                let source_id = format!("{}:{caller}:function", info.rel);
202                for name in call_sites(line) {
203                    let Some(mut candidates) = name_index.get(name.as_str()).cloned() else {
204                        continue;
205                    };
206                    // same-file candidates first
207                    candidates.sort_by_key(|&i| usize::from(nodes[i].file != info.rel));
208                    for i in candidates.into_iter().take(3) {
209                        edges.push(CodeEdge {
210                            source_id: source_id.clone(),
211                            target_id: nodes[i].id.clone(),
212                            edge_type: "calls".into(),
213                        });
214                    }
215                }
216            }
217            // Import edges.
218            for line in info.contents.lines() {
219                for target in import_targets(&info.language, line) {
220                    edges.push(CodeEdge {
221                        source_id: format!("{}:*:import", info.rel),
222                        target_id: target,
223                        edge_type: "imports".into(),
224                    });
225                }
226            }
227        }
228
229        // Deduplicate (a symbol may match several patterns).
230        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
231        nodes.retain(|n| seen.insert(n.id.clone()));
232        let mut edge_seen: std::collections::HashSet<String> = std::collections::HashSet::new();
233        edges.retain(|e| {
234            edge_seen.insert(format!("{}|{}|{}", e.source_id, e.target_id, e.edge_type))
235        });
236
237        self.nodes = nodes;
238        self.edges = edges;
239        self.project_root = project_root.to_string();
240        self.built = true;
241        Ok(files.len())
242    }
243
244    /// Search nodes by name substring.
245    #[must_use]
246    pub fn search(&self, query: &str, limit: usize) -> Vec<Value> {
247        let q = query.to_ascii_lowercase();
248        self.nodes
249            .iter()
250            .filter(|n| n.name.to_ascii_lowercase().contains(&q))
251            .take(limit)
252            .map(node_json)
253            .collect()
254    }
255
256    /// Find callers of a symbol (direct in-edges).
257    #[must_use]
258    pub fn callers(&self, symbol: &str, limit: usize) -> Vec<Value> {
259        let targets: Vec<&CodeNode> = self.nodes.iter().filter(|n| n.name == symbol).collect();
260        let mut out = Vec::new();
261        for target in targets {
262            for edge in &self.edges {
263                if edge.edge_type == "calls" && edge.target_id == target.id {
264                    if let Some(src) = self.node(&edge.source_id) {
265                        out.push(node_json(src));
266                        if out.len() >= limit {
267                            return out;
268                        }
269                    }
270                }
271            }
272        }
273        out
274    }
275
276    /// Find callees of a symbol (direct out-edges).
277    #[must_use]
278    pub fn callees(&self, symbol: &str, limit: usize) -> Vec<Value> {
279        let sources: Vec<&CodeNode> = self.nodes.iter().filter(|n| n.name == symbol).collect();
280        let mut out = Vec::new();
281        for source in sources {
282            for edge in &self.edges {
283                if edge.edge_type == "calls" && edge.source_id == source.id {
284                    if let Some(tgt) = self.node(&edge.target_id) {
285                        out.push(node_json(tgt));
286                        if out.len() >= limit {
287                            return out;
288                        }
289                    }
290                }
291            }
292        }
293        out
294    }
295
296    /// Explain a symbol: type, file, degree, incoming/outgoing.
297    #[must_use]
298    pub fn explain(&self, symbol: &str) -> Option<Value> {
299        let node = self.nodes.iter().find(|n| n.name == symbol).or_else(|| {
300            self.nodes
301                .iter()
302                .find(|n| n.name == symbol && n.node_type == "class")
303        })?;
304        let in_deg = self.edges.iter().filter(|e| e.target_id == node.id).count();
305        let out_deg = self.edges.iter().filter(|e| e.source_id == node.id).count();
306        let incoming: Vec<Value> = self
307            .edges
308            .iter()
309            .filter(|e| e.target_id == node.id && e.edge_type == "calls")
310            .take(20)
311            .filter_map(|e| self.node(&e.source_id))
312            .map(node_json)
313            .collect();
314        let outgoing: Vec<Value> = self
315            .edges
316            .iter()
317            .filter(|e| e.source_id == node.id && e.edge_type == "calls")
318            .take(20)
319            .filter_map(|e| self.node(&e.target_id))
320            .map(node_json)
321            .collect();
322        Some(json!({
323            "symbol": node.name,
324            "node_type": node.node_type,
325            "file": node.file,
326            "line": node.line,
327            "language": node.language,
328            "degree": in_deg + out_deg,
329            "in_degree": in_deg,
330            "out_degree": out_deg,
331            "incoming": incoming,
332            "outgoing": outgoing,
333        }))
334    }
335
336    /// Shortest call path from A to B (BFS), max_hops bound.
337    #[must_use]
338    pub fn path(&self, symbol_a: &str, symbol_b: &str, max_hops: usize) -> Value {
339        let node_a = self.nodes.iter().find(|n| n.name == symbol_a);
340        let node_b = self.nodes.iter().find(|n| n.name == symbol_b);
341        let (Some(node_a), Some(node_b)) = (node_a, node_b) else {
342            let missing = if node_a.is_none() { symbol_a } else { symbol_b };
343            return json!({
344                "status": "error",
345                "error": format!("symbol not found: {missing}"),
346            });
347        };
348        if node_a.id == node_b.id {
349            return json!({
350                "status": "success",
351                "path": [symbol_a],
352                "hops": 0,
353            });
354        }
355        let mut adj: HashMap<String, Vec<String>> = HashMap::new();
356        for e in &self.edges {
357            if e.edge_type == "calls" {
358                adj.entry(e.source_id.clone())
359                    .or_default()
360                    .push(e.target_id.clone());
361            }
362        }
363        let mut visited = std::collections::HashSet::new();
364        let mut queue: VecDeque<(String, Vec<String>)> = VecDeque::new();
365        visited.insert(node_a.id.clone());
366        queue.push_back((node_a.id.clone(), vec![node_a.id.clone()]));
367        while let Some((current, path)) = queue.pop_front() {
368            if current == node_b.id {
369                let names: Vec<String> = path
370                    .iter()
371                    .map(|id| self.node(id).map_or_else(|| id.clone(), |n| n.name.clone()))
372                    .collect();
373                return json!({
374                    "status": "success",
375                    "path": names,
376                    "hops": path.len() - 1,
377                });
378            }
379            if path.len() > max_hops {
380                continue;
381            }
382            if let Some(neighbors) = adj.get(&current) {
383                for neighbor in neighbors {
384                    if !visited.contains(neighbor) {
385                        visited.insert(neighbor.clone());
386                        let mut next = path.clone();
387                        next.push(neighbor.clone());
388                        queue.push_back((neighbor.clone(), next));
389                    }
390                }
391            }
392        }
393        json!({
394            "status": "no_path",
395            "message": format!("no call path between {symbol_a} and {symbol_b} in {max_hops} hops"),
396        })
397    }
398
399    /// All symbols transitively affected by a change to `symbol` —
400    /// reverse BFS over the call graph (who calls it, who calls them…).
401    #[must_use]
402    pub fn affected_by(&self, symbol: &str, max_depth: usize) -> Value {
403        let roots: Vec<&CodeNode> = self.nodes.iter().filter(|n| n.name == symbol).collect();
404        if roots.is_empty() {
405            return json!({
406                "status": "error",
407                "error": format!("symbol not found: {symbol}"),
408            });
409        }
410        // reverse adjacency over calls edges
411        let mut rev: HashMap<String, Vec<String>> = HashMap::new();
412        for e in &self.edges {
413            if e.edge_type == "calls" {
414                rev.entry(e.target_id.clone())
415                    .or_default()
416                    .push(e.source_id.clone());
417            }
418        }
419        let mut affected: Vec<Value> = Vec::new();
420        let mut visited = std::collections::HashSet::new();
421        let mut queue: VecDeque<(String, usize)> = VecDeque::new();
422        for root in roots {
423            queue.push_back((root.id.clone(), 0));
424            visited.insert(root.id.clone());
425        }
426        while let Some((current, depth)) = queue.pop_front() {
427            if depth > 0 {
428                if let Some(node) = self.node(&current) {
429                    affected.push(json!({
430                        "symbol": node.name,
431                        "node_type": node.node_type,
432                        "file": node.file,
433                        "line": node.line,
434                        "depth": depth,
435                    }));
436                }
437            }
438            if depth >= max_depth {
439                continue;
440            }
441            if let Some(callers) = rev.get(&current) {
442                for caller in callers {
443                    if !visited.contains(caller) {
444                        visited.insert(caller.clone());
445                        queue.push_back((caller.clone(), depth + 1));
446                    }
447                }
448            }
449        }
450        json!({
451            "status": "success",
452            "symbol": symbol,
453            "affected_count": affected.len(),
454            "max_depth": max_depth,
455            "affected": affected,
456        })
457    }
458
459    /// Most-connected symbols (call-graph degree).
460    #[must_use]
461    pub fn god_nodes(&self, limit: usize) -> Vec<Value> {
462        let mut degrees: HashMap<String, usize> = HashMap::new();
463        for e in &self.edges {
464            if e.edge_type == "calls" {
465                *degrees.entry(e.source_id.clone()).or_default() += 1;
466                *degrees.entry(e.target_id.clone()).or_default() += 1;
467            }
468        }
469        let mut ranked: Vec<(&CodeNode, usize)> = degrees
470            .iter()
471            .filter_map(|(id, d)| self.node(id).map(|n| (n, *d)))
472            .collect();
473        ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.name.cmp(&b.0.name)));
474        ranked
475            .into_iter()
476            .take(limit)
477            .map(|(n, d)| {
478                let mut v = node_json(n);
479                v["degree"] = json!(d);
480                v
481            })
482            .collect()
483    }
484
485    /// Locate file/line fragments mentioning `query` (bounded grep over
486    /// the scanned source files).
487    #[must_use]
488    pub fn fragment_search(&self, query: &str, max_results: usize) -> Vec<Value> {
489        let mut out = Vec::new();
490        let root = std::path::Path::new(&self.project_root);
491        let mut files: Vec<std::path::PathBuf> = Vec::new();
492        let mut walked = 0usize;
493        if collect_files(root, root, 20_000, &mut files, &mut walked).is_err() {
494            return out;
495        }
496        for file in files {
497            let rel = file
498                .strip_prefix(root)
499                .unwrap_or(&file)
500                .to_string_lossy()
501                .replace('\\', "/");
502            if language_for(&rel).is_empty() {
503                continue;
504            }
505            let Some(contents) = read_bounded(&file) else {
506                continue;
507            };
508            for (line_no, line) in contents.lines().enumerate() {
509                if line
510                    .to_ascii_lowercase()
511                    .contains(&query.to_ascii_lowercase())
512                {
513                    out.push(json!({
514                        "file": rel,
515                        "line": line_no + 1,
516                        "content": line.trim().to_string(),
517                    }));
518                    if out.len() >= max_results {
519                        return out;
520                    }
521                }
522            }
523        }
524        out
525    }
526
527    /// Graph statistics.
528    #[must_use]
529    pub fn stats(&self) -> Value {
530        let mut languages: HashMap<String, (usize, usize)> = HashMap::new();
531        for node in &self.nodes {
532            let e = languages.entry(node.language.clone()).or_default();
533            e.0 += 1;
534        }
535        for edge in &self.edges {
536            if edge.edge_type == "calls" {
537                if let Some(n) = self.node(&edge.source_id) {
538                    let e = languages.entry(n.language.clone()).or_default();
539                    e.1 += 1;
540                }
541            }
542        }
543        let languages: Vec<Value> = languages
544            .into_iter()
545            .map(|(lang, (nodes, calls))| json!({"language": lang, "nodes": nodes, "call_edges": calls}))
546            .collect();
547        json!({
548            "built": self.built,
549            "project_root": self.project_root,
550            "nodes": self.nodes.len(),
551            "edges": self.edges.len(),
552            "languages": languages,
553        })
554    }
555
556    fn node(&self, id: &str) -> Option<&CodeNode> {
557        self.nodes.iter().find(|n| n.id == id)
558    }
559}
560
561fn node_json(node: &CodeNode) -> Value {
562    json!({
563        "name": node.name,
564        "node_type": node.node_type,
565        "file": node.file,
566        "line": node.line,
567        "language": node.language,
568    })
569}
570
571/// Recursively collect source files, bounded.
572#[allow(clippy::only_used_in_recursion)]
573fn collect_files(
574    root: &std::path::Path,
575    dir: &std::path::Path,
576    max_files: usize,
577    out: &mut Vec<std::path::PathBuf>,
578    walked: &mut usize,
579) -> Result<(), String> {
580    if out.len() >= max_files {
581        return Ok(());
582    }
583    let entries =
584        std::fs::read_dir(dir).map_err(|e| format!("cannot read dir {}: {e}", dir.display()))?;
585    for entry in entries.flatten() {
586        let path = entry.path();
587        if path.is_dir() {
588            let name = entry.file_name().to_string_lossy().to_string();
589            if SKIP_DIRS.contains(&name.as_str()) {
590                continue;
591            }
592            collect_files(root, &path, max_files, out, walked)?;
593            if out.len() >= max_files {
594                return Ok(());
595            }
596        } else if path.is_file() {
597            *walked += 1;
598            if language_for(&path.to_string_lossy()).is_empty() {
599                continue;
600            }
601            if entry.metadata().map_or(true, |m| m.len() > MAX_FILE_BYTES) {
602                continue;
603            }
604            out.push(path);
605        }
606    }
607    Ok(())
608}
609
610fn read_bounded(path: &std::path::Path) -> Option<String> {
611    use std::io::Read;
612    let file = std::fs::File::open(path).ok()?;
613    let mut buf = Vec::new();
614    std::io::Read::take(file, MAX_FILE_BYTES)
615        .read_to_end(&mut buf)
616        .ok()?;
617    Some(String::from_utf8_lossy(&buf).into_owned())
618}
619
620fn language_for(path: &str) -> String {
621    let ext = path.rsplit('.').next().unwrap_or("").to_ascii_lowercase();
622    match ext.as_str() {
623        "rs" => "rust".to_string(),
624        "py" => "python".to_string(),
625        "js" | "jsx" | "mjs" | "cjs" => "javascript".to_string(),
626        "ts" | "tsx" => "typescript".to_string(),
627        "go" => "go".to_string(),
628        "java" => "java".to_string(),
629        "c" | "h" => "c".to_string(),
630        "cpp" | "cc" | "hpp" => "cpp".to_string(),
631        "rb" => "ruby".to_string(),
632        "sh" => "shell".to_string(),
633        "zig" => "zig".to_string(),
634        "jl" => "julia".to_string(),
635        _ => String::new(),
636    }
637}
638
639/// (pattern, node_type) pairs for top-level symbols; (pattern, node_type)
640/// pairs for inline symbols.
641type PatternList = Vec<(&'static str, &'static str)>;
642fn symbol_patterns(language: &str) -> (PatternList, PatternList) {
643    match language {
644        "rust" => (
645            vec![
646                (r"fn\s+([a-zA-Z_][a-zA-Z0-9_]*)", "function"),
647                (r"struct\s+([a-zA-Z_][a-zA-Z0-9_]*)", "struct"),
648                (r"enum\s+([a-zA-Z_][a-zA-Z0-9_]*)", "enum"),
649                (r"impl\s+([a-zA-Z_][a-zA-Z0-9_]*)", "impl"),
650                (r"trait\s+([a-zA-Z_][a-zA-Z0-9_]*)", "trait"),
651                (r"mod\s+([a-zA-Z_][a-zA-Z0-9_]*)", "module"),
652            ],
653            vec![(r"pub\s+fn\s+([a-zA-Z_][a-zA-Z0-9_]*)", "function")],
654        ),
655        "python" => (
656            vec![
657                (r"def\s+([a-zA-Z_][a-zA-Z0-9_]*)", "function"),
658                (r"class\s+([a-zA-Z_][a-zA-Z0-9_]*)", "class"),
659            ],
660            vec![],
661        ),
662        "javascript" | "typescript" => (
663            vec![
664                (r"function\s+([a-zA-Z_$][a-zA-Z0-9_$]*)", "function"),
665                (r"class\s+([a-zA-Z_$][a-zA-Z0-9_$]*)", "class"),
666            ],
667            vec![(
668                r"(?:const|let|var)\s+([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=",
669                "const",
670            )],
671        ),
672        "go" => (
673            vec![
674                (r"func\s+([a-zA-Z_][a-zA-Z0-9_]*)", "function"),
675                (r"type\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+struct", "struct"),
676            ],
677            vec![],
678        ),
679        "java" => (
680            vec![
681                (
682                    r"(?:public|private|protected)\s+(?:static\s+)?[a-zA-Z0-9_<>\[\]]+\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\(",
683                    "method",
684                ),
685                (r"class\s+([a-zA-Z_][a-zA-Z0-9_]*)", "class"),
686            ],
687            vec![],
688        ),
689        _ => (
690            vec![(
691                r"(?:fn|def|function)\s+([a-zA-Z_][a-zA-Z0-9_]*)",
692                "function",
693            )],
694            vec![],
695        ),
696    }
697}
698
699/// Regex capture helper without the regex crate: find `(name)` after the
700/// first occurrence of the pattern's literal prefix. Supports the simple
701/// `\s+` and capture-group patterns used above.
702fn regex_captures(pattern: &str, line: &str) -> Vec<String> {
703    let mut captures = Vec::new();
704    let line_lower = line;
705    // crude matcher for our pattern subset:
706    // pattern = "literal\s+([class])" where literal contains no regex
707    let open = pattern.find('(').unwrap_or(pattern.len());
708    let literal = &pattern[..open];
709    let trimmed = literal.trim_end_matches("\\s+");
710    let mut rest = line_lower;
711    while let Some(idx) = rest.find(trimmed) {
712        let after = &rest[idx + trimmed.len()..];
713        // skip \s+ if present
714        let after = after.trim_start_matches([' ', '\t']);
715        // capture group is a class like [a-zA-Z_][a-zA-Z0-9_]* — match a name
716        if let Some(name) = leading_identifier(after) {
717            captures.push(name);
718        }
719        rest = after;
720    }
721    captures
722}
723
724/// Match a leading identifier `[A-Za-z_][A-Za-z0-9_$]*`.
725fn leading_identifier(s: &str) -> Option<String> {
726    let mut name = String::new();
727    for c in s.chars() {
728        if name.is_empty() {
729            if c.is_ascii_alphabetic() || c == '_' || c == '$' {
730                name.push(c);
731            } else {
732                return None;
733            }
734        } else if c.is_ascii_alphanumeric() || c == '_' || c == '$' {
735            name.push(c);
736        } else {
737            break;
738        }
739    }
740    if name.is_empty() { None } else { Some(name) }
741}
742
743/// Class inheritance targets: `class A(B, C)` → `["B", "C"]`.
744fn inherit_targets(line: &str) -> Vec<String> {
745    let Some(open) = line.find('(') else {
746        return Vec::new();
747    };
748    let Some(close) = line[open..].find(')') else {
749        return Vec::new();
750    };
751    line[open + 1..open + close]
752        .split(',')
753        .map(|s| s.trim().to_string())
754        .filter(|s| !s.is_empty())
755        .collect()
756}
757
758/// Identifiers immediately followed by '(' on a line — potential call
759/// sites. Skips function definitions (`fn name(`, `def name(`,
760/// `function name(`) and qualified/attribute accesses are included
761/// (module::name(, obj.name() are real call sites).
762fn call_sites(line: &str) -> Vec<String> {
763    let mut out = Vec::new();
764    let mut rest = line;
765    while !rest.is_empty() {
766        // skip leading non-identifier characters
767        let skipped = rest
768            .chars()
769            .take_while(|c| !(c.is_ascii_alphabetic() || *c == '_' || *c == '$'))
770            .count();
771        rest = &rest[skipped..];
772        let Some(name) = leading_identifier(rest) else {
773            break;
774        };
775        let consumed = line.len() - rest.len();
776        let before = line[..consumed].chars().last();
777        // valid boundary: not part of a larger identifier
778        let boundary_ok = !before.is_some_and(|c| c.is_ascii_alphanumeric() || c == '_');
779        if boundary_ok {
780            let after = &rest[name.len()..];
781            if after.starts_with('(') {
782                // skip definition lines: "fn name(" / "def name(" / "function name("
783                let def_marker = line[..consumed].rsplit([' ', '\t']).next().unwrap_or("");
784                if !(def_marker == "fn"
785                    || def_marker == "def"
786                    || def_marker == "function"
787                    || out.contains(&name))
788                {
789                    out.push(name.clone());
790                }
791            }
792        }
793        // advance past the identifier
794        let skip = name.len().max(1);
795        rest = &rest[skip..];
796    }
797    out
798}
799
800/// The function that encloses a given line index — the **last**
801/// `fn/def/function` declaration at or before it (a definition line
802/// encloses itself).
803fn enclosing_function(contents: &str, line_idx: usize) -> Option<String> {
804    let mut found: Option<String> = None;
805    for (i, line) in contents.lines().enumerate() {
806        if i > line_idx {
807            break;
808        }
809        for pat in ["fn ", "def ", "function "] {
810            if let Some(idx) = line.find(pat) {
811                if let Some(name) = leading_identifier(&line[idx + pat.len()..]) {
812                    found = Some(name);
813                }
814            }
815        }
816    }
817    found
818}
819
820/// Import edge targets from a line, by language.
821fn import_targets(language: &str, line: &str) -> Vec<String> {
822    let mut out = Vec::new();
823    match language {
824        "rust" => {
825            if let Some(rest) = line.trim().strip_prefix("use ") {
826                let target = rest.trim_end_matches(';').trim();
827                if !target.starts_with("crate::") {
828                    out.push(format!("import:{target}"));
829                }
830            }
831        }
832        "python" => {
833            if let Some(rest) = line.trim().strip_prefix("import ") {
834                for part in rest.split(',') {
835                    let module = part.trim().split('.').next().unwrap_or("").to_string();
836                    if !module.is_empty() {
837                        out.push(format!("import:{module}"));
838                    }
839                }
840            } else if let Some(rest) = line.trim().strip_prefix("from ") {
841                let module = rest.split(" import ").next().unwrap_or("").trim();
842                if !module.is_empty() {
843                    out.push(format!("import:{module}"));
844                }
845            }
846        }
847        "javascript" | "typescript" => {
848            if let Some(rest) = line.trim().strip_prefix("import ") {
849                if let Some(from) = rest.split(" from ").nth(1) {
850                    let module = from.trim().trim_matches(['\'', '"']).to_string();
851                    out.push(format!("import:{module}"));
852                }
853            }
854        }
855        "go" => {
856            if let Some(rest) = line.trim().strip_prefix("import \"") {
857                let module = rest.trim_end_matches('"').to_string();
858                out.push(format!("import:{module}"));
859            }
860        }
861        _ => {}
862    }
863    out
864}
865
866// ── code.graph ───────────────────────────────────────────────────────
867
868/// `code.graph` — build (or refresh) the code structure graph.
869pub struct CodeGraphTool {
870    graph: Arc<Mutex<CodeGraph>>,
871    stats: ToolStats,
872    effects: EffectRow,
873}
874
875impl CodeGraphTool {
876    #[must_use]
877    pub fn new(graph: Arc<Mutex<CodeGraph>>) -> Self {
878        Self {
879            graph,
880            stats: ToolStats::default(),
881            effects: EffectRow::read_only(vec![Resource::Filesystem]),
882        }
883    }
884}
885
886#[async_trait]
887impl Tool for CodeGraphTool {
888    fn input_schema(&self) -> Value {
889        super::common::schema(
890            &json!({
891                "project_root": super::common::str_prop("Project root directory to scan (required)"),
892                "max_files": super::common::int_prop("Maximum files to scan (optional; default 50000)"),
893            }),
894            &["project_root"],
895        )
896    }
897    fn name(&self) -> &str {
898        "code.graph"
899    }
900    fn gana(&self) -> Gana {
901        Gana::Chariot
902    }
903    fn effects(&self) -> &EffectRow {
904        &self.effects
905    }
906    fn description(&self) -> &str {
907        "Build (or refresh) the code structure graph for a project. Args: project_root (required), max_files (default 50000)."
908    }
909    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
910        let project_root = args
911            .get("project_root")
912            .and_then(Value::as_str)
913            .ok_or_else(|| wm_core::CoreError::InvalidArgs("project_root is required".into()))?;
914        let max_files = args
915            .get("max_files")
916            .and_then(Value::as_u64)
917            .unwrap_or(DEFAULT_MAX_FILES as u64) as usize;
918        let project_root = project_root.to_string();
919        let mut graph = self
920            .graph
921            .lock()
922            .map_err(|e| wm_core::CoreError::Tool(format!("code graph lock: {e}")))?;
923        let files = graph
924            .build(&project_root, max_files)
925            .map_err(wm_core::CoreError::Tool)?;
926        let stats = graph.stats();
927        let mut result = json!({
928            "status": "success",
929            "files_scanned": files,
930        });
931        for (k, v) in stats.as_object().unwrap() {
932            result[k.clone()] = v.clone();
933        }
934        Ok(result)
935    }
936    fn stats(&self) -> &ToolStats {
937        &self.stats
938    }
939}
940
941// ── code.query ───────────────────────────────────────────────────────
942
943/// `code.query` — natural-language queries against the code graph.
944pub struct CodeQueryTool {
945    graph: Arc<Mutex<CodeGraph>>,
946    stats: ToolStats,
947    effects: EffectRow,
948}
949
950impl CodeQueryTool {
951    #[must_use]
952    pub fn new(graph: Arc<Mutex<CodeGraph>>) -> Self {
953        Self {
954            graph,
955            stats: ToolStats::default(),
956            effects: EffectRow::read_only(vec![Resource::Filesystem]),
957        }
958    }
959}
960
961#[async_trait]
962impl Tool for CodeQueryTool {
963    fn input_schema(&self) -> Value {
964        super::common::schema(
965            &json!({
966                "query": super::common::str_prop("Natural-language query — 'what calls X', 'what does X call', 'path from A to B', 'explain X', 'god nodes', 'stats', or a symbol search (required)"),
967                "limit": super::common::int_prop("Maximum results per section (optional; default 20)"),
968            }),
969            &["query"],
970        )
971    }
972    fn name(&self) -> &str {
973        "code.query"
974    }
975    fn gana(&self) -> Gana {
976        Gana::Chariot
977    }
978    fn effects(&self) -> &EffectRow {
979        &self.effects
980    }
981    fn description(&self) -> &str {
982        "Query the code graph with natural language: 'what calls X', 'what does X call', 'path from A to B', 'explain X', 'god nodes', or a symbol search. Args: query (required), limit (default 20). Build the graph first with code.graph."
983    }
984    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
985        let query = args
986            .get("query")
987            .and_then(Value::as_str)
988            .ok_or_else(|| wm_core::CoreError::InvalidArgs("query is required".into()))?;
989        let limit = args.get("limit").and_then(Value::as_u64).unwrap_or(20) as usize;
990        let graph = self
991            .graph
992            .lock()
993            .map_err(|e| wm_core::CoreError::Tool(format!("code graph lock: {e}")))?;
994        if !graph.is_built() {
995            return Ok(json!({
996                "status": "error",
997                "error": "code graph not built — run code.graph first",
998            }));
999        }
1000        let q = query.to_ascii_lowercase();
1001        let result = if let Some(rest) = q.strip_prefix("what calls ") {
1002            json!({"status": "success", "query": query, "callers": graph.callers(rest.trim(), limit)})
1003        } else if q.contains("what does") && q.contains("call") {
1004            let symbol = q
1005                .split("what does")
1006                .nth(1)
1007                .and_then(|s| s.split("call").next())
1008                .unwrap_or("")
1009                .trim();
1010            json!({"status": "success", "query": query, "callees": graph.callees(symbol, limit)})
1011        } else if q.contains("path from") && q.contains(" to ") {
1012            let parts: Vec<&str> = q.split(" to ").collect();
1013            let a = parts[0].strip_prefix("path from").unwrap_or("").trim();
1014            let b = parts[1].trim();
1015            graph.path(a, b, 5)
1016        } else if let Some(rest) = q.strip_prefix("explain ") {
1017            match graph.explain(rest.trim()) {
1018                Some(expl) => json!({"status": "success", "query": query, "explanation": expl}),
1019                None => {
1020                    json!({"status": "error", "error": format!("symbol not found: {}", rest.trim())})
1021                }
1022            }
1023        } else if q.contains("god") || q.contains("most connected") {
1024            json!({"status": "success", "query": query, "god_nodes": graph.god_nodes(limit)})
1025        } else if q == "stats" || q.contains("stats") {
1026            graph.stats()
1027        } else {
1028            json!({"status": "success", "query": query, "matches": graph.search(query, limit)})
1029        };
1030        Ok(result)
1031    }
1032    fn stats(&self) -> &ToolStats {
1033        &self.stats
1034    }
1035}
1036
1037// ── code.affected_by ─────────────────────────────────────────────────
1038
1039/// `code.affected_by` — find symbols affected by a change.
1040pub struct CodeAffectedByTool {
1041    graph: Arc<Mutex<CodeGraph>>,
1042    stats: ToolStats,
1043    effects: EffectRow,
1044}
1045
1046impl CodeAffectedByTool {
1047    #[must_use]
1048    pub fn new(graph: Arc<Mutex<CodeGraph>>) -> Self {
1049        Self {
1050            graph,
1051            stats: ToolStats::default(),
1052            effects: EffectRow::read_only(vec![Resource::Filesystem]),
1053        }
1054    }
1055}
1056
1057#[async_trait]
1058impl Tool for CodeAffectedByTool {
1059    fn input_schema(&self) -> Value {
1060        super::common::schema(
1061            &json!({
1062                "symbol": super::common::str_prop("Symbol whose change to trace (required)"),
1063                "max_depth": super::common::int_prop("Reverse call-graph BFS depth, clamped 1-10 (optional; default 3)"),
1064            }),
1065            &["symbol"],
1066        )
1067    }
1068    fn name(&self) -> &str {
1069        "code.affected_by"
1070    }
1071    fn gana(&self) -> Gana {
1072        Gana::Chariot
1073    }
1074    fn effects(&self) -> &EffectRow {
1075        &self.effects
1076    }
1077    fn description(&self) -> &str {
1078        "Find all symbols transitively affected by a change to the given symbol (reverse call-graph BFS). Args: symbol (required), max_depth (default 3)."
1079    }
1080    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
1081        let symbol = args
1082            .get("symbol")
1083            .and_then(Value::as_str)
1084            .ok_or_else(|| wm_core::CoreError::InvalidArgs("symbol is required".into()))?;
1085        let max_depth = args
1086            .get("max_depth")
1087            .and_then(Value::as_u64)
1088            .unwrap_or(3)
1089            .clamp(1, 10) as usize;
1090        let graph = self
1091            .graph
1092            .lock()
1093            .map_err(|e| wm_core::CoreError::Tool(format!("code graph lock: {e}")))?;
1094        if !graph.is_built() {
1095            return Ok(json!({
1096                "status": "error",
1097                "error": "code graph not built — run code.graph first",
1098            }));
1099        }
1100        Ok(graph.affected_by(symbol, max_depth))
1101    }
1102    fn stats(&self) -> &ToolStats {
1103        &self.stats
1104    }
1105}
1106
1107// ── fragment.search ──────────────────────────────────────────────────
1108
1109/// `fragment.search` — locate code fragments mentioning a query.
1110pub struct FragmentSearchTool {
1111    graph: Arc<Mutex<CodeGraph>>,
1112    stats: ToolStats,
1113    effects: EffectRow,
1114}
1115
1116impl FragmentSearchTool {
1117    #[must_use]
1118    pub fn new(graph: Arc<Mutex<CodeGraph>>) -> Self {
1119        Self {
1120            graph,
1121            stats: ToolStats::default(),
1122            effects: EffectRow::read_only(vec![Resource::Filesystem]),
1123        }
1124    }
1125}
1126
1127#[async_trait]
1128impl Tool for FragmentSearchTool {
1129    fn name(&self) -> &str {
1130        "fragment.search"
1131    }
1132    fn gana(&self) -> Gana {
1133        Gana::WinnowingBasket
1134    }
1135    fn effects(&self) -> &EffectRow {
1136        &self.effects
1137    }
1138    fn description(&self) -> &str {
1139        "Locate file/line fragments mentioning a query in the built code graph's project. Args: query (required), max_results (default 20)."
1140    }
1141    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
1142        let query = args
1143            .get("query")
1144            .and_then(Value::as_str)
1145            .ok_or_else(|| wm_core::CoreError::InvalidArgs("query is required".into()))?;
1146        let max_results = args
1147            .get("max_results")
1148            .and_then(Value::as_u64)
1149            .unwrap_or(20) as usize;
1150        let graph = self
1151            .graph
1152            .lock()
1153            .map_err(|e| wm_core::CoreError::Tool(format!("code graph lock: {e}")))?;
1154        if !graph.is_built() {
1155            return Ok(json!({
1156                "status": "error",
1157                "error": "code graph not built — run code.graph first",
1158            }));
1159        }
1160        let symbols = graph.search(query, max_results);
1161        let fragments = graph.fragment_search(query, max_results);
1162        Ok(json!({
1163            "status": "success",
1164            "query": query,
1165            "symbol_matches": symbols.len(),
1166            "fragment_matches": fragments.len(),
1167            "symbols": symbols,
1168            "fragments": fragments,
1169        }))
1170    }
1171    fn stats(&self) -> &ToolStats {
1172        &self.stats
1173    }
1174}
1175
1176/// Register the code tools (4) against a shared graph.
1177#[must_use]
1178pub fn register_code(
1179    registry: &wm_dispatch::ToolRegistry,
1180    graph: Arc<Mutex<CodeGraph>>,
1181) -> wm_dispatch::ToolRegistry {
1182    registry
1183        .register(Arc::new(CodeGraphTool::new(graph.clone())))
1184        .register(Arc::new(CodeQueryTool::new(graph.clone())))
1185        .register(Arc::new(CodeAffectedByTool::new(graph.clone())))
1186        .register(Arc::new(FragmentSearchTool::new(graph)))
1187}
1188
1189#[cfg(test)]
1190mod tests {
1191    use super::*;
1192
1193    fn write_project(dir: &std::path::Path) {
1194        std::fs::create_dir_all(dir.join("src")).unwrap();
1195        std::fs::write(
1196            dir.join("src/main.rs"),
1197            "mod utils;\nfn main() {\n    let x = utils::double(21);\n    println!(\"{x}\");\n}\n",
1198        )
1199        .unwrap();
1200        std::fs::write(
1201            dir.join("src/utils.rs"),
1202            "pub fn double(x: i32) -> i32 {\n    x * 2\n}\n",
1203        )
1204        .unwrap();
1205    }
1206
1207    #[test]
1208    fn build_extracts_symbols_and_calls() {
1209        let dir = tempfile::tempdir().unwrap();
1210        write_project(dir.path());
1211        let mut graph = CodeGraph::new();
1212        let files = graph.build(dir.path().to_str().unwrap(), 100).unwrap();
1213        assert!(files >= 2);
1214        assert!(
1215            graph
1216                .nodes
1217                .iter()
1218                .any(|n| n.name == "main" && n.node_type == "function")
1219        );
1220        assert!(
1221            graph
1222                .nodes
1223                .iter()
1224                .any(|n| n.name == "double" && n.node_type == "function")
1225        );
1226        // main calls double
1227        assert!(!graph.callers("double", 10).is_empty());
1228        assert!(!graph.callees("main", 10).is_empty());
1229    }
1230
1231    #[test]
1232    fn affected_by_traces_reverse_bfs() {
1233        let dir = tempfile::tempdir().unwrap();
1234        std::fs::create_dir_all(dir.path().join("src")).unwrap();
1235        std::fs::write(
1236            dir.path().join("src/a.rs"),
1237            "fn top() { mid(); }\nfn mid() { leaf(); }\nfn leaf() {}\n",
1238        )
1239        .unwrap();
1240        let mut graph = CodeGraph::new();
1241        graph.build(dir.path().to_str().unwrap(), 100).unwrap();
1242        let result = graph.affected_by("leaf", 3);
1243        assert_eq!(result["status"], "success");
1244        let affected = result["affected"].as_array().unwrap();
1245        let names: Vec<&str> = affected
1246            .iter()
1247            .filter_map(|a| a.get("symbol").and_then(Value::as_str))
1248            .collect();
1249        assert!(names.contains(&"mid"));
1250        assert!(names.contains(&"top"));
1251    }
1252
1253    #[test]
1254    fn path_finds_connection() {
1255        let dir = tempfile::tempdir().unwrap();
1256        std::fs::create_dir_all(dir.path().join("src")).unwrap();
1257        std::fs::write(dir.path().join("src/a.rs"), "fn a() { b(); }\nfn b() {}\n").unwrap();
1258        let mut graph = CodeGraph::new();
1259        graph.build(dir.path().to_str().unwrap(), 100).unwrap();
1260        let result = graph.path("a", "b", 3);
1261        assert_eq!(result["status"], "success");
1262        assert_eq!(result["hops"], 1);
1263    }
1264
1265    #[test]
1266    fn fragment_search_finds_lines() {
1267        let dir = tempfile::tempdir().unwrap();
1268        std::fs::create_dir_all(dir.path().join("src")).unwrap();
1269        std::fs::write(
1270            dir.path().join("src/a.rs"),
1271            "fn a() {}\n// the unique marker word\nfn b() {}\n",
1272        )
1273        .unwrap();
1274        let mut graph = CodeGraph::new();
1275        graph.build(dir.path().to_str().unwrap(), 100).unwrap();
1276        let fragments = graph.fragment_search("unique marker", 10);
1277        assert_eq!(fragments.len(), 1);
1278        assert_eq!(fragments[0]["line"], 2);
1279    }
1280
1281    #[test]
1282    fn skip_dirs_are_not_scanned() {
1283        let dir = tempfile::tempdir().unwrap();
1284        std::fs::create_dir_all(dir.path().join("target")).unwrap();
1285        std::fs::write(dir.path().join("target/bad.rs"), "fn bad() {}\n").unwrap();
1286        std::fs::write(dir.path().join("good.rs"), "fn good() {}\n").unwrap();
1287        let mut graph = CodeGraph::new();
1288        graph.build(dir.path().to_str().unwrap(), 100).unwrap();
1289        assert!(!graph.nodes.iter().any(|n| n.name == "bad"));
1290        assert!(graph.nodes.iter().any(|n| n.name == "good"));
1291    }
1292
1293    #[tokio::test]
1294    async fn tools_require_built_graph() {
1295        let graph = Arc::new(Mutex::new(CodeGraph::new()));
1296        let tool = CodeQueryTool::new(graph);
1297        let mut ctx = Context::default();
1298        let result = tool
1299            .call(&mut ctx, json!({"query": "what calls main"}))
1300            .await
1301            .unwrap();
1302        assert_eq!(result["status"], "error");
1303        assert_eq!(
1304            result["error"],
1305            "code graph not built — run code.graph first"
1306        );
1307    }
1308}