From 4246ca59b300aebf87d0c6ec3b3217509805f356 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Tue, 7 Oct 2025 10:20:17 -0400 Subject: [PATCH] Developer `analyze` tool improvement (#5030) --- Cargo.lock | 11 + crates/goose-mcp/Cargo.toml | 1 + .../goose-mcp/src/developer/analyze/graph.rs | 42 +++ .../src/developer/analyze/languages/go.rs | 83 ++++- .../src/developer/analyze/languages/mod.rs | 163 ++++++++-- .../src/developer/analyze/languages/ruby.rs | 151 +++++++++ .../src/developer/analyze/languages/rust.rs | 22 ++ .../src/developer/analyze/languages/swift.rs | 16 + crates/goose-mcp/src/developer/analyze/mod.rs | 63 ++-- .../goose-mcp/src/developer/analyze/parser.rs | 303 +++++++++++------- .../src/developer/analyze/tests/go_test.rs | 115 +++++++ .../analyze/tests/integration_tests.rs | 8 + .../analyze/tests/large_output_tests.rs | 3 + .../src/developer/analyze/tests/mod.rs | 2 + .../developer/analyze/tests/parser_tests.rs | 81 ++++- .../src/developer/analyze/tests/ruby_test.rs | 259 +++++++++++++++ .../goose-mcp/src/developer/analyze/types.rs | 32 +- 17 files changed, 1160 insertions(+), 195 deletions(-) create mode 100644 crates/goose-mcp/src/developer/analyze/languages/ruby.rs create mode 100644 crates/goose-mcp/src/developer/analyze/tests/go_test.rs create mode 100644 crates/goose-mcp/src/developer/analyze/tests/ruby_test.rs diff --git a/Cargo.lock b/Cargo.lock index 3e417bac..40747f44 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2786,6 +2786,7 @@ dependencies = [ "tree-sitter-javascript", "tree-sitter-kotlin", "tree-sitter-python", + "tree-sitter-ruby", "tree-sitter-rust", "umya-spreadsheet", "url", @@ -7254,6 +7255,16 @@ dependencies = [ "tree-sitter", ] +[[package]] +name = "tree-sitter-ruby" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0031f687c0772f2dad7b77104c43428611099a1804c81244ada21560f41f0b1" +dependencies = [ + "cc", + "tree-sitter", +] + [[package]] name = "tree-sitter-rust" version = "0.21.2" diff --git a/crates/goose-mcp/Cargo.toml b/crates/goose-mcp/Cargo.toml index 4db0e66b..2c42e53c 100644 --- a/crates/goose-mcp/Cargo.toml +++ b/crates/goose-mcp/Cargo.toml @@ -68,6 +68,7 @@ tree-sitter-go = "0.21" tree-sitter-java = "0.21" tree-sitter-kotlin = "0.3.8" devgen-tree-sitter-swift = "0.21.0" +tree-sitter-ruby = "0.21.0" streaming-iterator = "0.1" rayon = "1.10" libc = "0.2" diff --git a/crates/goose-mcp/src/developer/analyze/graph.rs b/crates/goose-mcp/src/developer/analyze/graph.rs index e2163d02..d87c72fd 100644 --- a/crates/goose-mcp/src/developer/analyze/graph.rs +++ b/crates/goose-mcp/src/developer/analyze/graph.rs @@ -3,6 +3,10 @@ use std::path::PathBuf; use crate::developer::analyze::types::{AnalysisResult, CallChain}; +/// Sentinel value used to represent type references (instantiation, field types, etc.) +/// as callers in the call graph, since they don't have an actual caller function. +const REFERENCE_CALLER: &str = ""; + #[derive(Debug, Clone, Default)] pub struct CallGraph { callers: HashMap>, @@ -60,6 +64,44 @@ impl CallGraph { )); } } + + for reference in &result.references { + use crate::developer::analyze::types::ReferenceType; + + match &reference.ref_type { + ReferenceType::MethodDefinition => { + if let Some(type_name) = &reference.associated_type { + tracing::trace!( + "Linking method {} to type {}", + reference.symbol, + type_name + ); + graph.callees.entry(type_name.clone()).or_default().push(( + file_path.clone(), + reference.line, + reference.symbol.clone(), + )); + } + } + ReferenceType::TypeInstantiation + | ReferenceType::FieldType + | ReferenceType::VariableType + | ReferenceType::ParameterType => { + graph + .callers + .entry(reference.symbol.clone()) + .or_default() + .push(( + file_path.clone(), + reference.line, + REFERENCE_CALLER.to_string(), + )); + } + ReferenceType::Definition | ReferenceType::Call | ReferenceType::Import => { + // These are handled elsewhere or not relevant for type tracking + } + } + } } tracing::trace!( diff --git a/crates/goose-mcp/src/developer/analyze/languages/go.rs b/crates/goose-mcp/src/developer/analyze/languages/go.rs index 4cef6ff4..71c7116e 100644 --- a/crates/goose-mcp/src/developer/analyze/languages/go.rs +++ b/crates/goose-mcp/src/developer/analyze/languages/go.rs @@ -3,17 +3,96 @@ pub const ELEMENT_QUERY: &str = r#" (function_declaration name: (identifier) @func) (method_declaration name: (field_identifier) @func) (type_declaration (type_spec name: (type_identifier) @struct)) + (const_declaration (const_spec name: (identifier) @const)) (import_declaration) @import "#; -/// Tree-sitter query for extracting Go function calls +/// Tree-sitter query for extracting Go function calls and identifier references pub const CALL_QUERY: &str = r#" ; Function calls (call_expression function: (identifier) @function.call) - + ; Method calls (call_expression function: (selector_expression field: (field_identifier) @method.call)) + + ; Identifier references in various expression contexts + ; This captures constants/variables used in arguments, comparisons, returns, assignments, etc. + (argument_list (identifier) @identifier.reference) + (binary_expression left: (identifier) @identifier.reference) + (binary_expression right: (identifier) @identifier.reference) + (unary_expression operand: (identifier) @identifier.reference) + (return_statement (expression_list (identifier) @identifier.reference)) + (assignment_statement right: (expression_list (identifier) @identifier.reference)) "#; + +/// Tree-sitter query for extracting Go struct references and usage patterns +pub const REFERENCE_QUERY: &str = r#" + ; Method receivers - pointer type + (method_declaration + receiver: (parameter_list + (parameter_declaration + type: (pointer_type (type_identifier) @method.receiver)))) + + ; Method receivers - value type + (method_declaration + receiver: (parameter_list + (parameter_declaration + type: (type_identifier) @method.receiver))) + + ; Struct literals - simple + (composite_literal + type: (type_identifier) @struct.literal) + + ; Struct literals - qualified (package.Type) + (composite_literal + type: (qualified_type + name: (type_identifier) @struct.literal)) + + ; Field declarations in structs - simple type + (field_declaration + type: (type_identifier) @field.type) + + ; Field declarations - pointer type + (field_declaration + type: (pointer_type + (type_identifier) @field.type)) + + ; Field declarations - qualified type (package.Type) + (field_declaration + type: (qualified_type + name: (type_identifier) @field.type)) + + ; Field declarations - pointer to qualified type + (field_declaration + type: (pointer_type + (qualified_type + name: (type_identifier) @field.type))) +"#; + +/// Find the method name for a method receiver node in Go +/// +/// This walks up the tree to find the method_declaration parent and extracts +/// the method name, used for associating methods with their receiver types. +pub fn find_method_for_receiver( + receiver_node: &tree_sitter::Node, + source: &str, + _ast_recursion_limit: Option, +) -> Option { + let mut current = *receiver_node; + while let Some(parent) = current.parent() { + if parent.kind() == "method_declaration" { + for i in 0..parent.child_count() { + if let Some(child) = parent.child(i) { + if child.kind() == "field_identifier" { + return Some(source[child.byte_range()].to_string()); + } + } + } + } + current = parent; + } + None +} diff --git a/crates/goose-mcp/src/developer/analyze/languages/mod.rs b/crates/goose-mcp/src/developer/analyze/languages/mod.rs index c5303ea9..db9372ee 100644 --- a/crates/goose-mcp/src/developer/analyze/languages/mod.rs +++ b/crates/goose-mcp/src/developer/analyze/languages/mod.rs @@ -1,35 +1,154 @@ +//! Language-specific analysis implementations +//! +//! This module contains language-specific parsing logic and tree-sitter queries +//! for the analyze tool. Each language has its own submodule with query definitions +//! and optional helper functions. +//! +//! ## Adding a New Language +//! +//! To add support for a new language: +//! +//! 1. Create a new file `languages/yourlang.rs` +//! 2. Define `ELEMENT_QUERY` and `CALL_QUERY` constants +//! 3. Optionally define `REFERENCE_QUERY` for advanced type tracking +//! 4. Add `pub mod yourlang;` below +//! 5. Add language configuration to registry in `get_language_info()` +//! +//! ## Optional Features +//! +//! Languages can opt into additional features by implementing: +//! +//! - Reference tracking: Define `REFERENCE_QUERY` to track type instantiation, +//! field types, and method-to-type associations (see Go and Ruby) +//! - Custom function naming: Implement `extract_function_name_for_kind()` for +//! special cases like Swift's init/deinit or Rust's impl blocks +//! - Method receiver lookup: Implement `find_method_for_receiver()` to associate +//! methods with their containing types (see Go and Ruby) + pub mod go; pub mod java; pub mod javascript; pub mod kotlin; pub mod python; +pub mod ruby; pub mod rust; pub mod swift; -/// Get the tree-sitter query for extracting code elements for a language -pub fn get_element_query(language: &str) -> &'static str { - match language { - "python" => python::ELEMENT_QUERY, - "rust" => rust::ELEMENT_QUERY, - "javascript" | "typescript" => javascript::ELEMENT_QUERY, - "go" => go::ELEMENT_QUERY, - "java" => java::ELEMENT_QUERY, - "kotlin" => kotlin::ELEMENT_QUERY, - "swift" => swift::ELEMENT_QUERY, - _ => "", - } +/// Handler for extracting function names from special node kinds +type ExtractFunctionNameHandler = fn(&tree_sitter::Node, &str, &str) -> Option; + +/// Handler for finding method names from receiver nodes +/// Takes: (receiver_node, source, ast_recursion_limit) +type FindMethodForReceiverHandler = fn(&tree_sitter::Node, &str, Option) -> Option; + +/// Language configuration containing all language-specific information +/// +/// This struct serves as a single source of truth for language support. +/// All language-specific queries and handlers are defined here. +#[derive(Copy, Clone)] +pub struct LanguageInfo { + /// Tree-sitter query for extracting code elements (functions, classes, imports) + pub element_query: &'static str, + /// Tree-sitter query for extracting function calls + pub call_query: &'static str, + /// Tree-sitter query for extracting type references (optional) + pub reference_query: &'static str, + /// Node kinds that represent function-like constructs + pub function_node_kinds: &'static [&'static str], + /// Node kinds that represent function name identifiers + pub function_name_kinds: &'static [&'static str], + /// Optional handler for language-specific function name extraction + pub extract_function_name_handler: Option, + /// Optional handler for finding method names from receiver nodes + pub find_method_for_receiver_handler: Option, } -/// Get the tree-sitter query for extracting function calls for a language -pub fn get_call_query(language: &str) -> &'static str { +/// Get language configuration for a given language +/// +/// Returns `Some(LanguageInfo)` if the language is supported, `None` otherwise. +pub fn get_language_info(language: &str) -> Option { match language { - "python" => python::CALL_QUERY, - "rust" => rust::CALL_QUERY, - "javascript" | "typescript" => javascript::CALL_QUERY, - "go" => go::CALL_QUERY, - "java" => java::CALL_QUERY, - "kotlin" => kotlin::CALL_QUERY, - "swift" => swift::CALL_QUERY, - _ => "", + "python" => Some(LanguageInfo { + element_query: python::ELEMENT_QUERY, + call_query: python::CALL_QUERY, + reference_query: "", + function_node_kinds: &["function_definition"], + function_name_kinds: &["identifier", "field_identifier", "property_identifier"], + extract_function_name_handler: None, + find_method_for_receiver_handler: None, + }), + "rust" => Some(LanguageInfo { + element_query: rust::ELEMENT_QUERY, + call_query: rust::CALL_QUERY, + reference_query: "", + function_node_kinds: &["function_item", "impl_item"], + function_name_kinds: &["identifier", "field_identifier", "property_identifier"], + extract_function_name_handler: Some(rust::extract_function_name_for_kind), + find_method_for_receiver_handler: None, + }), + "javascript" | "typescript" => Some(LanguageInfo { + element_query: javascript::ELEMENT_QUERY, + call_query: javascript::CALL_QUERY, + reference_query: "", + function_node_kinds: &[ + "function_declaration", + "method_definition", + "arrow_function", + ], + function_name_kinds: &["identifier", "field_identifier", "property_identifier"], + extract_function_name_handler: None, + find_method_for_receiver_handler: None, + }), + "go" => Some(LanguageInfo { + element_query: go::ELEMENT_QUERY, + call_query: go::CALL_QUERY, + reference_query: go::REFERENCE_QUERY, + function_node_kinds: &["function_declaration", "method_declaration"], + function_name_kinds: &["identifier", "field_identifier", "property_identifier"], + extract_function_name_handler: None, + find_method_for_receiver_handler: Some(go::find_method_for_receiver), + }), + "java" => Some(LanguageInfo { + element_query: java::ELEMENT_QUERY, + call_query: java::CALL_QUERY, + reference_query: "", + function_node_kinds: &["method_declaration", "constructor_declaration"], + function_name_kinds: &["identifier", "field_identifier", "property_identifier"], + extract_function_name_handler: None, + find_method_for_receiver_handler: None, + }), + "kotlin" => Some(LanguageInfo { + element_query: kotlin::ELEMENT_QUERY, + call_query: kotlin::CALL_QUERY, + reference_query: "", + function_node_kinds: &["function_declaration", "class_body"], + function_name_kinds: &["identifier", "field_identifier", "property_identifier"], + extract_function_name_handler: None, + find_method_for_receiver_handler: None, + }), + "swift" => Some(LanguageInfo { + element_query: swift::ELEMENT_QUERY, + call_query: swift::CALL_QUERY, + reference_query: "", + function_node_kinds: &[ + "function_declaration", + "init_declaration", + "deinit_declaration", + "subscript_declaration", + ], + function_name_kinds: &["simple_identifier"], + extract_function_name_handler: Some(swift::extract_function_name_for_kind), + find_method_for_receiver_handler: None, + }), + "ruby" => Some(LanguageInfo { + element_query: ruby::ELEMENT_QUERY, + call_query: ruby::CALL_QUERY, + reference_query: ruby::REFERENCE_QUERY, + function_node_kinds: &["method", "singleton_method"], + function_name_kinds: &["identifier", "field_identifier", "property_identifier"], + extract_function_name_handler: None, + find_method_for_receiver_handler: Some(ruby::find_method_for_receiver), + }), + _ => None, } } diff --git a/crates/goose-mcp/src/developer/analyze/languages/ruby.rs b/crates/goose-mcp/src/developer/analyze/languages/ruby.rs new file mode 100644 index 00000000..6214e631 --- /dev/null +++ b/crates/goose-mcp/src/developer/analyze/languages/ruby.rs @@ -0,0 +1,151 @@ +/// Tree-sitter query for extracting Ruby code elements. +/// +/// This query captures: +/// - Method definitions (def) +/// - Class and module definitions +/// - Constants +/// - Common attr_* declarations (attr_accessor, attr_reader, attr_writer) +/// - Import statements (require, require_relative, load) +pub const ELEMENT_QUERY: &str = r#" + ; Method definitions + (method name: (identifier) @func) + + ; Class and module definitions + (class name: (constant) @class) + (module name: (constant) @class) + + ; Constant assignments + (assignment left: (constant) @const) + + ; Attr declarations as functions + (call method: (identifier) @func (#eq? @func "attr_accessor")) + (call method: (identifier) @func (#eq? @func "attr_reader")) + (call method: (identifier) @func (#eq? @func "attr_writer")) + + ; Require statements + (call method: (identifier) @import (#eq? @import "require")) + (call method: (identifier) @import (#eq? @import "require_relative")) + (call method: (identifier) @import (#eq? @import "load")) +"#; + +/// Tree-sitter query for extracting Ruby function calls. +/// +/// This query captures: +/// - Direct method calls +/// - Method calls with receivers (object.method) +/// - Calls to constants (typically constructors like ClassName.new) +/// - Identifier and constant references in various expression contexts +pub const CALL_QUERY: &str = r#" + ; Method calls + (call method: (identifier) @method.call) + + ; Method calls with receiver + (call receiver: (_) method: (identifier) @method.call) + + ; Calls to constants (typically constructors) + (call receiver: (constant) @function.call) + + ; Identifier and constant references in argument lists + (argument_list (identifier) @identifier.reference) + (argument_list (constant) @identifier.reference) + + ; Binary expressions + (binary left: (identifier) @identifier.reference) + (binary right: (identifier) @identifier.reference) + (binary left: (constant) @identifier.reference) + (binary right: (constant) @identifier.reference) + + ; Assignment expressions + (assignment right: (identifier) @identifier.reference) + (assignment right: (constant) @identifier.reference) +"#; + +/// Tree-sitter query for extracting Ruby type references and usage patterns. +/// +/// This query captures: +/// - Method-to-class associations (instance and class methods) +/// - Class instantiation (ClassName.new) +/// - Type references in various contexts +pub const REFERENCE_QUERY: &str = r#" + ; Instance methods within a class - capture class name, will find method via receiver lookup + (class + name: (constant) @method.receiver + (body_statement (method))) + + ; Class instantiation (ClassName.new) + (call + receiver: (constant) @struct.literal + method: (identifier) @method.name (#eq? @method.name "new")) + + ; Constant references as receivers (type usage) + (call + receiver: (constant) @field.type + method: (identifier)) +"#; + +/// Find the method name for a method receiver node in Ruby +/// +/// For Ruby, the receiver_node is the class constant. This finds methods +/// within that class node, used for associating methods with their classes. +pub fn find_method_for_receiver( + receiver_node: &tree_sitter::Node, + source: &str, + ast_recursion_limit: Option, +) -> Option { + let max_depth = ast_recursion_limit.unwrap_or(10); + + // For Ruby, receiver_node is the class constant + if receiver_node.kind() == "constant" { + let mut current = *receiver_node; + while let Some(parent) = current.parent() { + if parent.kind() == "class" { + return find_first_method_in_class(&parent, source, max_depth); + } + current = parent; + } + } + None +} + +/// Find the first method name within a Ruby class node +fn find_first_method_in_class( + class_node: &tree_sitter::Node, + source: &str, + max_depth: usize, +) -> Option { + for i in 0..class_node.child_count() { + if let Some(child) = class_node.child(i) { + if child.kind() == "body_statement" { + return find_method_in_body_with_depth(&child, source, 0, max_depth); + } + } + } + None +} + +/// Recursively find a method within a body_statement node with depth limit +fn find_method_in_body_with_depth( + node: &tree_sitter::Node, + source: &str, + depth: usize, + max_depth: usize, +) -> Option { + if depth >= max_depth { + return None; + } + + for i in 0..node.child_count() { + if let Some(child) = node.child(i) { + if child.kind() == "method" { + for j in 0..child.child_count() { + if let Some(name_node) = child.child(j) { + if name_node.kind() == "identifier" { + return Some(source[name_node.byte_range()].to_string()); + } + } + } + } + } + } + None +} diff --git a/crates/goose-mcp/src/developer/analyze/languages/rust.rs b/crates/goose-mcp/src/developer/analyze/languages/rust.rs index 1a406744..31c46cfd 100644 --- a/crates/goose-mcp/src/developer/analyze/languages/rust.rs +++ b/crates/goose-mcp/src/developer/analyze/languages/rust.rs @@ -26,3 +26,25 @@ pub const CALL_QUERY: &str = r#" (macro_invocation macro: (identifier) @macro.call) "#; + +/// Extract function name for Rust-specific node kinds +/// +/// Rust has special cases like impl_item blocks that should be +/// formatted as "impl TypeName" instead of extracting a simple name. +pub fn extract_function_name_for_kind( + node: &tree_sitter::Node, + source: &str, + kind: &str, +) -> Option { + if kind == "impl_item" { + // For impl blocks, find the type being implemented + for i in 0..node.child_count() { + if let Some(child) = node.child(i) { + if child.kind() == "type_identifier" { + return Some(format!("impl {}", &source[child.byte_range()])); + } + } + } + } + None +} diff --git a/crates/goose-mcp/src/developer/analyze/languages/swift.rs b/crates/goose-mcp/src/developer/analyze/languages/swift.rs index bbf24239..dd8ca813 100644 --- a/crates/goose-mcp/src/developer/analyze/languages/swift.rs +++ b/crates/goose-mcp/src/developer/analyze/languages/swift.rs @@ -54,3 +54,19 @@ pub const CALL_QUERY: &str = r#" (call_expression (navigation_expression) @function.call) "#; + +/// Extract function name for Swift-specific node kinds +/// +/// Swift has special cases like init_declaration and deinit_declaration +/// that should return fixed names instead of extracting from children. +pub fn extract_function_name_for_kind( + _node: &tree_sitter::Node, + _source: &str, + kind: &str, +) -> Option { + match kind { + "init_declaration" => Some("init".to_string()), + "deinit_declaration" => Some("deinit".to_string()), + _ => None, + } +} diff --git a/crates/goose-mcp/src/developer/analyze/mod.rs b/crates/goose-mcp/src/developer/analyze/mod.rs index 5c36f0b3..a6dccd7a 100644 --- a/crates/goose-mcp/src/developer/analyze/mod.rs +++ b/crates/goose-mcp/src/developer/analyze/mod.rs @@ -53,7 +53,6 @@ impl Default for CodeAnalyzer { } impl CodeAnalyzer { - /// Create a new code analyzer pub fn new() -> Self { tracing::debug!("Initializing CodeAnalyzer"); Self { @@ -62,7 +61,6 @@ impl CodeAnalyzer { } } - /// Main analyze entry point pub fn analyze( &self, params: AnalyzeParams, @@ -83,16 +81,15 @@ impl CodeAnalyzer { AnalysisMode::Focused => self.analyze_focused(&path, ¶ms, &traverser)?, AnalysisMode::Semantic => { if path.is_file() { - let result = self.analyze_file(&path, &mode)?; + let result = self.analyze_file(&path, &mode, ¶ms)?; Formatter::format_analysis_result(&path, &result, &mode) } else { - // Semantic mode on directory - analyze all files self.analyze_directory(&path, ¶ms, &traverser, &mode)? } } AnalysisMode::Structure => { if path.is_file() { - let result = self.analyze_file(&path, &mode)?; + let result = self.analyze_file(&path, &mode, ¶ms)?; Formatter::format_analysis_result(&path, &result, &mode) } else { self.analyze_directory(&path, ¶ms, &traverser, &mode)? @@ -107,7 +104,6 @@ impl CodeAnalyzer { } } - // Check output size and warn if too large (unless force flag is set) const OUTPUT_LIMIT: usize = 1000; if !params.force { let line_count = output.lines().count(); @@ -142,14 +138,11 @@ impl CodeAnalyzer { Ok(CallToolResult::success(Formatter::format_results(output))) } - /// Determine the analysis mode based on parameters and path fn determine_mode(&self, params: &AnalyzeParams, path: &Path) -> AnalysisMode { - // If focus is specified, use focused mode if params.focus.is_some() { return AnalysisMode::Focused; } - // Otherwise, use semantic for files, structure for directories if path.is_file() { AnalysisMode::Semantic } else { @@ -157,11 +150,14 @@ impl CodeAnalyzer { } } - /// Analyze a single file - fn analyze_file(&self, path: &Path, mode: &AnalysisMode) -> Result { + fn analyze_file( + &self, + path: &Path, + mode: &AnalysisMode, + params: &AnalyzeParams, + ) -> Result { tracing::debug!("Analyzing file {:?} in {:?} mode", path, mode); - // Check cache first let metadata = std::fs::metadata(path).map_err(|e| { tracing::error!("Failed to get file metadata for {:?}: {}", path, e); ErrorData::new( @@ -183,61 +179,56 @@ impl CodeAnalyzer { ) })?; - // Check cache if let Some(cached) = self.cache.get(&path.to_path_buf(), modified) { tracing::trace!("Using cached result for {:?}", path); return Ok(cached); } - // Read file content - handle binary files gracefully let content = match std::fs::read_to_string(path) { Ok(content) => content, Err(e) => { - // Binary or non-UTF-8 file, skip parsing tracing::trace!("Skipping binary/non-UTF-8 file {:?}: {}", path, e); return Ok(AnalysisResult::empty(0)); } }; - // Count lines let line_count = content.lines().count(); - // Get language let language = lang::get_language_identifier(path); if language.is_empty() { tracing::trace!("Unsupported file type: {:?}", path); - // Unsupported language, return empty result return Ok(AnalysisResult::empty(line_count)); } // Check if we support this language for parsing - let supported = matches!( - language, - "python" | "rust" | "javascript" | "typescript" | "go" | "java" | "kotlin" | "swift" - ); + // A language is supported if it has query definitions + let language_supported = languages::get_language_info(language) + .map(|info| !info.element_query.is_empty()) + .unwrap_or(false); - if !supported { + if !language_supported { tracing::trace!("Language {} not supported for parsing", language); return Ok(AnalysisResult::empty(line_count)); } - // Parse the file let tree = self.parser_manager.parse(&content, language)?; - // Extract information based on mode let depth = mode.as_str(); - let mut result = ElementExtractor::extract_with_depth(&tree, &content, language, depth)?; + let mut result = ElementExtractor::extract_with_depth( + &tree, + &content, + language, + depth, + params.ast_recursion_limit, + )?; - // Add line count to the result result.line_count = line_count; - // Cache the result self.cache.put(path.to_path_buf(), modified, result.clone()); Ok(result) } - /// Analyze a directory fn analyze_directory( &self, path: &Path, @@ -249,12 +240,10 @@ impl CodeAnalyzer { let mode = *mode; - // Collect directory results with parallel processing let results = traverser.collect_directory_results(path, params.max_depth, |file_path| { - self.analyze_file(file_path, &mode) + self.analyze_file(file_path, &mode, params) })?; - // Format based on mode Ok(Formatter::format_directory_structure( path, &results, @@ -262,14 +251,12 @@ impl CodeAnalyzer { )) } - /// Focused mode analysis - track a symbol across files fn analyze_focused( &self, path: &Path, params: &AnalyzeParams, traverser: &FileTraverser<'_>, ) -> Result { - // Focused mode requires focus parameter let focus_symbol = params.focus.as_ref().ok_or_else(|| { ErrorData::new( ErrorCode::INVALID_PARAMS, @@ -281,7 +268,6 @@ impl CodeAnalyzer { tracing::info!("Running focused analysis for symbol '{}'", focus_symbol); - // Step 1: Collect all files to analyze let files_to_analyze = if path.is_file() { vec![path.to_path_buf()] } else { @@ -293,21 +279,18 @@ impl CodeAnalyzer { files_to_analyze.len() ); - // Step 2: Analyze all files and collect results using parallel processing use rayon::prelude::*; let all_results: Result, _> = files_to_analyze .par_iter() .map(|file_path| { - self.analyze_file(file_path, &AnalysisMode::Semantic) + self.analyze_file(file_path, &AnalysisMode::Semantic, params) .map(|result| (file_path.clone(), result)) }) .collect(); let all_results = all_results?; - // Step 3: Build the call graph let graph = CallGraph::build_from_results(&all_results); - // Step 4: Find call chains based on follow_depth let incoming_chains = if params.follow_depth > 0 { graph.find_incoming_chains(focus_symbol, params.follow_depth) } else { @@ -320,14 +303,12 @@ impl CodeAnalyzer { vec![] }; - // Step 5: Get definitions from graph let definitions = graph .definitions .get(focus_symbol) .cloned() .unwrap_or_default(); - // Step 6: Format the output let focus_data = FocusedAnalysisData { focus_symbol, follow_depth: params.follow_depth, diff --git a/crates/goose-mcp/src/developer/analyze/parser.rs b/crates/goose-mcp/src/developer/analyze/parser.rs index e44de3cb..9edb360a 100644 --- a/crates/goose-mcp/src/developer/analyze/parser.rs +++ b/crates/goose-mcp/src/developer/analyze/parser.rs @@ -9,7 +9,6 @@ use crate::developer::analyze::types::{ ReferenceType, }; -/// Manages tree-sitter parsers for different languages #[derive(Clone)] pub struct ParserManager { parsers: Arc>>>>, @@ -23,7 +22,6 @@ impl ParserManager { } } - /// Get or create a parser for the specified language pub fn get_or_create_parser(&self, language: &str) -> Result>, ErrorData> { let mut cache = lock_or_recover(&self.parsers, |c| c.clear()); @@ -42,6 +40,7 @@ impl ParserManager { "java" => tree_sitter_java::language(), "kotlin" => tree_sitter_kotlin::language(), "swift" => devgen_tree_sitter_swift::language(), + "ruby" => tree_sitter_ruby::language(), _ => { tracing::warn!("Unsupported language: {}", language); return Err(ErrorData::new( @@ -66,10 +65,8 @@ impl ParserManager { Ok(parser_arc) } - /// Parse source code and return the syntax tree pub fn parse(&self, content: &str, language: &str) -> Result { let parser_arc = self.get_or_create_parser(language)?; - // Parser doesn't have a clear() method, so we just continue with it let mut parser = lock_or_recover(&parser_arc, |_| {}); parser.parse(content, None).ok_or_else(|| { @@ -89,66 +86,94 @@ impl Default for ParserManager { } } -/// Extract code elements from a parsed tree pub struct ElementExtractor; impl ElementExtractor { - /// Extract code elements with optional semantic analysis + fn find_child_by_kind<'a>( + node: &'a tree_sitter::Node, + kinds: &[&str], + ) -> Option> { + (0..node.child_count()) + .filter_map(|i| node.child(i)) + .find(|child| kinds.contains(&child.kind())) + } + + fn extract_text_from_child( + node: &tree_sitter::Node, + source: &str, + kinds: &[&str], + ) -> Option { + Self::find_child_by_kind(node, kinds).map(|child| source[child.byte_range()].to_string()) + } + pub fn extract_with_depth( tree: &Tree, source: &str, language: &str, depth: &str, + ast_recursion_limit: Option, ) -> Result { + use crate::developer::analyze::languages; + tracing::trace!( "Extracting elements from {} code with depth {}", language, depth ); - // First get the structural analysis let mut result = Self::extract_elements(tree, source, language)?; - // For structure mode, clear the detailed vectors but keep the counts if depth == "structure" { result.functions.clear(); result.classes.clear(); result.imports.clear(); } else if depth == "semantic" { - // For semantic mode, also extract calls let calls = Self::extract_calls(tree, source, language)?; result.calls = calls; - // Also populate references from the calls for call in &result.calls { result.references.push(ReferenceInfo { symbol: call.callee_name.clone(), ref_type: ReferenceType::Call, line: call.line, context: call.context.clone(), + associated_type: None, }); } + + // Languages can opt-in to advanced reference tracking by providing a REFERENCE_QUERY + // in their language definition. This enables tracking of: + // - Type instantiation (struct literals, object creation) + // - Field/variable/parameter type references + // - Method-to-type associations + if let Some(info) = languages::get_language_info(language) { + if !info.reference_query.is_empty() { + let references = + Self::extract_references(tree, source, language, ast_recursion_limit)?; + result.references.extend(references); + } + } } Ok(result) } - /// Extract basic code elements (functions, classes, imports) pub fn extract_elements( tree: &Tree, source: &str, language: &str, ) -> Result { - // Get language-specific query - let query_str = Self::get_element_query(language); - if query_str.is_empty() { - return Ok(Self::empty_analysis_result()); - } + use crate::developer::analyze::languages; + + let info = match languages::get_language_info(language) { + Some(info) if !info.element_query.is_empty() => info, + _ => return Ok(Self::empty_analysis_result()), + }; + + let query_str = info.element_query; - // Parse and process the query let (functions, classes, imports) = Self::process_element_query(tree, source, query_str)?; - // Detect main function let main_line = functions.iter().find(|f| f.name == "main").map(|f| f.line); Ok(AnalysisResult { @@ -165,23 +190,6 @@ impl ElementExtractor { }) } - /// Get language-specific query for elements - fn get_element_query(language: &str) -> &'static str { - use crate::developer::analyze::languages; - - match language { - "python" => languages::python::ELEMENT_QUERY, - "rust" => languages::rust::ELEMENT_QUERY, - "javascript" | "typescript" => languages::javascript::ELEMENT_QUERY, - "go" => languages::go::ELEMENT_QUERY, - "java" => languages::java::ELEMENT_QUERY, - "kotlin" => languages::kotlin::ELEMENT_QUERY, - "swift" => languages::swift::ELEMENT_QUERY, - _ => "", - } - } - - /// Process element query and extract functions, classes, imports fn process_element_query( tree: &Tree, source: &str, @@ -212,7 +220,7 @@ impl ElementExtractor { let line = source[..node.start_byte()].lines().count() + 1; match query.capture_names()[capture.index as usize] { - "func" => { + "func" | "const" => { functions.push(FunctionInfo { name: text.to_string(), line, @@ -244,37 +252,22 @@ impl ElementExtractor { Ok((functions, classes, imports)) } - /// Get language-specific query for finding function calls - fn get_call_query(language: &str) -> &'static str { - use crate::developer::analyze::languages; - - match language { - "python" => languages::python::CALL_QUERY, - "rust" => languages::rust::CALL_QUERY, - "javascript" | "typescript" => languages::javascript::CALL_QUERY, - "go" => languages::go::CALL_QUERY, - "java" => languages::java::CALL_QUERY, - "kotlin" => languages::kotlin::CALL_QUERY, - "swift" => languages::swift::CALL_QUERY, - _ => "", - } - } - - /// Extract function calls from the parse tree fn extract_calls( tree: &Tree, source: &str, language: &str, ) -> Result, ErrorData> { + use crate::developer::analyze::languages; use tree_sitter::{Query, QueryCursor}; let mut calls = Vec::new(); - // Get language-specific call query - let query_str = Self::get_call_query(language); - if query_str.is_empty() { - return Ok(calls); // No call query for this language - } + let info = match languages::get_language_info(language) { + Some(info) if !info.call_query.is_empty() => info, + _ => return Ok(calls), + }; + + let query_str = info.call_query; let query = Query::new(&tree.language(), query_str).map_err(|e| { tracing::error!("Failed to create call query: {}", e); @@ -294,7 +287,6 @@ impl ElementExtractor { let text = &source[node.byte_range()]; let start_pos = node.start_position(); - // Get the line of code for context let line_start = source[..node.start_byte()] .rfind('\n') .map(|i| i + 1) @@ -305,13 +297,15 @@ impl ElementExtractor { .unwrap_or(source.len()); let context = source[line_start..line_end].trim().to_string(); - // Find the containing function let caller_name = Self::find_containing_function(&node, source, language); - // Add the call based on capture name match query.capture_names()[capture.index as usize] { - "function.call" | "method.call" | "scoped.call" | "macro.call" - | "constructor.call" => { + "function.call" + | "method.call" + | "scoped.call" + | "macro.call" + | "constructor.call" + | "identifier.reference" => { calls.push(CallInfo { caller_name, callee_name: text.to_string(), @@ -329,82 +323,151 @@ impl ElementExtractor { Ok(calls) } - /// Find which function contains a given node + fn extract_references( + tree: &Tree, + source: &str, + language: &str, + ast_recursion_limit: Option, + ) -> Result, ErrorData> { + use crate::developer::analyze::languages; + use tree_sitter::{Query, QueryCursor}; + + let mut references = Vec::new(); + + let info = match languages::get_language_info(language) { + Some(info) if !info.reference_query.is_empty() => info, + _ => return Ok(references), + }; + + let query_str = info.reference_query; + + let query = Query::new(&tree.language(), query_str).map_err(|e| { + tracing::error!("Failed to create reference query: {}", e); + ErrorData::new( + ErrorCode::INTERNAL_ERROR, + format!("Failed to create reference query: {}", e), + None, + ) + })?; + + let mut cursor = QueryCursor::new(); + let mut matches = cursor.matches(&query, tree.root_node(), source.as_bytes()); + + for match_ in matches.by_ref() { + for capture in match_.captures { + let node = capture.node; + let text = &source[node.byte_range()]; + let start_pos = node.start_position(); + + let line_start = source[..node.start_byte()] + .rfind('\n') + .map(|i| i + 1) + .unwrap_or(0); + let line_end = source[node.end_byte()..] + .find('\n') + .map(|i| node.end_byte() + i) + .unwrap_or(source.len()); + let context = source[line_start..line_end].trim().to_string(); + + let capture_name = query.capture_names()[capture.index as usize]; + + let (ref_type, symbol, associated_type) = match capture_name { + "method.receiver" => { + let method_name = Self::find_method_name_for_receiver( + &node, + source, + language, + ast_recursion_limit, + ); + if let Some(method_name) = method_name { + ( + ReferenceType::MethodDefinition, + method_name, + Some(text.to_string()), + ) + } else { + continue; + } + } + "struct.literal" => (ReferenceType::TypeInstantiation, text.to_string(), None), + "field.type" => (ReferenceType::FieldType, text.to_string(), None), + "param.type" => (ReferenceType::ParameterType, text.to_string(), None), + "var.type" | "shortvar.type" => { + (ReferenceType::VariableType, text.to_string(), None) + } + "type.assertion" | "type.conversion" => { + (ReferenceType::Call, text.to_string(), None) + } + _ => continue, + }; + + references.push(ReferenceInfo { + symbol, + ref_type, + line: start_pos.row + 1, + context, + associated_type, + }); + } + } + + tracing::trace!("Extracted {} struct references", references.len()); + Ok(references) + } + + fn find_method_name_for_receiver( + receiver_node: &tree_sitter::Node, + source: &str, + language: &str, + ast_recursion_limit: Option, + ) -> Option { + use crate::developer::analyze::languages; + + languages::get_language_info(language) + .and_then(|info| info.find_method_for_receiver_handler) + .and_then(|handler| handler(receiver_node, source, ast_recursion_limit)) + } + fn find_containing_function( node: &tree_sitter::Node, source: &str, language: &str, ) -> Option { + use crate::developer::analyze::languages; + + let info = languages::get_language_info(language)?; + let mut current = *node; - // Walk up the tree to find a function definition while let Some(parent) = current.parent() { let kind = parent.kind(); - // Check for function-like nodes based on language - let is_function = match language { - "python" => kind == "function_definition", - "rust" => kind == "function_item" || kind == "impl_item", - "javascript" | "typescript" => { - kind == "function_declaration" - || kind == "method_definition" - || kind == "arrow_function" - } - "go" => kind == "function_declaration" || kind == "method_declaration", - "java" => kind == "method_declaration" || kind == "constructor_declaration", - "kotlin" => kind == "function_declaration" || kind == "class_body", - "swift" => { - kind == "function_declaration" - || kind == "init_declaration" - || kind == "deinit_declaration" - || kind == "subscript_declaration" - } - _ => false, - }; - - if is_function { - // Try to extract the function name - for i in 0..parent.child_count() { - if let Some(child) = parent.child(i) { - // Look for identifier nodes that represent the function name - if child.kind() == "identifier" - || child.kind() == "field_identifier" - || child.kind() == "property_identifier" - || (language == "swift" && child.kind() == "simple_identifier") - { - // For Python, skip the first identifier if it's 'def' - if language == "python" && i == 0 { - continue; - } - // For Swift init/deinit, use special names - if language == "swift" { - if kind == "init_declaration" { - return Some("init".to_string()); - } else if kind == "deinit_declaration" { - return Some("deinit".to_string()); - } - } - return Some(source[child.byte_range()].to_string()); - } - - // For Rust impl blocks, look for the type - if language == "rust" - && kind == "impl_item" - && child.kind() == "type_identifier" - { - return Some(format!("impl {}", &source[child.byte_range()])); - } + // Check if this is a function-like node + if info.function_node_kinds.contains(&kind) { + // Two-step extraction process: + // 1. Try language-specific extraction for special cases (e.g., Rust impl blocks, Swift init/deinit) + // 2. Fall back to generic extraction using standard identifier node kinds + // This pattern allows languages to override default behavior when needed + if let Some(handler) = info.extract_function_name_handler { + if let Some(name) = handler(&parent, source, kind) { + return Some(name); } } + + // Standard extraction: find first child matching expected identifier kinds + if let Some(name) = + Self::extract_text_from_child(&parent, source, info.function_name_kinds) + { + return Some(name); + } } current = parent; } - None // No containing function found (module-level call) + None } - /// Create an empty analysis result fn empty_analysis_result() -> AnalysisResult { AnalysisResult { functions: vec![], diff --git a/crates/goose-mcp/src/developer/analyze/tests/go_test.rs b/crates/goose-mcp/src/developer/analyze/tests/go_test.rs new file mode 100644 index 00000000..2d7fc488 --- /dev/null +++ b/crates/goose-mcp/src/developer/analyze/tests/go_test.rs @@ -0,0 +1,115 @@ +use crate::developer::analyze::graph::CallGraph; +use crate::developer::analyze::parser::{ElementExtractor, ParserManager}; +use crate::developer::analyze::types::{AnalysisResult, ReferenceType}; +use std::collections::HashSet; +use std::path::PathBuf; + +fn parse_and_extract(code: &str) -> AnalysisResult { + let manager = ParserManager::new(); + let tree = manager.parse(code, "go").unwrap(); + ElementExtractor::extract_with_depth(&tree, code, "go", "semantic", None).unwrap() +} + +fn build_test_graph(files: Vec<(&str, &str)>) -> CallGraph { + let manager = ParserManager::new(); + let results: Vec<_> = files + .iter() + .map(|(path, code)| { + let tree = manager.parse(code, "go").unwrap(); + let result = + ElementExtractor::extract_with_depth(&tree, code, "go", "semantic", None).unwrap(); + (PathBuf::from(*path), result) + }) + .collect(); + CallGraph::build_from_results(&results) +} + +#[test] +fn test_go_struct_and_method_tracking() { + let code = r#" +package main + +import "myapp/pkg/service" + +type Config struct { + Host string + Port int +} + +type Handler struct { + Cfg *Config + Svc *service.Widget +} + +func (h *Handler) Start() error { + return nil +} + +func (h *Handler) Stop() error { + return nil +} + +func main() { + cfg := Config{Host: "localhost", Port: 8080} + handler := Handler{Cfg: &cfg} + _ = handler.Start() +} +"#; + + let result = parse_and_extract(code); + let graph = build_test_graph(vec![("test.go", code)]); + + assert_eq!(result.class_count, 2); + let struct_names: HashSet<_> = result.classes.iter().map(|c| c.name.as_str()).collect(); + assert!(struct_names.contains("Config")); + assert!(struct_names.contains("Handler")); + + assert_eq!(result.function_count, 3); + let method_names: HashSet<_> = result.functions.iter().map(|f| f.name.as_str()).collect(); + assert!(method_names.contains("Start")); + assert!(method_names.contains("Stop")); + assert!(method_names.contains("main")); + + let handler_methods: Vec<_> = result + .references + .iter() + .filter(|r| { + r.ref_type == ReferenceType::MethodDefinition + && r.associated_type.as_deref() == Some("Handler") + }) + .collect(); + assert!( + handler_methods.len() >= 2, + "Expected at least 2 methods on Handler, found {}", + handler_methods.len() + ); + + let field_type_refs: Vec<_> = result + .references + .iter() + .filter(|r| r.ref_type == ReferenceType::FieldType) + .collect(); + assert!( + !field_type_refs.is_empty(), + "Expected to find field type references" + ); + + let config_literals: Vec<_> = result + .references + .iter() + .filter(|r| r.symbol == "Config" && r.ref_type == ReferenceType::TypeInstantiation) + .collect(); + assert!( + !config_literals.is_empty(), + "Expected to find Config struct literals" + ); + + let incoming = graph.find_incoming_chains("Handler", 1); + assert!( + !incoming.is_empty(), + "Expected to find incoming references to Handler" + ); + + let outgoing = graph.find_outgoing_chains("Handler", 1); + assert!(!outgoing.is_empty(), "Expected to find methods on Handler"); +} diff --git a/crates/goose-mcp/src/developer/analyze/tests/integration_tests.rs b/crates/goose-mcp/src/developer/analyze/tests/integration_tests.rs index 464424f3..4db4a23d 100644 --- a/crates/goose-mcp/src/developer/analyze/tests/integration_tests.rs +++ b/crates/goose-mcp/src/developer/analyze/tests/integration_tests.rs @@ -17,6 +17,7 @@ fn test_analyze_python_file() { focus: None, follow_depth: 2, max_depth: 3, + ast_recursion_limit: None, force: false, }; @@ -45,6 +46,7 @@ fn test_analyze_directory() { focus: None, follow_depth: 2, max_depth: 3, + ast_recursion_limit: None, force: false, }; @@ -81,6 +83,7 @@ fn test_focused_analysis() { focus: Some("helper".to_string()), follow_depth: 1, max_depth: 3, + ast_recursion_limit: None, force: false, }; @@ -109,6 +112,7 @@ fn test_analyze_with_cache() { focus: None, follow_depth: 2, max_depth: 3, + ast_recursion_limit: None, force: false, }; @@ -140,6 +144,7 @@ fn test_analyze_unsupported_file() { focus: None, follow_depth: 2, max_depth: 3, + ast_recursion_limit: None, force: false, }; @@ -158,6 +163,7 @@ fn test_analyze_nonexistent_path() { focus: None, follow_depth: 2, max_depth: 3, + ast_recursion_limit: None, force: false, }; @@ -182,6 +188,7 @@ fn test_focused_without_symbol() { focus: Some("nonexistent_symbol".to_string()), follow_depth: 1, max_depth: 3, + ast_recursion_limit: None, force: false, }; @@ -219,6 +226,7 @@ fn test_nested_directory_analysis() { focus: None, follow_depth: 2, max_depth: 3, // Increase max_depth to ensure we reach nested files + ast_recursion_limit: None, force: false, }; diff --git a/crates/goose-mcp/src/developer/analyze/tests/large_output_tests.rs b/crates/goose-mcp/src/developer/analyze/tests/large_output_tests.rs index a2c1409a..5df17ddb 100644 --- a/crates/goose-mcp/src/developer/analyze/tests/large_output_tests.rs +++ b/crates/goose-mcp/src/developer/analyze/tests/large_output_tests.rs @@ -34,6 +34,7 @@ fn test_large_output_warning() { focus: None, follow_depth: 2, max_depth: 3, + ast_recursion_limit: None, force: false, // Should trigger warning }; @@ -82,6 +83,7 @@ fn test_force_flag_bypasses_warning() { focus: None, follow_depth: 2, max_depth: 3, + ast_recursion_limit: None, force: true, // Should bypass warning }; @@ -119,6 +121,7 @@ fn test_small_output_no_warning() { focus: None, follow_depth: 2, max_depth: 3, + ast_recursion_limit: None, force: false, // Shouldn't matter for small output }; diff --git a/crates/goose-mcp/src/developer/analyze/tests/mod.rs b/crates/goose-mcp/src/developer/analyze/tests/mod.rs index efc42587..f74ec3ba 100644 --- a/crates/goose-mcp/src/developer/analyze/tests/mod.rs +++ b/crates/goose-mcp/src/developer/analyze/tests/mod.rs @@ -3,8 +3,10 @@ pub mod cache_tests; pub mod fixtures; pub mod formatter_tests; +pub mod go_test; pub mod graph_tests; pub mod integration_tests; pub mod large_output_tests; pub mod parser_tests; +pub mod ruby_test; pub mod traversal_tests; diff --git a/crates/goose-mcp/src/developer/analyze/tests/parser_tests.rs b/crates/goose-mcp/src/developer/analyze/tests/parser_tests.rs index ae5e7a75..0b93dec6 100644 --- a/crates/goose-mcp/src/developer/analyze/tests/parser_tests.rs +++ b/crates/goose-mcp/src/developer/analyze/tests/parser_tests.rs @@ -118,7 +118,7 @@ def func2(): let tree = manager.parse(content, "python").unwrap(); let result = - ElementExtractor::extract_with_depth(&tree, content, "python", "structure").unwrap(); + ElementExtractor::extract_with_depth(&tree, content, "python", "structure", None).unwrap(); // In structure mode, detailed vectors should be empty but counts preserved assert_eq!(result.function_count, 2); @@ -139,7 +139,7 @@ def func2(): let tree = manager.parse(content, "python").unwrap(); let result = - ElementExtractor::extract_with_depth(&tree, content, "python", "semantic").unwrap(); + ElementExtractor::extract_with_depth(&tree, content, "python", "semantic", None).unwrap(); // In semantic mode, should have both elements and calls assert_eq!(result.function_count, 2); @@ -226,3 +226,80 @@ fun helper() { assert!(result.import_count > 0); // import statements assert!(result.main_line.is_some()); } + +#[test] +fn test_language_registry() { + use crate::developer::analyze::languages; + + let supported = vec![ + "python", + "rust", + "javascript", + "typescript", + "go", + "java", + "kotlin", + "swift", + "ruby", + ]; + + for lang in supported { + let info = languages::get_language_info(lang); + assert!(info.is_some(), "Language {} should be supported", lang); + + let info = info.unwrap(); + assert!( + !info.element_query.is_empty(), + "{} missing element_query", + lang + ); + assert!(!info.call_query.is_empty(), "{} missing call_query", lang); + assert!( + !info.function_node_kinds.is_empty(), + "{} missing function_node_kinds", + lang + ); + assert!( + !info.function_name_kinds.is_empty(), + "{} missing function_name_kinds", + lang + ); + } + + let js = languages::get_language_info("javascript").unwrap(); + let ts = languages::get_language_info("typescript").unwrap(); + assert_eq!( + js.element_query, ts.element_query, + "JS/TS should share config" + ); + + let go = languages::get_language_info("go").unwrap(); + assert!( + !go.reference_query.is_empty(), + "Go should have reference tracking" + ); + assert!(go.find_method_for_receiver_handler.is_some()); + + let ruby = languages::get_language_info("ruby").unwrap(); + assert!( + !ruby.reference_query.is_empty(), + "Ruby should have reference tracking" + ); + assert!(ruby.find_method_for_receiver_handler.is_some()); + + let rust = languages::get_language_info("rust").unwrap(); + assert!( + rust.extract_function_name_handler.is_some(), + "Rust should have custom handler" + ); + + let swift = languages::get_language_info("swift").unwrap(); + assert!( + swift.extract_function_name_handler.is_some(), + "Swift should have custom handler" + ); + + assert!(languages::get_language_info("unsupported").is_none()); + assert!(languages::get_language_info("").is_none()); + assert!(languages::get_language_info("C++").is_none()); +} diff --git a/crates/goose-mcp/src/developer/analyze/tests/ruby_test.rs b/crates/goose-mcp/src/developer/analyze/tests/ruby_test.rs new file mode 100644 index 00000000..5f661660 --- /dev/null +++ b/crates/goose-mcp/src/developer/analyze/tests/ruby_test.rs @@ -0,0 +1,259 @@ +#[cfg(test)] +mod ruby_tests { + use crate::developer::analyze::graph::CallGraph; + use crate::developer::analyze::parser::{ElementExtractor, ParserManager}; + use crate::developer::analyze::types::ReferenceType; + use std::collections::HashSet; + use std::path::PathBuf; + + #[test] + fn test_ruby_basic_parsing() { + let parser = ParserManager::new(); + let source = r#" +require 'json' + +class MyClass + attr_accessor :name + + def initialize(name) + @name = name + end + + def greet + puts "Hello" + end +end +"#; + + let tree = parser.parse(source, "ruby").unwrap(); + let result = ElementExtractor::extract_elements(&tree, source, "ruby").unwrap(); + + assert_eq!(result.class_count, 1); + assert!(result.classes.iter().any(|c| c.name == "MyClass")); + + assert!(result.function_count > 0); + assert!(result.functions.iter().any(|f| f.name == "initialize")); + assert!(result.functions.iter().any(|f| f.name == "greet")); + + assert!(result.import_count > 0); + } + + #[test] + fn test_ruby_attr_methods() { + let parser = ParserManager::new(); + let source = r#" +class Person + attr_reader :age + attr_writer :status + attr_accessor :name +end +"#; + + let tree = parser.parse(source, "ruby").unwrap(); + let result = ElementExtractor::extract_elements(&tree, source, "ruby").unwrap(); + + assert!( + result.function_count >= 3, + "Expected at least 3 functions from attr_* declarations, got {}", + result.function_count + ); + } + + #[test] + fn test_ruby_require_patterns() { + let parser = ParserManager::new(); + let source = r#" +require 'json' +require_relative 'lib/helper' +"#; + + let tree = parser.parse(source, "ruby").unwrap(); + let result = ElementExtractor::extract_elements(&tree, source, "ruby").unwrap(); + + assert_eq!( + result.import_count, 2, + "Should find both require and require_relative" + ); + } + + #[test] + fn test_ruby_method_calls() { + let parser = ParserManager::new(); + let source = r#" +class Example + def test_method + puts "Hello" + JSON.parse("{}") + object.method_call + end +end +"#; + + let tree = parser.parse(source, "ruby").unwrap(); + let result = + ElementExtractor::extract_with_depth(&tree, source, "ruby", "semantic", None).unwrap(); + + assert!(!result.calls.is_empty(), "Should find method calls"); + assert!(result.calls.iter().any(|c| c.callee_name == "puts")); + } + + #[test] + fn test_ruby_reference_tracking() { + let parser = ParserManager::new(); + let source = r#" +class User + attr_accessor :name + + def initialize(name) + @name = name + end + + def greet + puts "Hello, #{@name}" + end +end + +class Post + STATUS_DRAFT = "draft" + STATUS_PUBLISHED = "published" + + def initialize(title) + @title = title + @status = STATUS_DRAFT + end + + def publish + @status = STATUS_PUBLISHED + notify_users(@status) + end +end + +def main + user = User.new("Alice") + post = Post.new("My Title") + post.publish +end +"#; + + let tree = parser.parse(source, "ruby").unwrap(); + let result = + ElementExtractor::extract_with_depth(&tree, source, "ruby", "semantic", None).unwrap(); + + assert_eq!(result.class_count, 2); + let class_names: HashSet<_> = result.classes.iter().map(|c| c.name.as_str()).collect(); + assert!(class_names.contains("User")); + assert!(class_names.contains("Post")); + + assert!(result.function_count > 0); + let method_names: HashSet<_> = result.functions.iter().map(|f| f.name.as_str()).collect(); + assert!(method_names.contains("initialize")); + assert!(method_names.contains("greet")); + assert!(method_names.contains("publish")); + + let constant_refs: Vec<_> = result + .references + .iter() + .filter(|r| r.symbol == "STATUS_DRAFT" || r.symbol == "STATUS_PUBLISHED") + .collect(); + assert!( + !constant_refs.is_empty(), + "Expected to find constant references" + ); + + let instantiations: Vec<_> = result + .references + .iter() + .filter(|r| r.ref_type == ReferenceType::TypeInstantiation) + .collect(); + assert!( + instantiations.len() >= 2, + "Expected at least 2 class instantiations (User.new, Post.new)" + ); + let instantiated_types: HashSet<_> = + instantiations.iter().map(|r| r.symbol.as_str()).collect(); + assert!(instantiated_types.contains("User")); + assert!(instantiated_types.contains("Post")); + + let constant_usages: Vec<_> = result + .references + .iter() + .filter(|r| r.symbol == "STATUS_DRAFT" || r.symbol == "STATUS_PUBLISHED") + .collect(); + assert!( + !constant_usages.is_empty(), + "Expected to find STATUS_* constant usages" + ); + } + + #[test] + fn test_ruby_call_chains() { + let parser = ParserManager::new(); + + let file1 = r#" +class User + def initialize(name) + @name = name + end + + def display + format_output(@name) + end + + def format_output(text) + "User: #{text}" + end +end +"#; + + let file2 = r#" +require_relative 'user' + +def create_user(name) + User.new(name) +end + +def show_user(name) + user = create_user(name) + user.display +end +"#; + + let tree1 = parser.parse(file1, "ruby").unwrap(); + let result1 = + ElementExtractor::extract_with_depth(&tree1, file1, "ruby", "semantic", None).unwrap(); + + let tree2 = parser.parse(file2, "ruby").unwrap(); + let result2 = + ElementExtractor::extract_with_depth(&tree2, file2, "ruby", "semantic", None).unwrap(); + + let results = vec![ + (PathBuf::from("user.rb"), result1), + (PathBuf::from("main.rb"), result2), + ]; + let graph = CallGraph::build_from_results(&results); + + let incoming_user = graph.find_incoming_chains("User", 1); + assert!( + !incoming_user.is_empty(), + "Expected incoming references to User class" + ); + + let outgoing_display = graph.find_outgoing_chains("display", 1); + assert!( + !outgoing_display.is_empty(), + "Expected display to call format_output" + ); + + let outgoing_create = graph.find_outgoing_chains("create_user", 2); + assert!( + !outgoing_create.is_empty(), + "Expected create_user to have call chains" + ); + + let incoming_create = graph.find_incoming_chains("create_user", 1); + assert!( + !incoming_create.is_empty(), + "Expected show_user to call create_user" + ); + } +} diff --git a/crates/goose-mcp/src/developer/analyze/types.rs b/crates/goose-mcp/src/developer/analyze/types.rs index d2c172e8..68175814 100644 --- a/crates/goose-mcp/src/developer/analyze/types.rs +++ b/crates/goose-mcp/src/developer/analyze/types.rs @@ -2,13 +2,10 @@ use rmcp::schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::path::PathBuf; -/// Parameters for the analyze tool #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct AnalyzeParams { - /// Absolute path. Step 1: Directory for overview. Step 2: File for details. Step 3: Directory with focus param for call graphs pub path: String, - /// Symbol name for call graph analysis (Step 3). Requires directory path with broad enough scope to capture all relevant symbol references pub focus: Option, /// Call graph depth. 0=where defined, 1=direct callers/callees, 2+=transitive chains @@ -19,6 +16,10 @@ pub struct AnalyzeParams { #[serde(default = "default_max_depth")] pub max_depth: u32, + /// Maximum depth for recursive AST traversal (prevents stack overflow in deeply nested code) + #[serde(default)] + pub ast_recursion_limit: Option, + /// Allow large outputs without warning (default: false) #[serde(default)] pub force: bool, @@ -64,11 +65,11 @@ pub struct ClassInfo { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CallInfo { - pub caller_name: Option, // Function containing this call - pub callee_name: String, // Function being called + pub caller_name: Option, + pub callee_name: String, pub line: usize, pub column: usize, - pub context: String, // Line of code containing the call + pub context: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -77,14 +78,29 @@ pub struct ReferenceInfo { pub ref_type: ReferenceType, pub line: usize, pub context: String, + /// For method definitions, this stores the type the method belongs to + /// For type usage, this is None + pub associated_type: Option, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum ReferenceType { + /// Type/class/struct definition Definition, + /// Method or function definition on a type (use associated_type to link to type) + MethodDefinition, + /// Function call or method call Call, + /// Type instantiation (e.g., struct literal, class constructor) + TypeInstantiation, + /// Type used in field declaration + FieldType, + /// Type used in variable declaration + VariableType, + /// Type used in function/method parameter + ParameterType, + /// Import statement Import, - Assignment, } // Entry type for directory results - cleaner than overloading AnalysisResult