Add analyze tool to Developer extension (#4530)

Adds an analyze tool to the developer MCP extension that provides code structure analysis and symbol tracking capabilities across multiple programming languages.
This commit is contained in:
tlongwell-block
2025-09-12 20:02:26 -04:00
committed by GitHub
parent cb3def45e1
commit 6970cd850c
28 changed files with 3694 additions and 8 deletions
+13
View File
@@ -60,6 +60,17 @@ hyper = "1"
serde_with = "3"
which = "6.0"
glob = "0.3"
lru = "0.12"
tree-sitter = "0.21"
tree-sitter-python = "0.21"
tree-sitter-rust = "0.21"
tree-sitter-javascript = "0.21"
tree-sitter-go = "0.21"
tree-sitter-java = "0.21"
tree-sitter-kotlin = "0.3.8"
devgen-tree-sitter-swift = "0.21.0"
streaming-iterator = "0.1"
rayon = "1.10"
# TODO: Fork mpatch or replace with a custom implementation using `similar` crate
# for fuzzy patch matching. Current crate has limited maintenance (single maintainer,
# ~1000 downloads). Pinned to exact version to prevent supply chain attacks.
@@ -70,6 +81,8 @@ mpatch = "=0.2.0"
serial_test = "3.0.0"
sysinfo = "0.32.1"
temp-env = "0.3.6"
clap = { version = "4", features = ["derive"] }
colored = "2"
[features]
utoipa = ["dep:utoipa"]
@@ -0,0 +1,86 @@
use lru::LruCache;
use std::num::NonZeroUsize;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use super::lock_or_recover;
use crate::developer::analyze::types::AnalysisResult;
#[derive(Clone)]
pub struct AnalysisCache {
cache: Arc<Mutex<LruCache<CacheKey, Arc<AnalysisResult>>>>,
#[allow(dead_code)]
max_size: usize,
}
#[derive(Hash, Eq, PartialEq, Debug, Clone)]
struct CacheKey {
path: PathBuf,
modified: SystemTime,
}
impl AnalysisCache {
pub fn new(max_size: usize) -> Self {
tracing::info!("Initializing analysis cache with size {}", max_size);
let size = NonZeroUsize::new(max_size).unwrap_or_else(|| {
tracing::warn!("Invalid cache size {}, using default 100", max_size);
NonZeroUsize::new(100).unwrap()
});
Self {
cache: Arc::new(Mutex::new(LruCache::new(size))),
max_size,
}
}
pub fn get(&self, path: &PathBuf, modified: SystemTime) -> Option<AnalysisResult> {
let mut cache = lock_or_recover(&self.cache, |c| c.clear());
let key = CacheKey {
path: path.clone(),
modified,
};
if let Some(result) = cache.get(&key) {
tracing::trace!("Cache hit for {:?}", path);
Some((**result).clone())
} else {
tracing::trace!("Cache miss for {:?}", path);
None
}
}
pub fn put(&self, path: PathBuf, modified: SystemTime, result: AnalysisResult) {
let mut cache = lock_or_recover(&self.cache, |c| c.clear());
let key = CacheKey {
path: path.clone(),
modified,
};
tracing::trace!("Caching result for {:?}", path);
cache.put(key, Arc::new(result));
}
pub fn clear(&self) {
let mut cache = lock_or_recover(&self.cache, |c| c.clear());
cache.clear();
tracing::debug!("Cache cleared");
}
pub fn len(&self) -> usize {
let cache = lock_or_recover(&self.cache, |c| c.clear());
cache.len()
}
pub fn is_empty(&self) -> bool {
let cache = lock_or_recover(&self.cache, |c| c.clear());
cache.is_empty()
}
}
impl Default for AnalysisCache {
fn default() -> Self {
Self::new(100)
}
}
@@ -0,0 +1,654 @@
use rmcp::model::{Content, Role};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use crate::developer::analyze::types::{
AnalysisMode, AnalysisResult, CallChain, EntryType, FocusedAnalysisData,
};
use crate::developer::lang;
pub struct Formatter;
impl Formatter {
pub fn format_results(output: String) -> Vec<Content> {
vec![
Content::text(output.clone()).with_audience(vec![Role::Assistant]),
Content::text(output)
.with_audience(vec![Role::User])
.with_priority(0.0),
]
}
/// Format analysis result based on mode
pub fn format_analysis_result(
path: &Path,
result: &AnalysisResult,
mode: &AnalysisMode,
) -> String {
tracing::debug!("Formatting result for {:?} in {:?} mode", path, mode);
match mode {
AnalysisMode::Structure => Self::format_structure_overview(path, result),
AnalysisMode::Semantic => Self::format_semantic_result(path, result),
AnalysisMode::Focused => {
// Focused mode is handled separately
tracing::warn!("format_analysis_result called with Focused mode");
String::new()
}
}
}
/// Format structure overview (compact format)
pub fn format_structure_overview(path: &Path, result: &AnalysisResult) -> String {
let mut output = String::new();
// Format as: path [LOC, FUNCTIONS, CLASSES] <FLAGS>
output.push_str(&format!("{} [{}L", path.display(), result.line_count));
if result.function_count > 0 {
output.push_str(&format!(", {}F", result.function_count));
}
if result.class_count > 0 {
output.push_str(&format!(", {}C", result.class_count));
}
output.push(']');
// Add FLAGS if any
if let Some(main_line) = result.main_line {
output.push_str(&format!(" main:{}", main_line));
}
output.push('\n');
output
}
/// Format semantic analysis result (dense matrix format)
pub fn format_semantic_result(path: &Path, result: &AnalysisResult) -> String {
let mut output = format!(
"FILE: {} [{}L, {}F, {}C]\n\n",
path.display(),
result.line_count,
result.function_count,
result.class_count
);
// Classes on single/multiple lines with colon-separated line numbers
if !result.classes.is_empty() {
output.push_str("C: ");
let class_strs: Vec<String> = result
.classes
.iter()
.map(|c| format!("{}:{}", c.name, c.line))
.collect();
output.push_str(&class_strs.join(" "));
output.push_str("\n\n");
}
// Functions with call counts where significant
if !result.functions.is_empty() {
output.push_str("F: ");
// Count how many times each function is called
let mut call_counts: HashMap<String, usize> = HashMap::new();
for call in &result.calls {
*call_counts.entry(call.callee_name.clone()).or_insert(0) += 1;
}
let func_strs: Vec<String> = result
.functions
.iter()
.map(|f| {
let count = call_counts.get(&f.name).unwrap_or(&0);
if *count > 3 {
format!("{}:{}{}", f.name, f.line, count)
} else {
format!("{}:{}", f.name, f.line)
}
})
.collect();
// Format functions, wrapping at reasonable line length
let mut line_len = 3; // "F: "
for (i, func_str) in func_strs.iter().enumerate() {
if i > 0 && line_len + func_str.len() + 1 > 100 {
output.push_str("\n ");
line_len = 3;
}
if i > 0 {
output.push(' ');
line_len += 1;
}
output.push_str(func_str);
line_len += func_str.len();
}
output.push_str("\n\n");
}
// Condensed imports
if !result.imports.is_empty() {
output.push_str("I: ");
// Group imports by module/package
let mut grouped_imports: HashMap<String, Vec<String>> = HashMap::new();
for import in &result.imports {
// Simple heuristic: first word/module is the group
let group = if import.starts_with("use ") {
import.split("::").next().unwrap_or("use").to_string()
} else if import.starts_with("import ") {
import
.split_whitespace()
.nth(1)
.unwrap_or("import")
.to_string()
} else if import.starts_with("from ") {
import
.split_whitespace()
.nth(1)
.unwrap_or("from")
.to_string()
} else {
import.split_whitespace().next().unwrap_or("").to_string()
};
grouped_imports
.entry(group)
.or_default()
.push(import.clone());
}
// Show condensed import summary
let import_summary: Vec<String> = grouped_imports
.iter()
.map(|(group, imports)| {
if imports.len() > 1 {
format!("{}({})", group, imports.len())
} else {
// For single imports, show more detail
let imp = &imports[0];
if imp.len() > 40 {
format!("{}...", &imp[..37])
} else {
imp.clone()
}
}
})
.collect();
output.push_str(&import_summary.join("; "));
output.push('\n');
}
output
}
/// Format directory structure with summary
pub fn format_directory_structure(
base_path: &Path,
results: &[(PathBuf, EntryType)],
max_depth: u32,
) -> String {
let mut output = String::new();
// Add summary section
Self::append_summary(&mut output, results, max_depth);
output.push_str("\nPATH [LOC, FUNCTIONS, CLASSES] <FLAGS>\n");
// Add tree structure
Self::append_tree_structure(&mut output, base_path, results);
output
}
/// Append summary section with statistics
fn append_summary(output: &mut String, results: &[(PathBuf, EntryType)], max_depth: u32) {
// Calculate totals (only from files)
let files: Vec<&AnalysisResult> = results
.iter()
.filter_map(|(_, entry)| match entry {
EntryType::File(result) => Some(result),
_ => None,
})
.collect();
let total_files = files.len();
let total_lines: usize = files.iter().map(|r| r.line_count).sum();
let total_functions: usize = files.iter().map(|r| r.function_count).sum();
let total_classes: usize = files.iter().map(|r| r.class_count).sum();
// Format summary with depth indicator
output.push_str("SUMMARY:\n");
if max_depth == 0 {
output.push_str(&format!(
"Shown: {} files, {}L, {}F, {}C (unlimited depth)\n",
total_files, total_lines, total_functions, total_classes
));
} else {
output.push_str(&format!(
"Shown: {} files, {}L, {}F, {}C (max_depth={})\n",
total_files, total_lines, total_functions, total_classes, max_depth
));
}
// Add language distribution
Self::append_language_stats(output, results, total_lines);
}
/// Append language statistics
fn append_language_stats(
output: &mut String,
results: &[(PathBuf, EntryType)],
total_lines: usize,
) {
// Calculate language distribution
let mut language_lines: HashMap<String, usize> = HashMap::new();
for (path, entry) in results {
if let EntryType::File(result) = entry {
let lang = lang::get_language_identifier(path);
if !lang.is_empty() && result.line_count > 0 {
*language_lines.entry(lang.to_string()).or_insert(0) += result.line_count;
}
}
}
// Format language percentages
if !language_lines.is_empty() && total_lines > 0 {
let mut languages: Vec<_> = language_lines.iter().collect();
languages.sort_by(|a, b| b.1.cmp(a.1)); // Sort by lines descending
let lang_str: Vec<String> = languages
.iter()
.map(|(lang, lines)| {
let percentage = (**lines as f64 / total_lines as f64 * 100.0) as u32;
format!("{} ({}%)", lang, percentage)
})
.collect();
output.push_str(&format!("Languages: {}\n", lang_str.join(", ")));
}
}
/// Append tree structure for directory contents
fn append_tree_structure(
output: &mut String,
base_path: &Path,
results: &[(PathBuf, EntryType)],
) {
// Sort results by path for consistent output
let mut sorted_results = results.to_vec();
sorted_results.sort_by(|a, b| a.0.cmp(&b.0));
// Track which directories we've already printed to avoid duplicates
let mut printed_dirs = HashSet::new();
// Format each entry with tree-style indentation
for (path, entry) in sorted_results {
Self::format_tree_entry(output, base_path, &path, &entry, &mut printed_dirs);
}
}
/// Format a single tree entry
fn format_tree_entry(
output: &mut String,
base_path: &Path,
path: &Path,
entry: &EntryType,
printed_dirs: &mut HashSet<PathBuf>,
) {
// Make path relative to base_path
let relative_path = path.strip_prefix(base_path).unwrap_or(path);
// Get path components for determining structure
let components: Vec<_> = relative_path.components().collect();
if components.is_empty() {
return;
}
// Print parent directories if not already printed
for i in 0..components.len().saturating_sub(1) {
let parent_path: PathBuf = components[..=i].iter().collect();
if !printed_dirs.contains(&parent_path) {
let indent = " ".repeat(i);
let dir_name = components[i].as_os_str().to_string_lossy();
output.push_str(&format!("{}{}/\n", indent, dir_name));
printed_dirs.insert(parent_path);
}
}
// Determine indentation level for this entry
let indent_level = components.len().saturating_sub(1);
let indent = " ".repeat(indent_level);
// Get the file/directory name (last component)
let name = components
.last()
.map(|c| c.as_os_str().to_string_lossy().to_string())
.unwrap_or_else(|| relative_path.display().to_string());
// Format based on entry type
Self::format_entry_line(
output,
&indent,
&name,
entry,
base_path,
relative_path,
printed_dirs,
);
}
/// Format the line for a specific entry type
fn format_entry_line(
output: &mut String,
indent: &str,
name: &str,
entry: &EntryType,
base_path: &Path,
relative_path: &Path,
printed_dirs: &mut HashSet<PathBuf>,
) {
match entry {
EntryType::File(result) => {
output.push_str(&format!("{}{} [{}L", indent, name, result.line_count));
if result.function_count > 0 {
output.push_str(&format!(", {}F", result.function_count));
}
if result.class_count > 0 {
output.push_str(&format!(", {}C", result.class_count));
}
output.push(']');
if let Some(main_line) = result.main_line {
output.push_str(&format!(" main:{}", main_line));
}
output.push('\n');
}
EntryType::Directory => {
// Only print if not already printed as a parent
if !printed_dirs.contains(relative_path) {
output.push_str(&format!("{}{}/\n", indent, name));
printed_dirs.insert(relative_path.to_path_buf());
}
}
EntryType::SymlinkDir(target) | EntryType::SymlinkFile(target) => {
let is_dir = matches!(entry, EntryType::SymlinkDir(_));
let target_display = if target.is_relative() {
target.display().to_string()
} else if let Ok(rel) = target.strip_prefix(base_path) {
rel.display().to_string()
} else {
target.display().to_string()
};
let suffix = if is_dir { "/" } else { "" };
output.push_str(&format!(
"{}{}{} -> {}\n",
indent, name, suffix, target_display
));
}
}
}
/// Format focused analysis output with call chains
pub fn format_focused_output(focus_data: &FocusedAnalysisData) -> String {
let mut output = format!("FOCUSED ANALYSIS: {}\n\n", focus_data.focus_symbol);
// Build file alias mapping
let (file_map, sorted_files) = Self::build_file_aliases(
focus_data.definitions,
focus_data.incoming_chains,
focus_data.outgoing_chains,
);
// Section 1: Definitions
Self::append_definitions(
&mut output,
focus_data.definitions,
&file_map,
focus_data.focus_symbol,
);
// Section 2: Incoming Call Chains
Self::append_call_chains(
&mut output,
focus_data.incoming_chains,
&file_map,
focus_data.follow_depth,
true,
);
// Section 3: Outgoing Call Chains
Self::append_call_chains(
&mut output,
focus_data.outgoing_chains,
&file_map,
focus_data.follow_depth,
false,
);
// Section 4: Summary Statistics
Self::append_statistics(
&mut output,
focus_data.files_analyzed,
focus_data.definitions,
focus_data.incoming_chains,
focus_data.outgoing_chains,
focus_data.follow_depth,
);
// Section 5: File Legend
Self::append_file_legend(
&mut output,
&file_map,
&sorted_files,
focus_data.definitions,
focus_data.incoming_chains,
focus_data.outgoing_chains,
);
if focus_data.definitions.is_empty()
&& focus_data.incoming_chains.is_empty()
&& focus_data.outgoing_chains.is_empty()
{
output = format!(
"Symbol '{}' not found in any analyzed files.\n",
focus_data.focus_symbol
);
}
output
}
/// Build file alias mapping for focused output
fn build_file_aliases(
definitions: &[(PathBuf, usize)],
incoming_chains: &[CallChain],
outgoing_chains: &[CallChain],
) -> (HashMap<PathBuf, String>, Vec<PathBuf>) {
let mut all_files = HashSet::new();
for (file, _) in definitions {
all_files.insert(file.clone());
}
for chain in incoming_chains.iter().chain(outgoing_chains.iter()) {
for (file, _, _, _) in &chain.path {
all_files.insert(file.clone());
}
}
let mut sorted_files: Vec<_> = all_files.into_iter().collect();
sorted_files.sort();
let mut file_map = HashMap::new();
for (index, file) in sorted_files.iter().enumerate() {
let alias = if sorted_files.len() == 1 {
file.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string()
} else {
format!("F{}", index + 1)
};
file_map.insert(file.clone(), alias);
}
(file_map, sorted_files)
}
/// Append definitions section to output
fn append_definitions(
output: &mut String,
definitions: &[(PathBuf, usize)],
file_map: &HashMap<PathBuf, String>,
focus_symbol: &str,
) {
if !definitions.is_empty() {
output.push_str("DEFINITIONS:\n");
for (file, line) in definitions {
let alias = file_map.get(file).cloned().unwrap_or_else(|| {
file.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string()
});
output.push_str(&format!("{}:{} - {}\n", alias, line, focus_symbol));
}
output.push('\n');
}
}
/// Append call chains section to output
fn append_call_chains(
output: &mut String,
chains: &[CallChain],
file_map: &HashMap<PathBuf, String>,
follow_depth: u32,
is_incoming: bool,
) {
if !chains.is_empty() {
let chain_type = if is_incoming { "INCOMING" } else { "OUTGOING" };
output.push_str(&format!(
"{} CALL CHAINS (depth={}):\n",
chain_type, follow_depth
));
let mut unique_chains = HashSet::new();
for chain in chains {
let chain_str = Self::format_chain_path(&chain.path, file_map);
unique_chains.insert(chain_str);
}
let mut sorted_chains: Vec<_> = unique_chains.into_iter().collect();
sorted_chains.sort();
for chain in sorted_chains {
output.push_str(&format!("{}\n", chain));
}
output.push('\n');
}
}
/// Format a single chain path
fn format_chain_path(
path: &[(PathBuf, usize, String, String)],
file_map: &HashMap<PathBuf, String>,
) -> String {
path.iter()
.map(|(file, line, from, to)| {
let alias = file_map.get(file).cloned().unwrap_or_else(|| {
file.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string()
});
format!("{}:{} ({} -> {})", alias, line, from, to)
})
.collect::<Vec<_>>()
.join(" -> ")
}
/// Append statistics section to output
fn append_statistics(
output: &mut String,
files_analyzed: &[PathBuf],
definitions: &[(PathBuf, usize)],
incoming_chains: &[CallChain],
outgoing_chains: &[CallChain],
follow_depth: u32,
) {
output.push_str("STATISTICS:\n");
output.push_str(&format!(" Files analyzed: {}\n", files_analyzed.len()));
output.push_str(&format!(" Definitions found: {}\n", definitions.len()));
output.push_str(&format!(" Incoming chains: {}\n", incoming_chains.len()));
output.push_str(&format!(" Outgoing chains: {}\n", outgoing_chains.len()));
output.push_str(&format!(" Follow depth: {}\n", follow_depth));
}
/// Append file legend section to output
fn append_file_legend(
output: &mut String,
file_map: &HashMap<PathBuf, String>,
sorted_files: &[PathBuf],
definitions: &[(PathBuf, usize)],
incoming_chains: &[CallChain],
outgoing_chains: &[CallChain],
) {
if !file_map.is_empty()
&& (sorted_files.len() > 1
|| !incoming_chains.is_empty()
|| !outgoing_chains.is_empty()
|| !definitions.is_empty())
{
output.push_str("\nFILES:\n");
let mut legend_entries: Vec<_> = file_map.iter().collect();
legend_entries.sort_by_key(|(_, alias)| alias.as_str());
for (file_path, alias) in legend_entries {
if sorted_files.len() == 1
&& alias == file_path.file_name().and_then(|n| n.to_str()).unwrap_or("")
{
continue;
}
output.push_str(&format!(" {}: {}\n", alias, file_path.display()));
}
}
}
/// Filter output by focus symbol
pub fn filter_by_focus(output: &str, focus: &str) -> String {
let mut filtered = String::new();
let mut include_section = false;
for line in output.lines() {
if line.starts_with("##") {
include_section = false;
}
if line.contains(focus) {
include_section = true;
// Include the file header
if let Some(header_line) = output
.lines()
.rev()
.find(|l| l.starts_with("##") && line.contains(&l[3..]))
{
if !filtered.contains(header_line) {
filtered.push_str(header_line);
filtered.push('\n');
}
}
}
if include_section || line.starts_with('#') {
filtered.push_str(line);
filtered.push('\n');
}
}
if filtered.is_empty() {
format!("No results found for symbol: {}", focus)
} else {
filtered
}
}
}
@@ -0,0 +1,203 @@
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::PathBuf;
use crate::developer::analyze::types::{AnalysisResult, CallChain};
#[derive(Debug, Clone, Default)]
pub struct CallGraph {
callers: HashMap<String, Vec<(PathBuf, usize, String)>>,
callees: HashMap<String, Vec<(PathBuf, usize, String)>>,
pub definitions: HashMap<String, Vec<(PathBuf, usize)>>,
}
impl CallGraph {
pub fn new() -> Self {
Self::default()
}
pub fn build_from_results(results: &[(PathBuf, AnalysisResult)]) -> Self {
tracing::debug!("Building call graph from {} files", results.len());
let mut graph = Self::new();
for (file_path, result) in results {
// Record definitions
for func in &result.functions {
graph
.definitions
.entry(func.name.clone())
.or_default()
.push((file_path.clone(), func.line));
}
for class in &result.classes {
graph
.definitions
.entry(class.name.clone())
.or_default()
.push((file_path.clone(), class.line));
}
// Record call relationships
for call in &result.calls {
let caller = call
.caller_name
.clone()
.unwrap_or_else(|| "<module>".to_string());
// Add to callers map (who calls this function)
graph
.callers
.entry(call.callee_name.clone())
.or_default()
.push((file_path.clone(), call.line, caller.clone()));
// Add to callees map (what this function calls)
if caller != "<module>" {
graph.callees.entry(caller).or_default().push((
file_path.clone(),
call.line,
call.callee_name.clone(),
));
}
}
}
tracing::trace!(
"Graph built: {} definitions, {} caller entries, {} callee entries",
graph.definitions.len(),
graph.callers.len(),
graph.callees.len()
);
graph
}
pub fn find_incoming_chains(&self, symbol: &str, max_depth: u32) -> Vec<CallChain> {
tracing::trace!(
"Finding incoming chains for {} with depth {}",
symbol,
max_depth
);
if max_depth == 0 {
return vec![];
}
let mut chains = Vec::new();
let mut visited = HashSet::new();
let mut queue = VecDeque::new();
// Start with direct callers
if let Some(direct_callers) = self.callers.get(symbol) {
for (file, line, caller) in direct_callers {
let initial_path = vec![(file.clone(), *line, caller.clone(), symbol.to_string())];
if max_depth == 1 {
chains.push(CallChain { path: initial_path });
} else {
queue.push_back((caller.clone(), initial_path, 1));
}
}
}
// BFS to find deeper chains
while let Some((current_symbol, path, depth)) = queue.pop_front() {
if depth >= max_depth {
chains.push(CallChain { path });
continue;
}
// Avoid cycles
if visited.contains(&current_symbol) {
chains.push(CallChain { path }); // Still record the path we found
continue;
}
visited.insert(current_symbol.clone());
// Find who calls the current symbol
if let Some(callers) = self.callers.get(&current_symbol) {
for (file, line, caller) in callers {
let mut new_path =
vec![(file.clone(), *line, caller.clone(), current_symbol.clone())];
new_path.extend(path.clone());
if depth + 1 >= max_depth {
chains.push(CallChain { path: new_path });
} else {
queue.push_back((caller.clone(), new_path, depth + 1));
}
}
} else {
// No more callers, this is a chain end
chains.push(CallChain { path });
}
}
tracing::trace!("Found {} incoming chains", chains.len());
chains
}
pub fn find_outgoing_chains(&self, symbol: &str, max_depth: u32) -> Vec<CallChain> {
tracing::trace!(
"Finding outgoing chains for {} with depth {}",
symbol,
max_depth
);
if max_depth == 0 {
return vec![];
}
let mut chains = Vec::new();
let mut visited = HashSet::new();
let mut queue = VecDeque::new();
// Start with what this symbol calls
if let Some(direct_callees) = self.callees.get(symbol) {
for (file, line, callee) in direct_callees {
let initial_path = vec![(file.clone(), *line, symbol.to_string(), callee.clone())];
if max_depth == 1 {
chains.push(CallChain { path: initial_path });
} else {
queue.push_back((callee.clone(), initial_path, 1));
}
}
}
// BFS to find deeper chains
while let Some((current_symbol, path, depth)) = queue.pop_front() {
if depth >= max_depth {
chains.push(CallChain { path });
continue;
}
// Avoid cycles
if visited.contains(&current_symbol) {
chains.push(CallChain { path });
continue;
}
visited.insert(current_symbol.clone());
// Find what the current symbol calls
if let Some(callees) = self.callees.get(&current_symbol) {
for (file, line, callee) in callees {
let mut new_path = path.clone();
new_path.push((file.clone(), *line, current_symbol.clone(), callee.clone()));
if depth + 1 >= max_depth {
chains.push(CallChain { path: new_path });
} else {
queue.push_back((callee.clone(), new_path, depth + 1));
}
}
} else {
// No more callees, this is a chain end
chains.push(CallChain { path });
}
}
tracing::trace!("Found {} outgoing chains", chains.len());
chains
}
}
@@ -0,0 +1,19 @@
/// Tree-sitter query for extracting Go code elements
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))
(import_declaration) @import
"#;
/// Tree-sitter query for extracting Go function calls
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))
"#;
@@ -0,0 +1,17 @@
/// Tree-sitter query for extracting Java code elements
pub const ELEMENT_QUERY: &str = r#"
(method_declaration name: (identifier) @func)
(class_declaration name: (identifier) @class)
(import_declaration) @import
"#;
/// Tree-sitter query for extracting Java function calls
pub const CALL_QUERY: &str = r#"
; Method invocations
(method_invocation
name: (identifier) @method.call)
; Constructor calls
(object_creation_expression
type: (type_identifier) @constructor.call)
"#;
@@ -0,0 +1,22 @@
/// Tree-sitter query for extracting JavaScript/TypeScript code elements
pub const ELEMENT_QUERY: &str = r#"
(function_declaration name: (identifier) @func)
(class_declaration name: (identifier) @class)
(import_statement) @import
"#;
/// Tree-sitter query for extracting JavaScript/TypeScript function calls
pub const CALL_QUERY: &str = r#"
; Function calls
(call_expression
function: (identifier) @function.call)
; Method calls
(call_expression
function: (member_expression
property: (property_identifier) @method.call))
; Constructor calls
(new_expression
constructor: (identifier) @constructor.call)
"#;
@@ -0,0 +1,27 @@
/// Tree-sitter query for extracting Kotlin code elements
pub const ELEMENT_QUERY: &str = r#"
; Functions
(function_declaration (simple_identifier) @func)
; Classes
(class_declaration (type_identifier) @class)
; Objects (singleton classes)
(object_declaration (type_identifier) @class)
; Imports
(import_header) @import
"#;
/// Tree-sitter query for extracting Kotlin function calls
pub const CALL_QUERY: &str = r#"
; Simple function calls
(call_expression
(simple_identifier) @function.call)
; Method calls with navigation (obj.method())
(call_expression
(navigation_expression
(navigation_suffix
(simple_identifier) @method.call)))
"#;
@@ -0,0 +1,35 @@
pub mod go;
pub mod java;
pub mod javascript;
pub mod kotlin;
pub mod python;
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,
_ => "",
}
}
/// Get the tree-sitter query for extracting function calls for a language
pub fn get_call_query(language: &str) -> &'static str {
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,
_ => "",
}
}
@@ -0,0 +1,25 @@
/// Tree-sitter query for extracting Python code elements
pub const ELEMENT_QUERY: &str = r#"
(function_definition name: (identifier) @func)
(class_definition name: (identifier) @class)
(import_statement) @import
(import_from_statement) @import
(aliased_import) @import
(assignment left: (identifier) @class)
"#;
/// Tree-sitter query for extracting Python function calls
pub const CALL_QUERY: &str = r#"
; Function calls
(call
function: (identifier) @function.call)
; Method calls
(call
function: (attribute
attribute: (identifier) @method.call))
; Decorator applications
(decorator (identifier) @function.call)
(decorator (attribute attribute: (identifier) @method.call))
"#;
@@ -0,0 +1,28 @@
/// Tree-sitter query for extracting Rust code elements
pub const ELEMENT_QUERY: &str = r#"
(function_item name: (identifier) @func)
(impl_item type: (type_identifier) @class)
(struct_item name: (type_identifier) @struct)
(use_declaration) @import
"#;
/// Tree-sitter query for extracting Rust function calls
pub const CALL_QUERY: &str = r#"
; Function calls
(call_expression
function: (identifier) @function.call)
; Method calls
(call_expression
function: (field_expression
field: (field_identifier) @method.call))
; Associated function calls (e.g., Type::method())
; Now captures the full Type::method instead of just method
(call_expression
function: (scoped_identifier) @scoped.call)
; Macro calls (often contain function-like behavior)
(macro_invocation
macro: (identifier) @macro.call)
"#;
@@ -0,0 +1,56 @@
/// Tree-sitter query for extracting Swift code elements
pub const ELEMENT_QUERY: &str = r#"
; Functions
(function_declaration name: (simple_identifier) @func)
; Classes
(class_declaration name: (type_identifier) @class)
; Protocols (interfaces)
(protocol_declaration name: (type_identifier) @class)
; Imports
(import_declaration) @import
"#;
/// Tree-sitter query for extracting Swift function calls
pub const CALL_QUERY: &str = r#"
; Function calls
(call_expression
(simple_identifier) @function.call)
; Method calls with navigation
(call_expression
(navigation_expression
target: (_)
suffix: (navigation_suffix
suffix: (simple_identifier) @method.call)))
; Constructor calls
(constructor_expression
(user_type
(type_identifier) @constructor.call))
; Async function calls
(await_expression
(call_expression
(simple_identifier) @function.call))
; Async method calls
(await_expression
(call_expression
(navigation_expression
suffix: (navigation_suffix
suffix: (simple_identifier) @method.call))))
; Static method calls (Type.method())
(call_expression
(navigation_expression
target: (user_type)
suffix: (navigation_suffix
suffix: (simple_identifier) @scoped.call)))
; Closure calls
(call_expression
(navigation_expression) @function.call)
"#;
@@ -0,0 +1,342 @@
pub mod cache;
pub mod formatter;
pub mod graph;
pub mod languages;
pub mod parser;
pub mod traversal;
pub mod types;
#[cfg(test)]
mod tests;
use ignore::gitignore::Gitignore;
use rmcp::model::{CallToolResult, ErrorCode, ErrorData};
use std::path::{Path, PathBuf};
use crate::developer::lang;
use self::cache::AnalysisCache;
use self::formatter::Formatter;
use self::graph::CallGraph;
use self::parser::{ElementExtractor, ParserManager};
use self::traversal::FileTraverser;
use self::types::{AnalysisMode, AnalysisResult, AnalyzeParams, FocusedAnalysisData};
/// Helper to safely lock a mutex with poison recovery
/// The recovery function is called on the mutex contents if the lock was poisoned
pub(crate) fn lock_or_recover<T, F>(
mutex: &std::sync::Mutex<T>,
recovery: F,
) -> std::sync::MutexGuard<'_, T>
where
F: FnOnce(&mut T),
{
mutex.lock().unwrap_or_else(|poisoned| {
let mut guard = poisoned.into_inner();
recovery(&mut guard);
tracing::warn!("Recovered from poisoned lock");
guard
})
}
/// Code analyzer with caching and tree-sitter parsing
#[derive(Clone)]
pub struct CodeAnalyzer {
parser_manager: ParserManager,
cache: AnalysisCache,
}
impl Default for CodeAnalyzer {
fn default() -> Self {
Self::new()
}
}
impl CodeAnalyzer {
/// Create a new code analyzer
pub fn new() -> Self {
tracing::debug!("Initializing CodeAnalyzer");
Self {
parser_manager: ParserManager::new(),
cache: AnalysisCache::new(100),
}
}
/// Main analyze entry point
pub fn analyze(
&self,
params: AnalyzeParams,
path: PathBuf,
ignore_patterns: &Gitignore,
) -> Result<CallToolResult, ErrorData> {
tracing::info!("Starting analysis of {:?} with params {:?}", path, params);
let traverser = FileTraverser::new(ignore_patterns);
traverser.validate_path(&path)?;
let mode = self.determine_mode(&params, &path);
tracing::debug!("Using analysis mode: {:?}", mode);
let mut output = match mode {
AnalysisMode::Focused => self.analyze_focused(&path, &params, &traverser)?,
AnalysisMode::Semantic => {
if path.is_file() {
let result = self.analyze_file(&path, &mode)?;
Formatter::format_analysis_result(&path, &result, &mode)
} else {
// Semantic mode on directory - analyze all files
self.analyze_directory(&path, &params, &traverser, &mode)?
}
}
AnalysisMode::Structure => {
if path.is_file() {
let result = self.analyze_file(&path, &mode)?;
Formatter::format_analysis_result(&path, &result, &mode)
} else {
self.analyze_directory(&path, &params, &traverser, &mode)?
}
}
};
// If focus is specified with non-focused mode, filter results
if let Some(focus) = &params.focus {
if mode != AnalysisMode::Focused {
output = Formatter::filter_by_focus(&output, focus);
}
}
// 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();
if line_count > OUTPUT_LIMIT {
let warning = format!(
"LARGE OUTPUT WARNING\n\n\
The analysis would produce {} lines (~{} tokens).\n\
This exceeds the {} line limit.\n\n\
To proceed anyway, add 'force: true' to your parameters:\n\
analyze path=\"{}\" force=true{}\n\n\
Or narrow your scope by:\n\
• Analyzing a subdirectory instead\n\
• Using focus mode: focus=\"symbol_name\"\n\
• Reducing depth: max_depth=1",
line_count,
line_count * 10, // rough token estimate
OUTPUT_LIMIT,
path.display(),
if let Some(f) = &params.focus {
format!(" focus=\"{}\"", f)
} else {
String::new()
}
);
return Ok(CallToolResult::success(vec![rmcp::model::Content::text(
warning,
)]));
}
}
tracing::info!("Analysis complete");
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 {
AnalysisMode::Structure
}
}
/// Analyze a single file
fn analyze_file(&self, path: &Path, mode: &AnalysisMode) -> Result<AnalysisResult, ErrorData> {
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(
ErrorCode::INTERNAL_ERROR,
format!("Failed to get metadata for '{}': {}", path.display(), e),
None,
)
})?;
let modified = metadata.modified().map_err(|e| {
ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!(
"Failed to get modification time for '{}': {}",
path.display(),
e
),
None,
)
})?;
// 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"
);
if !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)?;
// 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,
params: &AnalyzeParams,
traverser: &FileTraverser<'_>,
mode: &AnalysisMode,
) -> Result<String, ErrorData> {
tracing::debug!("Analyzing directory {:?} in {:?} mode", path, mode);
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)
})?;
// Format based on mode
Ok(Formatter::format_directory_structure(
path,
&results,
params.max_depth,
))
}
/// Focused mode analysis - track a symbol across files
fn analyze_focused(
&self,
path: &Path,
params: &AnalyzeParams,
traverser: &FileTraverser<'_>,
) -> Result<String, ErrorData> {
// Focused mode requires focus parameter
let focus_symbol = params.focus.as_ref().ok_or_else(|| {
ErrorData::new(
ErrorCode::INVALID_PARAMS,
"Focused mode requires 'focus' parameter to specify the symbol to track"
.to_string(),
None,
)
})?;
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 {
traverser.collect_files_for_focused(path, params.max_depth)?
};
tracing::debug!(
"Analyzing {} files for focused analysis",
files_to_analyze.len()
);
// Step 2: Analyze all files and collect results using parallel processing
use rayon::prelude::*;
let all_results: Result<Vec<_>, _> = files_to_analyze
.par_iter()
.map(|file_path| {
self.analyze_file(file_path, &AnalysisMode::Semantic)
.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 {
vec![]
};
let outgoing_chains = if params.follow_depth > 0 {
graph.find_outgoing_chains(focus_symbol, params.follow_depth)
} else {
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,
files_analyzed: &files_to_analyze,
definitions: &definitions,
incoming_chains: &incoming_chains,
outgoing_chains: &outgoing_chains,
};
Ok(Formatter::format_focused_output(&focus_data))
}
}
@@ -0,0 +1,422 @@
use rmcp::model::{ErrorCode, ErrorData};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tree_sitter::{Language, Parser, Tree};
use super::lock_or_recover;
use crate::developer::analyze::types::{
AnalysisResult, CallInfo, ClassInfo, ElementQueryResult, FunctionInfo, ReferenceInfo,
ReferenceType,
};
/// Manages tree-sitter parsers for different languages
#[derive(Clone)]
pub struct ParserManager {
parsers: Arc<Mutex<HashMap<String, Arc<Mutex<Parser>>>>>,
}
impl ParserManager {
pub fn new() -> Self {
tracing::debug!("Initializing ParserManager");
Self {
parsers: Arc::new(Mutex::new(HashMap::new())),
}
}
/// Get or create a parser for the specified language
pub fn get_or_create_parser(&self, language: &str) -> Result<Arc<Mutex<Parser>>, ErrorData> {
let mut cache = lock_or_recover(&self.parsers, |c| c.clear());
if let Some(parser) = cache.get(language) {
tracing::trace!("Reusing cached parser for {}", language);
return Ok(Arc::clone(parser));
}
tracing::debug!("Creating new parser for {}", language);
let mut parser = Parser::new();
let language_config: Language = match language {
"python" => tree_sitter_python::language(),
"rust" => tree_sitter_rust::language(),
"javascript" | "typescript" => tree_sitter_javascript::language(),
"go" => tree_sitter_go::language(),
"java" => tree_sitter_java::language(),
"kotlin" => tree_sitter_kotlin::language(),
"swift" => devgen_tree_sitter_swift::language(),
_ => {
tracing::warn!("Unsupported language: {}", language);
return Err(ErrorData::new(
ErrorCode::INVALID_PARAMS,
format!("Unsupported language: {}", language),
None,
));
}
};
parser.set_language(&language_config).map_err(|e| {
tracing::error!("Failed to set language for {}: {}", language, e);
ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to set language: {}", e),
None,
)
})?;
let parser_arc = Arc::new(Mutex::new(parser));
cache.insert(language.to_string(), Arc::clone(&parser_arc));
Ok(parser_arc)
}
/// Parse source code and return the syntax tree
pub fn parse(&self, content: &str, language: &str) -> Result<Tree, ErrorData> {
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(|| {
tracing::error!("Failed to parse content as {}", language);
ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to parse file as {}", language),
None,
)
})
}
}
impl Default for ParserManager {
fn default() -> Self {
Self::new()
}
}
/// Extract code elements from a parsed tree
pub struct ElementExtractor;
impl ElementExtractor {
/// Extract code elements with optional semantic analysis
pub fn extract_with_depth(
tree: &Tree,
source: &str,
language: &str,
depth: &str,
) -> Result<AnalysisResult, ErrorData> {
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(),
});
}
}
Ok(result)
}
/// Extract basic code elements (functions, classes, imports)
pub fn extract_elements(
tree: &Tree,
source: &str,
language: &str,
) -> Result<AnalysisResult, ErrorData> {
// Get language-specific query
let query_str = Self::get_element_query(language);
if query_str.is_empty() {
return Ok(Self::empty_analysis_result());
}
// 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 {
function_count: functions.len(),
class_count: classes.len(),
import_count: imports.len(),
functions,
classes,
imports,
calls: vec![],
references: vec![],
line_count: 0,
main_line,
})
}
/// 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,
query_str: &str,
) -> Result<ElementQueryResult, ErrorData> {
use tree_sitter::{Query, QueryCursor};
let mut functions = Vec::new();
let mut classes = Vec::new();
let mut imports = Vec::new();
let query = Query::new(&tree.language(), query_str).map_err(|e| {
tracing::error!("Failed to create query: {}", e);
ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to create 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 line = source[..node.start_byte()].lines().count() + 1;
match query.capture_names()[capture.index as usize] {
"func" => {
functions.push(FunctionInfo {
name: text.to_string(),
line,
params: vec![], // Simplified for now
});
}
"class" | "struct" => {
classes.push(ClassInfo {
name: text.to_string(),
line,
methods: vec![], // Simplified for now
});
}
"import" => {
imports.push(text.to_string());
}
_ => {}
}
}
}
tracing::trace!(
"Extracted {} functions, {} classes, {} imports",
functions.len(),
classes.len(),
imports.len()
);
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<Vec<CallInfo>, ErrorData> {
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 query = Query::new(&tree.language(), query_str).map_err(|e| {
tracing::error!("Failed to create call query: {}", e);
ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to create call 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();
// Get the line of code for context
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();
// 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" => {
calls.push(CallInfo {
caller_name,
callee_name: text.to_string(),
line: start_pos.row + 1,
column: start_pos.column,
context,
});
}
_ => {}
}
}
}
tracing::trace!("Extracted {} calls", calls.len());
Ok(calls)
}
/// Find which function contains a given node
fn find_containing_function(
node: &tree_sitter::Node,
source: &str,
language: &str,
) -> Option<String> {
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()]));
}
}
}
}
current = parent;
}
None // No containing function found (module-level call)
}
/// Create an empty analysis result
fn empty_analysis_result() -> AnalysisResult {
AnalysisResult {
functions: vec![],
classes: vec![],
imports: vec![],
calls: vec![],
references: vec![],
function_count: 0,
class_count: 0,
line_count: 0,
import_count: 0,
main_line: None,
}
}
}
@@ -0,0 +1,94 @@
// Tests for the cache module
use crate::developer::analyze::cache::AnalysisCache;
use crate::developer::analyze::types::{AnalysisResult, FunctionInfo};
use std::path::PathBuf;
use std::time::SystemTime;
fn create_test_result() -> AnalysisResult {
AnalysisResult {
functions: vec![FunctionInfo {
name: "test_func".to_string(),
line: 1,
params: vec![],
}],
classes: vec![],
imports: vec![],
calls: vec![],
references: vec![],
function_count: 1,
class_count: 0,
line_count: 10,
import_count: 0,
main_line: None,
}
}
#[test]
fn test_cache_hit_miss() {
let cache = AnalysisCache::new(10);
let path = PathBuf::from("test.rs");
let time = SystemTime::now();
let result = create_test_result();
// Initial miss
assert!(cache.get(&path, time).is_none());
// Store and hit
cache.put(path.clone(), time, result.clone());
assert!(cache.get(&path, time).is_some());
// Different time = miss
let later = time + std::time::Duration::from_secs(1);
assert!(cache.get(&path, later).is_none());
}
#[test]
fn test_cache_eviction() {
let cache = AnalysisCache::new(2);
let result = create_test_result();
let time = SystemTime::now();
// Fill cache
cache.put(PathBuf::from("file1.rs"), time, result.clone());
cache.put(PathBuf::from("file2.rs"), time, result.clone());
assert_eq!(cache.len(), 2);
// Add third item, should evict first
cache.put(PathBuf::from("file3.rs"), time, result.clone());
assert_eq!(cache.len(), 2);
// First item should be evicted
assert!(cache.get(&PathBuf::from("file1.rs"), time).is_none());
assert!(cache.get(&PathBuf::from("file2.rs"), time).is_some());
assert!(cache.get(&PathBuf::from("file3.rs"), time).is_some());
}
#[test]
fn test_cache_clear() {
let cache = AnalysisCache::new(10);
let path = PathBuf::from("test.rs");
let time = SystemTime::now();
let result = create_test_result();
cache.put(path.clone(), time, result);
assert!(!cache.is_empty());
cache.clear();
assert!(cache.is_empty());
assert!(cache.get(&path, time).is_none());
}
#[test]
fn test_cache_default() {
let cache = AnalysisCache::default();
assert!(cache.is_empty());
// Default cache should work normally
let path = PathBuf::from("test.rs");
let time = SystemTime::now();
let result = create_test_result();
cache.put(path.clone(), time, result);
assert!(cache.get(&path, time).is_some());
}
@@ -0,0 +1,87 @@
// Shared test fixtures and utilities
use crate::developer::analyze::types::{AnalysisResult, CallInfo, ClassInfo, FunctionInfo};
use ignore::gitignore::Gitignore;
/// Create a test AnalysisResult with sample data
pub fn create_test_result() -> AnalysisResult {
AnalysisResult {
functions: vec![
FunctionInfo {
name: "main".to_string(),
line: 10,
params: vec![],
},
FunctionInfo {
name: "helper".to_string(),
line: 20,
params: vec![],
},
],
classes: vec![ClassInfo {
name: "TestClass".to_string(),
line: 5,
methods: vec![],
}],
imports: vec!["use std::fs".to_string()],
calls: vec![],
references: vec![],
function_count: 2,
class_count: 1,
line_count: 100,
import_count: 1,
main_line: Some(10),
}
}
/// Create a test result with specific functions and call relationships
pub fn create_test_result_with_calls(
functions: Vec<&str>,
calls: Vec<(&str, &str)>,
) -> AnalysisResult {
AnalysisResult {
functions: functions
.into_iter()
.map(|name| FunctionInfo {
name: name.to_string(),
line: 1,
params: vec![],
})
.collect(),
classes: vec![],
imports: vec![],
calls: calls
.into_iter()
.map(|(caller, callee)| CallInfo {
caller_name: Some(caller.to_string()),
callee_name: callee.to_string(),
line: 1,
column: 0,
context: String::new(),
})
.collect(),
references: vec![],
function_count: 0,
class_count: 0,
line_count: 0,
import_count: 0,
main_line: None,
}
}
/// Create a simple test gitignore
pub fn create_test_gitignore() -> Gitignore {
let mut builder = ignore::gitignore::GitignoreBuilder::new(".");
builder.add_line(None, "*.log").unwrap();
builder.add_line(None, "node_modules/").unwrap();
builder.build().unwrap()
}
/// Create a test gitignore with custom base path
#[allow(dead_code)]
pub fn create_test_gitignore_at(base_path: &std::path::Path) -> Gitignore {
let mut builder = ignore::gitignore::GitignoreBuilder::new(base_path);
builder.add_line(None, "*.log").unwrap();
builder.add_line(None, "node_modules/").unwrap();
builder.build().unwrap()
}
@@ -0,0 +1,151 @@
// Tests for the formatter module
use crate::developer::analyze::formatter::Formatter;
use crate::developer::analyze::tests::fixtures::create_test_result;
use crate::developer::analyze::types::{AnalysisMode, CallChain, EntryType, FocusedAnalysisData};
use std::path::{Path, PathBuf};
#[test]
fn test_format_structure_overview() {
let result = create_test_result();
let output = Formatter::format_structure_overview(Path::new("test.rs"), &result);
assert!(output.contains("[100L, 2F, 1C]"));
assert!(output.contains("main:10"));
}
#[test]
fn test_format_semantic_result() {
let result = create_test_result();
let output = Formatter::format_semantic_result(Path::new("test.rs"), &result);
assert!(output.contains("FILE: test.rs"));
assert!(output.contains("C: TestClass:5"));
assert!(output.contains("F: main:10 helper:20"));
assert!(output.contains("I: use std::fs"));
}
#[test]
fn test_filter_by_focus() {
// The filter_by_focus function includes the whole section when it finds a match
// This is the expected behavior - if a symbol is found in a file, show the whole file section
let output = "## test.rs\nfunction main at line 10\nfunction helper at line 20\n## other.rs\nfunction foo at line 5\n";
let filtered = Formatter::filter_by_focus(output, "main");
assert!(filtered.contains("main"));
// When we find 'main' in test.rs, we include the whole test.rs section including 'helper'
assert!(filtered.contains("helper"));
assert!(!filtered.contains("foo")); // But we don't include other.rs
}
#[test]
fn test_format_analysis_result_modes() {
let result = create_test_result();
let path = Path::new("test.rs");
// Test structure mode
let output = Formatter::format_analysis_result(path, &result, &AnalysisMode::Structure);
assert!(output.contains("[100L, 2F, 1C]"));
// Test semantic mode
let output = Formatter::format_analysis_result(path, &result, &AnalysisMode::Semantic);
assert!(output.contains("FILE: test.rs"));
assert!(output.contains("C: TestClass:5"));
// Test focused mode (should return empty string with warning)
let output = Formatter::format_analysis_result(path, &result, &AnalysisMode::Focused);
assert_eq!(output, "");
}
#[test]
fn test_format_directory_structure() {
let base_path = Path::new("/test");
let result1 = create_test_result();
let mut result2 = create_test_result();
result2.line_count = 200;
let results = vec![
(PathBuf::from("/test/file1.rs"), EntryType::File(result1)),
(PathBuf::from("/test/dir"), EntryType::Directory),
(
PathBuf::from("/test/dir/file2.rs"),
EntryType::File(result2),
),
];
let output = Formatter::format_directory_structure(base_path, &results, 2);
// Check summary
assert!(output.contains("SUMMARY:"));
assert!(output.contains("2 files, 300L, 4F, 2C"));
assert!(output.contains("Languages: rust (100%)"));
// Check file entries
assert!(output.contains("file1.rs [100L, 2F, 1C]"));
assert!(output.contains("file2.rs [200L, 2F, 1C]"));
}
#[test]
fn test_format_focused_output() {
let focus_data = FocusedAnalysisData {
focus_symbol: "test_func",
definitions: &[(PathBuf::from("test.rs"), 10)],
incoming_chains: &[CallChain {
path: vec![(
PathBuf::from("test.rs"),
20,
"caller".to_string(),
"test_func".to_string(),
)],
}],
outgoing_chains: &[CallChain {
path: vec![(
PathBuf::from("test.rs"),
30,
"test_func".to_string(),
"callee".to_string(),
)],
}],
files_analyzed: &[PathBuf::from("test.rs")],
follow_depth: 2,
};
let output = Formatter::format_focused_output(&focus_data);
assert!(output.contains("FOCUSED ANALYSIS: test_func"));
assert!(output.contains("DEFINITIONS:"));
assert!(output.contains("INCOMING CALL CHAINS"));
assert!(output.contains("OUTGOING CALL CHAINS"));
assert!(output.contains("STATISTICS:"));
}
#[test]
fn test_format_focused_output_empty() {
let focus_data = FocusedAnalysisData {
focus_symbol: "nonexistent",
definitions: &[],
incoming_chains: &[],
outgoing_chains: &[],
files_analyzed: &[PathBuf::from("test.rs")],
follow_depth: 2,
};
let output = Formatter::format_focused_output(&focus_data);
assert!(output.contains("Symbol 'nonexistent' not found"));
}
#[test]
fn test_format_results_wrapper() {
let text = "Test output";
let contents = Formatter::format_results(text.to_string());
assert_eq!(contents.len(), 2);
// Check that both assistant and user content are created
let assistant_content = contents[0].as_text().unwrap();
assert_eq!(assistant_content.text, "Test output");
let user_content = contents[1].as_text().unwrap();
assert_eq!(user_content.text, "Test output");
}
@@ -0,0 +1,116 @@
// Tests for the graph module
use crate::developer::analyze::graph::CallGraph;
use crate::developer::analyze::tests::fixtures::create_test_result_with_calls;
use std::path::PathBuf;
#[test]
fn test_simple_call_chain() {
let results = vec![(
PathBuf::from("test.rs"),
create_test_result_with_calls(vec!["a", "b", "c"], vec![("a", "b"), ("b", "c")]),
)];
let graph = CallGraph::build_from_results(&results);
// Test incoming chains for 'c'
let chains = graph.find_incoming_chains("c", 2);
assert_eq!(chains.len(), 1);
assert_eq!(chains[0].path.len(), 2); // b->c, a->b
// Test outgoing chains for 'a'
let chains = graph.find_outgoing_chains("a", 2);
assert_eq!(chains.len(), 1);
assert_eq!(chains[0].path.len(), 2); // a->b, b->c
}
#[test]
fn test_circular_dependency() {
let results = vec![(
PathBuf::from("test.rs"),
create_test_result_with_calls(vec!["a", "b"], vec![("a", "b"), ("b", "a")]),
)];
let graph = CallGraph::build_from_results(&results);
// Should handle cycles without infinite loop
let chains = graph.find_incoming_chains("a", 3);
assert!(!chains.is_empty());
}
#[test]
fn test_empty_graph() {
let graph = CallGraph::new();
// Should return empty results for non-existent symbols
let chains = graph.find_incoming_chains("nonexistent", 2);
assert!(chains.is_empty());
let chains = graph.find_outgoing_chains("nonexistent", 2);
assert!(chains.is_empty());
}
#[test]
fn test_max_depth_zero() {
let results = vec![(
PathBuf::from("test.rs"),
create_test_result_with_calls(vec!["a", "b"], vec![("a", "b")]),
)];
let graph = CallGraph::build_from_results(&results);
// max_depth of 0 should return empty results
let chains = graph.find_incoming_chains("b", 0);
assert!(chains.is_empty());
let chains = graph.find_outgoing_chains("a", 0);
assert!(chains.is_empty());
}
#[test]
fn test_multiple_callers() {
let results = vec![(
PathBuf::from("test.rs"),
create_test_result_with_calls(
vec!["a", "b", "c", "target"],
vec![("a", "target"), ("b", "target"), ("c", "target")],
),
)];
let graph = CallGraph::build_from_results(&results);
// Should find all three callers
let chains = graph.find_incoming_chains("target", 1);
assert_eq!(chains.len(), 3);
// Each chain should have exactly one call
for chain in chains {
assert_eq!(chain.path.len(), 1);
}
}
#[test]
fn test_deep_chain() {
let results = vec![(
PathBuf::from("test.rs"),
create_test_result_with_calls(
vec!["a", "b", "c", "d", "e"],
vec![("a", "b"), ("b", "c"), ("c", "d"), ("d", "e")],
),
)];
let graph = CallGraph::build_from_results(&results);
// Test various depths
let chains = graph.find_incoming_chains("e", 1);
assert_eq!(chains.len(), 1);
assert_eq!(chains[0].path.len(), 1); // Just d->e
let chains = graph.find_incoming_chains("e", 2);
assert_eq!(chains.len(), 1);
assert_eq!(chains[0].path.len(), 2); // c->d, d->e
let chains = graph.find_incoming_chains("e", 4);
assert_eq!(chains.len(), 1);
assert_eq!(chains[0].path.len(), 4); // Full chain a->b->c->d->e
}
@@ -0,0 +1,236 @@
// Integration tests for the analyze module
use crate::developer::analyze::tests::fixtures::create_test_gitignore;
use crate::developer::analyze::{types::AnalyzeParams, CodeAnalyzer};
use std::fs;
use tempfile::TempDir;
#[test]
fn test_analyze_python_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.py");
fs::write(&file_path, "def main():\n pass").unwrap();
let analyzer = CodeAnalyzer::new();
let params = AnalyzeParams {
path: file_path.to_string_lossy().to_string(),
focus: None,
follow_depth: 2,
max_depth: 3,
force: false,
};
let ignore = create_test_gitignore();
let result = analyzer.analyze(params, file_path, &ignore);
assert!(result.is_ok());
let result = result.unwrap();
// Check that we got content back
assert!(!result.content.is_empty());
}
#[test]
fn test_analyze_directory() {
let temp_dir = TempDir::new().unwrap();
let dir_path = temp_dir.path();
// Create test files
fs::write(dir_path.join("test1.rs"), "fn main() {}").unwrap();
fs::write(dir_path.join("test2.py"), "def test(): pass").unwrap();
let analyzer = CodeAnalyzer::new();
let params = AnalyzeParams {
path: dir_path.to_string_lossy().to_string(),
focus: None,
follow_depth: 2,
max_depth: 3,
force: false,
};
let ignore = create_test_gitignore();
let result = analyzer.analyze(params, dir_path.to_path_buf(), &ignore);
assert!(result.is_ok());
let result = result.unwrap();
// Check that we got content back
assert!(!result.content.is_empty());
// Extract text content and verify it contains expected information
if let Some(text_content) = result.content[0].as_text() {
assert!(text_content.text.contains("SUMMARY:"));
assert!(text_content.text.contains("test1.rs"));
assert!(text_content.text.contains("test2.py"));
}
}
#[test]
fn test_focused_analysis() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.py");
fs::write(
&file_path,
"def main():\n helper()\n\ndef helper():\n pass",
)
.unwrap();
let analyzer = CodeAnalyzer::new();
let params = AnalyzeParams {
path: file_path.to_string_lossy().to_string(),
focus: Some("helper".to_string()),
follow_depth: 1,
max_depth: 3,
force: false,
};
let ignore = create_test_gitignore();
let result = analyzer.analyze(params, file_path, &ignore);
assert!(result.is_ok());
let result = result.unwrap();
// Check that focused analysis output is generated
if let Some(text_content) = result.content[0].as_text() {
assert!(text_content.text.contains("FOCUSED ANALYSIS: helper"));
assert!(text_content.text.contains("DEFINITIONS:"));
}
}
#[test]
fn test_analyze_with_cache() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.rs");
fs::write(&file_path, "fn main() {\n println!(\"Hello\");\n}").unwrap();
let analyzer = CodeAnalyzer::new();
let params = AnalyzeParams {
path: file_path.to_string_lossy().to_string(),
focus: None,
follow_depth: 2,
max_depth: 3,
force: false,
};
let ignore = create_test_gitignore();
// First analysis - should cache
let result1 = analyzer.analyze(params.clone(), file_path.clone(), &ignore);
assert!(result1.is_ok());
// Second analysis - should use cache
let result2 = analyzer.analyze(params, file_path, &ignore);
assert!(result2.is_ok());
// Results should be identical
let content1 = result1.unwrap().content[0].as_text().unwrap().text.clone();
let content2 = result2.unwrap().content[0].as_text().unwrap().text.clone();
assert_eq!(content1, content2);
}
#[test]
fn test_analyze_unsupported_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
fs::write(&file_path, "This is not code").unwrap();
let analyzer = CodeAnalyzer::new();
let params = AnalyzeParams {
path: file_path.to_string_lossy().to_string(),
focus: None,
follow_depth: 2,
max_depth: 3,
force: false,
};
let ignore = create_test_gitignore();
let result = analyzer.analyze(params, file_path, &ignore);
// Should succeed but return minimal information
assert!(result.is_ok());
}
#[test]
fn test_analyze_nonexistent_path() {
let analyzer = CodeAnalyzer::new();
let params = AnalyzeParams {
path: "/nonexistent/path".to_string(),
focus: None,
follow_depth: 2,
max_depth: 3,
force: false,
};
let ignore = create_test_gitignore();
let result = analyzer.analyze(params, "/nonexistent/path".into(), &ignore);
// Should return an error
assert!(result.is_err());
}
#[test]
fn test_focused_without_symbol() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.py");
fs::write(&file_path, "def main(): pass").unwrap();
let analyzer = CodeAnalyzer::new();
// This should trigger focused mode due to having focus parameter
let params = AnalyzeParams {
path: file_path.to_string_lossy().to_string(),
focus: Some("nonexistent_symbol".to_string()),
follow_depth: 1,
max_depth: 3,
force: false,
};
let ignore = create_test_gitignore();
let result = analyzer.analyze(params, file_path, &ignore);
assert!(result.is_ok());
let result = result.unwrap();
// Should indicate symbol not found
if let Some(text_content) = result.content[0].as_text() {
assert!(text_content
.text
.contains("Symbol 'nonexistent_symbol' not found"));
}
}
#[test]
fn test_nested_directory_analysis() {
let temp_dir = TempDir::new().unwrap();
let dir_path = temp_dir.path();
// Create nested structure
let src_dir = dir_path.join("src");
fs::create_dir(&src_dir).unwrap();
fs::write(src_dir.join("main.rs"), "fn main() {}").unwrap();
let lib_dir = src_dir.join("lib");
fs::create_dir(&lib_dir).unwrap();
fs::write(lib_dir.join("utils.rs"), "pub fn util() {}").unwrap();
let analyzer = CodeAnalyzer::new();
let params = AnalyzeParams {
path: dir_path.to_string_lossy().to_string(),
focus: None,
follow_depth: 2,
max_depth: 3, // Increase max_depth to ensure we reach nested files
force: false,
};
let ignore = create_test_gitignore();
let result = analyzer.analyze(params, dir_path.to_path_buf(), &ignore);
assert!(result.is_ok());
let result = result.unwrap();
if let Some(text_content) = result.content[0].as_text() {
assert!(text_content.text.contains("main.rs"));
// The directory structure analysis should show both files
assert!(text_content.text.contains("src"));
}
}
@@ -0,0 +1,137 @@
use super::fixtures::create_test_gitignore;
use crate::developer::analyze::{types::AnalyzeParams, CodeAnalyzer};
use std::fs;
use tempfile::TempDir;
#[test]
fn test_large_output_warning() {
let analyzer = CodeAnalyzer::new();
let gitignore = create_test_gitignore();
// Create a temp directory with many files to trigger the warning
let temp_dir = TempDir::new().unwrap();
// Create many Python files with lots of functions to ensure we exceed 1000 lines
// Each file generates about 1 line in structure mode, so we need 1000+ files
for i in 0..1100 {
let file_path = temp_dir.path().join(format!("file{}.py", i));
// Each file will have multiple functions to generate more output
let mut content = String::new();
for j in 0..10 {
content.push_str(&format!("def function_{}_{}():\n pass\n\n", i, j));
}
for j in 0..5 {
content.push_str(&format!(
"class Class_{}_{}:\n def method(self):\n pass\n\n",
i, j
));
}
fs::write(&file_path, content).unwrap();
}
let params = AnalyzeParams {
path: temp_dir.path().to_str().unwrap().to_string(),
focus: None,
follow_depth: 2,
max_depth: 3,
force: false, // Should trigger warning
};
let result = analyzer
.analyze(params, temp_dir.path().to_path_buf(), &gitignore)
.unwrap();
// Check that we got a warning, not the actual analysis
assert_eq!(result.content.len(), 1);
if let Some(text_content) = result.content[0].as_text() {
assert!(text_content.text.contains("LARGE OUTPUT WARNING"));
assert!(text_content.text.contains("force=true"));
assert!(text_content.text.contains("exceed"));
} else {
panic!("Expected text content");
}
}
#[test]
fn test_force_flag_bypasses_warning() {
let analyzer = CodeAnalyzer::new();
let gitignore = create_test_gitignore();
// Create a temp directory with many files
let temp_dir = TempDir::new().unwrap();
// Create many Python files with lots of functions to ensure we exceed 1000 lines
for i in 0..50 {
let file_path = temp_dir.path().join(format!("file{}.py", i));
// Each file will have multiple functions to generate more output
let mut content = String::new();
for j in 0..10 {
content.push_str(&format!("def function_{}_{}():\n pass\n\n", i, j));
}
for j in 0..5 {
content.push_str(&format!(
"class Class_{}_{}:\n def method(self):\n pass\n\n",
i, j
));
}
fs::write(&file_path, content).unwrap();
}
let params = AnalyzeParams {
path: temp_dir.path().to_str().unwrap().to_string(),
focus: None,
follow_depth: 2,
max_depth: 3,
force: true, // Should bypass warning
};
let result = analyzer
.analyze(params, temp_dir.path().to_path_buf(), &gitignore)
.unwrap();
// Check that we got the actual analysis, not a warning
if let Some(text_content) = result.content[0].as_text() {
assert!(!text_content.text.contains("LARGE OUTPUT WARNING"));
// Should contain actual file analysis
assert!(text_content.text.contains("file0.py"));
assert!(text_content.text.contains("file29.py"));
} else {
panic!("Expected text content");
}
}
#[test]
fn test_small_output_no_warning() {
let analyzer = CodeAnalyzer::new();
let gitignore = create_test_gitignore();
// Create a temp directory with just a few files
let temp_dir = TempDir::new().unwrap();
// Create only 2 Python files - should not trigger warning
for i in 0..2 {
let file_path = temp_dir.path().join(format!("file{}.py", i));
fs::write(&file_path, format!("def function_{}():\n pass\n", i)).unwrap();
}
let params = AnalyzeParams {
path: temp_dir.path().to_str().unwrap().to_string(),
focus: None,
follow_depth: 2,
max_depth: 3,
force: false, // Shouldn't matter for small output
};
let result = analyzer
.analyze(params, temp_dir.path().to_path_buf(), &gitignore)
.unwrap();
// Check that we got the actual analysis, not a warning
if let Some(text_content) = result.content[0].as_text() {
assert!(!text_content.text.contains("LARGE OUTPUT WARNING"));
assert!(text_content.text.contains("file0.py"));
assert!(text_content.text.contains("file1.py"));
} else {
panic!("Expected text content");
}
}
@@ -0,0 +1,10 @@
// Test modules for the analyze tool
pub mod cache_tests;
pub mod fixtures;
pub mod formatter_tests;
pub mod graph_tests;
pub mod integration_tests;
pub mod large_output_tests;
pub mod parser_tests;
pub mod traversal_tests;
@@ -0,0 +1,228 @@
// Tests for the parser module
use crate::developer::analyze::parser::{ElementExtractor, ParserManager};
use std::sync::Arc;
#[test]
fn test_parser_initialization() {
let manager = ParserManager::new();
assert!(manager.get_or_create_parser("python").is_ok());
assert!(manager.get_or_create_parser("rust").is_ok());
assert!(manager.get_or_create_parser("unknown").is_err());
}
#[test]
fn test_parser_caching() {
let manager = ParserManager::new();
// First call creates parser
let parser1 = manager.get_or_create_parser("python").unwrap();
// Second call should return cached parser
let parser2 = manager.get_or_create_parser("python").unwrap();
// They should be the same Arc
assert!(Arc::ptr_eq(&parser1, &parser2));
}
#[test]
fn test_parse_python() {
let manager = ParserManager::new();
let content = "def hello():\n pass";
let tree = manager.parse(content, "python").unwrap();
assert!(tree.root_node().child_count() > 0);
}
#[test]
fn test_parse_rust() {
let manager = ParserManager::new();
let content = "fn main() {\n println!(\"Hello\");\n}";
let tree = manager.parse(content, "rust").unwrap();
assert!(tree.root_node().child_count() > 0);
}
#[test]
fn test_parse_javascript() {
let manager = ParserManager::new();
let content = "function hello() {\n console.log('Hello');\n}";
let tree = manager.parse(content, "javascript").unwrap();
assert!(tree.root_node().child_count() > 0);
}
#[test]
fn test_extract_python_elements() {
let manager = ParserManager::new();
let content = r#"
import os
class MyClass:
def method(self):
pass
def main():
print("hello")
"#;
let tree = manager.parse(content, "python").unwrap();
let result = ElementExtractor::extract_elements(&tree, content, "python").unwrap();
assert_eq!(result.function_count, 2); // main and method
assert_eq!(result.class_count, 1); // MyClass
assert_eq!(result.import_count, 1); // import os
assert!(result.main_line.is_some());
}
#[test]
fn test_extract_rust_elements() {
let manager = ParserManager::new();
let content = r#"
use std::fs;
struct MyStruct {
field: i32,
}
impl MyStruct {
fn new() -> Self {
Self { field: 0 }
}
}
fn main() {
let s = MyStruct::new();
}
"#;
let tree = manager.parse(content, "rust").unwrap();
let result = ElementExtractor::extract_elements(&tree, content, "rust").unwrap();
assert_eq!(result.function_count, 2); // main and new
assert_eq!(result.class_count, 2); // MyStruct (struct) and MyStruct (impl)
assert_eq!(result.import_count, 1); // use std::fs
assert!(result.main_line.is_some());
}
#[test]
fn test_extract_with_depth_structure() {
let manager = ParserManager::new();
let content = r#"
def func1():
pass
def func2():
func1()
"#;
let tree = manager.parse(content, "python").unwrap();
let result =
ElementExtractor::extract_with_depth(&tree, content, "python", "structure").unwrap();
// In structure mode, detailed vectors should be empty but counts preserved
assert_eq!(result.function_count, 2);
assert!(result.functions.is_empty());
assert!(result.calls.is_empty());
}
#[test]
fn test_extract_with_depth_semantic() {
let manager = ParserManager::new();
let content = r#"
def func1():
pass
def func2():
func1()
"#;
let tree = manager.parse(content, "python").unwrap();
let result =
ElementExtractor::extract_with_depth(&tree, content, "python", "semantic").unwrap();
// In semantic mode, should have both elements and calls
assert_eq!(result.function_count, 2);
assert_eq!(result.functions.len(), 2);
assert!(!result.calls.is_empty());
assert_eq!(result.calls[0].callee_name, "func1");
}
#[test]
fn test_parse_invalid_syntax() {
let manager = ParserManager::new();
let content = "def invalid syntax here";
// Should still parse (tree-sitter is error-tolerant)
let tree = manager.parse(content, "python");
assert!(tree.is_ok());
}
#[test]
fn test_multiple_languages() {
let manager = ParserManager::new();
// Test that we can handle multiple languages in the same manager
assert!(manager.get_or_create_parser("python").is_ok());
assert!(manager.get_or_create_parser("rust").is_ok());
assert!(manager.get_or_create_parser("javascript").is_ok());
assert!(manager.get_or_create_parser("go").is_ok());
assert!(manager.get_or_create_parser("java").is_ok());
assert!(manager.get_or_create_parser("kotlin").is_ok());
}
#[test]
fn test_parse_kotlin() {
let manager = ParserManager::new();
let content = r#"
package com.example
import kotlin.math.*
class Example(val name: String) {
fun greet() {
println("Hello, $name")
}
}
fun main() {
val example = Example("World")
example.greet()
}
"#;
let tree = manager.parse(content, "kotlin").unwrap();
assert!(tree.root_node().child_count() > 0);
}
#[test]
fn test_extract_kotlin_elements() {
let manager = ParserManager::new();
let content = r#"
package com.example
import kotlin.math.*
class MyClass {
fun method() {
println("method")
}
}
fun main() {
println("hello")
}
fun helper() {
main()
}
"#;
let tree = manager.parse(content, "kotlin").unwrap();
let result = ElementExtractor::extract_elements(&tree, content, "kotlin").unwrap();
assert_eq!(result.function_count, 3); // main, helper, method
assert_eq!(result.class_count, 1); // MyClass
assert!(result.import_count > 0); // import statements
assert!(result.main_line.is_some());
}
@@ -0,0 +1,190 @@
// Tests for the traversal module
use crate::developer::analyze::tests::fixtures::create_test_gitignore;
use crate::developer::analyze::traversal::FileTraverser;
use ignore::gitignore::Gitignore;
use std::fs;
use std::path::Path;
use tempfile::TempDir;
#[test]
fn test_is_ignored() {
// Create a temporary directory for testing
let temp_dir = TempDir::new().unwrap();
let dir_path = temp_dir.path();
// Create actual files and directories to test
fs::write(dir_path.join("test.log"), "log content").unwrap();
fs::write(dir_path.join("test.rs"), "fn main() {}").unwrap();
// Create gitignore that ignores .log files
let mut builder = ignore::gitignore::GitignoreBuilder::new(dir_path);
builder.add_line(None, "*.log").unwrap();
let ignore = builder.build().unwrap();
let traverser = FileTraverser::new(&ignore);
// Test that .log files are ignored and .rs files are not
assert!(traverser.is_ignored(&dir_path.join("test.log")));
assert!(!traverser.is_ignored(&dir_path.join("test.rs")));
}
#[test]
fn test_validate_path() {
let ignore = create_test_gitignore();
let traverser = FileTraverser::new(&ignore);
// Test non-existent path
assert!(traverser
.validate_path(Path::new("/nonexistent/path"))
.is_err());
// Test ignored path
assert!(traverser.validate_path(Path::new("test.log")).is_err());
}
#[test]
fn test_collect_files() {
let temp_dir = TempDir::new().unwrap();
let dir_path = temp_dir.path();
// Create test files
fs::write(dir_path.join("test.rs"), "fn main() {}").unwrap();
fs::write(dir_path.join("test.py"), "def main(): pass").unwrap();
fs::write(dir_path.join("test.txt"), "not code").unwrap();
// Create subdirectory with file
let sub_dir = dir_path.join("src");
fs::create_dir(&sub_dir).unwrap();
fs::write(sub_dir.join("lib.rs"), "pub fn test() {}").unwrap();
let ignore = Gitignore::empty();
let traverser = FileTraverser::new(&ignore);
let files = traverser.collect_files_for_focused(dir_path, 0).unwrap();
// Should find .rs and .py files but not .txt
assert_eq!(files.len(), 3);
assert!(files.iter().any(|p| p.ends_with("test.rs")));
assert!(files.iter().any(|p| p.ends_with("test.py")));
assert!(files.iter().any(|p| p.ends_with("lib.rs")));
}
#[test]
fn test_max_depth() {
let temp_dir = TempDir::new().unwrap();
let dir_path = temp_dir.path();
// Create nested structure
fs::write(dir_path.join("root.rs"), "").unwrap();
let level1 = dir_path.join("level1");
fs::create_dir(&level1).unwrap();
fs::write(level1.join("file1.rs"), "").unwrap();
let level2 = level1.join("level2");
fs::create_dir(&level2).unwrap();
fs::write(level2.join("file2.rs"), "").unwrap();
let level3 = level2.join("level3");
fs::create_dir(&level3).unwrap();
fs::write(level3.join("file3.rs"), "").unwrap();
let ignore = Gitignore::empty();
let traverser = FileTraverser::new(&ignore);
// Test that limiting depth works - exact counts may vary based on implementation
// The important thing is that deeper files are excluded with lower max_depth
// With a small max_depth, we should find fewer files
let files_limited = traverser.collect_files_for_focused(dir_path, 2).unwrap();
// With unlimited depth, we should find all files
let files_unlimited = traverser.collect_files_for_focused(dir_path, 0).unwrap();
// The unlimited search should find more files than the limited one
assert!(
files_unlimited.len() > files_limited.len(),
"Unlimited depth should find more files than limited depth"
);
// Should always find the root file
assert!(files_unlimited.iter().any(|p| p.ends_with("root.rs")));
// With unlimited, should find all 4 files
assert_eq!(
files_unlimited.len(),
4,
"Should find all 4 files with unlimited depth"
);
}
#[test]
fn test_symlink_handling() {
let temp_dir = TempDir::new().unwrap();
let dir_path = temp_dir.path();
// Create a file and directory
fs::write(dir_path.join("target.rs"), "fn main() {}").unwrap();
let target_dir = dir_path.join("target_dir");
fs::create_dir(&target_dir).unwrap();
fs::write(target_dir.join("inner.rs"), "fn test() {}").unwrap();
// Create symlinks (if supported by the OS)
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
let _ = symlink(&dir_path.join("target.rs"), dir_path.join("link.rs"));
let _ = symlink(&target_dir, dir_path.join("link_dir"));
}
let ignore = Gitignore::empty();
let traverser = FileTraverser::new(&ignore);
// Collect files - symlinks should be handled appropriately
let files = traverser.collect_files_for_focused(dir_path, 0).unwrap();
// Should find the actual files
assert!(files.iter().any(|p| p.ends_with("target.rs")));
assert!(files.iter().any(|p| p.ends_with("inner.rs")));
}
#[test]
fn test_empty_directory() {
let temp_dir = TempDir::new().unwrap();
let dir_path = temp_dir.path();
let ignore = Gitignore::empty();
let traverser = FileTraverser::new(&ignore);
let files = traverser.collect_files_for_focused(dir_path, 0).unwrap();
assert_eq!(files.len(), 0);
}
#[test]
fn test_gitignore_patterns() {
let temp_dir = TempDir::new().unwrap();
let dir_path = temp_dir.path();
// Create files
fs::write(dir_path.join("test.log"), "log").unwrap();
fs::write(dir_path.join("debug.log"), "debug").unwrap();
fs::write(dir_path.join("test.rs"), "fn main() {}").unwrap();
fs::write(dir_path.join("main.py"), "def main(): pass").unwrap();
// Create gitignore that only ignores .log files
let mut builder = ignore::gitignore::GitignoreBuilder::new(dir_path);
builder.add_line(None, "*.log").unwrap();
let ignore = builder.build().unwrap();
let traverser = FileTraverser::new(&ignore);
let files = traverser.collect_files_for_focused(dir_path, 0).unwrap();
// Should find .rs and .py files, but not .log files
assert_eq!(files.len(), 2, "Should find 2 non-log files");
assert!(files.iter().any(|p| p.ends_with("test.rs")));
assert!(files.iter().any(|p| p.ends_with("main.py")));
assert!(!files.iter().any(|p| p.ends_with(".log")));
}
@@ -0,0 +1,171 @@
use ignore::gitignore::Gitignore;
use rayon::prelude::*;
use rmcp::model::{ErrorCode, ErrorData};
use std::path::{Path, PathBuf};
use crate::developer::analyze::types::{AnalysisResult, EntryType};
use crate::developer::lang;
/// Handles file system traversal with ignore patterns
pub struct FileTraverser<'a> {
ignore_patterns: &'a Gitignore,
}
impl<'a> FileTraverser<'a> {
/// Create a new file traverser with the given ignore patterns
pub fn new(ignore_patterns: &'a Gitignore) -> Self {
Self { ignore_patterns }
}
/// Check if a path should be ignored
pub fn is_ignored(&self, path: &Path) -> bool {
let ignored = self.ignore_patterns.matched(path, false).is_ignore();
if ignored {
tracing::trace!("Path {:?} is ignored", path);
}
ignored
}
/// Validate that a path exists and is not ignored
pub fn validate_path(&self, path: &Path) -> Result<(), ErrorData> {
// Check if path is ignored
if self.is_ignored(path) {
return Err(ErrorData::new(
ErrorCode::INVALID_PARAMS,
format!(
"Access to '{}' is restricted by .gooseignore",
path.display()
),
None,
));
}
// Check if path exists
if !path.exists() {
return Err(ErrorData::new(
ErrorCode::INVALID_PARAMS,
format!("Path '{}' does not exist", path.display()),
None,
));
}
Ok(())
}
/// Collect all files for focused analysis
pub fn collect_files_for_focused(
&self,
path: &Path,
max_depth: u32,
) -> Result<Vec<PathBuf>, ErrorData> {
tracing::debug!(
"Collecting files from {:?} with max_depth {}",
path,
max_depth
);
if max_depth == 0 {
tracing::warn!("Unlimited depth traversal requested for {:?}", path);
}
let files = self.collect_files_recursive(path, 0, max_depth)?;
tracing::info!("Collected {} files from {:?}", files.len(), path);
Ok(files)
}
/// Recursively collect files
fn collect_files_recursive(
&self,
path: &Path,
current_depth: u32,
max_depth: u32,
) -> Result<Vec<PathBuf>, ErrorData> {
let mut files = Vec::new();
// Check if we're at a file (base case)
if path.is_file() {
let lang = lang::get_language_identifier(path);
if !lang.is_empty() {
tracing::trace!("Including file {:?} (language: {})", path, lang);
files.push(path.to_path_buf());
}
return Ok(files);
}
// max_depth of 0 means unlimited depth
// current_depth starts at 0, max_depth is the number of directory levels to traverse
if max_depth > 0 && current_depth >= max_depth {
tracing::trace!("Reached max depth {} at {:?}", max_depth, path);
return Ok(files);
}
let entries = std::fs::read_dir(path).map_err(|e| {
tracing::error!("Failed to read directory {:?}: {}", path, e);
ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to read directory: {}", e),
None,
)
})?;
for entry in entries {
let entry = entry.map_err(|e| {
ErrorData::new(
ErrorCode::INTERNAL_ERROR,
format!("Failed to read directory entry: {}", e),
None,
)
})?;
let entry_path = entry.path();
// Skip ignored paths
if self.is_ignored(&entry_path) {
continue;
}
if entry_path.is_file() {
// Only include supported file types
let lang = lang::get_language_identifier(&entry_path);
if !lang.is_empty() {
tracing::trace!("Including file {:?} (language: {})", entry_path, lang);
files.push(entry_path);
}
} else if entry_path.is_dir() {
// Recurse into subdirectory
let mut sub_files =
self.collect_files_recursive(&entry_path, current_depth + 1, max_depth)?;
files.append(&mut sub_files);
}
}
Ok(files)
}
/// Collect directory results for analysis with parallel processing
pub fn collect_directory_results<F>(
&self,
path: &Path,
max_depth: u32,
analyze_file: F,
) -> Result<Vec<(PathBuf, EntryType)>, ErrorData>
where
F: Fn(&Path) -> Result<AnalysisResult, ErrorData> + Sync,
{
tracing::debug!("Collecting directory results from {:?}", path);
// First collect all files to analyze
let files_to_analyze = self.collect_files_recursive(path, 0, max_depth)?;
// Then analyze them in parallel using Rayon
let results: Result<Vec<_>, ErrorData> = files_to_analyze
.par_iter()
.map(|file_path| {
analyze_file(file_path).map(|result| (file_path.clone(), EntryType::File(result)))
})
.collect();
results
}
}
@@ -0,0 +1,160 @@
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<String>,
/// Call graph depth. 0=where defined, 1=direct callers/callees, 2+=transitive chains
#[serde(default = "default_follow_depth")]
pub follow_depth: u32,
/// Directory recursion limit. 0=unlimited (warning: fails on binary files)
#[serde(default = "default_max_depth")]
pub max_depth: u32,
/// Allow large outputs without warning (default: false)
#[serde(default)]
pub force: bool,
}
fn default_follow_depth() -> u32 {
2
}
fn default_max_depth() -> u32 {
3
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnalysisResult {
pub functions: Vec<FunctionInfo>,
pub classes: Vec<ClassInfo>,
pub imports: Vec<String>,
// Semantic analysis fields
pub calls: Vec<CallInfo>,
pub references: Vec<ReferenceInfo>,
// Structure mode fields (for compact overview)
pub function_count: usize,
pub class_count: usize,
pub line_count: usize,
pub import_count: usize,
pub main_line: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FunctionInfo {
pub name: String,
pub line: usize,
pub params: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClassInfo {
pub name: String,
pub line: usize,
pub methods: Vec<FunctionInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CallInfo {
pub caller_name: Option<String>, // Function containing this call
pub callee_name: String, // Function being called
pub line: usize,
pub column: usize,
pub context: String, // Line of code containing the call
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReferenceInfo {
pub symbol: String,
pub ref_type: ReferenceType,
pub line: usize,
pub context: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ReferenceType {
Definition,
Call,
Import,
Assignment,
}
// Entry type for directory results - cleaner than overloading AnalysisResult
#[derive(Debug, Clone)]
pub enum EntryType {
File(AnalysisResult),
Directory,
SymlinkDir(PathBuf),
SymlinkFile(PathBuf),
}
// Type alias for complex query results
pub type ElementQueryResult = (Vec<FunctionInfo>, Vec<ClassInfo>, Vec<String>);
#[derive(Debug, Clone)]
pub struct CallChain {
pub path: Vec<(PathBuf, usize, String, String)>, // (file, line, from, to)
}
// Data structure to pass to format_focused_output_with_chains
pub struct FocusedAnalysisData<'a> {
pub focus_symbol: &'a str,
pub follow_depth: u32,
pub files_analyzed: &'a [PathBuf],
pub definitions: &'a [(PathBuf, usize)],
pub incoming_chains: &'a [CallChain],
pub outgoing_chains: &'a [CallChain],
}
/// Analysis modes
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum AnalysisMode {
Structure, // Directory overview
Semantic, // File details
Focused, // Symbol tracking
}
impl AnalysisMode {
pub fn as_str(&self) -> &str {
match self {
AnalysisMode::Structure => "structure",
AnalysisMode::Semantic => "semantic",
AnalysisMode::Focused => "focused",
}
}
pub fn parse(s: &str) -> Self {
match s {
"structure" => AnalysisMode::Structure,
"semantic" => AnalysisMode::Semantic,
"focused" => AnalysisMode::Focused,
_ => AnalysisMode::Structure,
}
}
}
impl AnalysisResult {
/// Create an empty analysis result with only line count
pub fn empty(line_count: usize) -> Self {
Self {
functions: vec![],
classes: vec![],
imports: vec![],
calls: vec![],
references: vec![],
function_count: 0,
class_count: 0,
line_count,
import_count: 0,
main_line: None,
}
}
}
+1
View File
@@ -1,3 +1,4 @@
pub mod analyze;
mod editor_models;
mod goose_hints;
mod lang;
@@ -31,6 +31,7 @@ use tokio::{
};
use tokio_stream::{wrappers::SplitStream, StreamExt as _};
use super::analyze::{types::AnalyzeParams, CodeAnalyzer};
use super::editor_models::{create_editor_model, EditorModel};
use super::goose_hints::load_hints::{load_hint_files, GOOSE_HINTS_FILENAME};
use super::shell::{expand_path, get_shell_config, is_absolute_path};
@@ -164,13 +165,14 @@ fn load_prompt_files() -> HashMap<String, Prompt> {
}
/// Developer MCP Server using official RMCP SDK
#[derive(Debug, Clone)]
#[derive(Clone)]
pub struct DeveloperServer {
tool_router: ToolRouter<Self>,
file_history: Arc<Mutex<HashMap<PathBuf, Vec<String>>>>,
ignore_patterns: Gitignore,
editor_model: Option<EditorModel>,
prompts: HashMap<String, Prompt>,
code_analyzer: CodeAnalyzer,
}
#[tool_handler(router = self.tool_router)]
@@ -192,6 +194,9 @@ impl ServerHandler for DeveloperServer {
Use the shell tool as needed to locate files or interact with the project.
Leverage `analyze` through `return_last_only=true` subagents for deep codebase understanding with lean context
- delegate analysis, retain summaries
Your windows/screen tools can be used for visual debugging. You should not use these tools unless
prompted to, but you can mention they are available if they are relevant.
@@ -210,9 +215,14 @@ impl ServerHandler for DeveloperServer {
You can use the shell tool to run any command that would work on the relevant operating system.
Use the shell tool as needed to locate files or interact with the project.
Leverage `analyze` through `return_last_only=true` subagents for deep codebase understanding with lean context
- delegate analysis, retain summaries
Your windows/screen tools can be used for visual debugging. You should not use these tools unless
prompted to, but you can mention they are available if they are relevant.
Always prefer ripgrep (rg -C 3) to grep.
operating system: {os}
current directory: {cwd}
{container_info}
@@ -516,6 +526,7 @@ impl DeveloperServer {
ignore_patterns,
editor_model,
prompts: load_prompt_files(),
code_analyzer: CodeAnalyzer::new(),
}
}
@@ -1002,6 +1013,31 @@ impl DeveloperServer {
Ok(())
}
/// Analyze code structure and relationships.
///
/// Automatically selects the appropriate analysis:
/// - Files: Semantic analysis with call graphs
/// - Directories: Structure overview with metrics
/// - With focus parameter: Track symbol across files
///
/// Examples:
/// analyze(path="file.py") -> semantic analysis
/// analyze(path="src/") -> structure overview down to max_depth subdirs
/// analyze(path="src/", focus="main") -> track main() across files in src/ down to max_depth subdirs
#[tool(
name = "analyze",
description = "Analyze code structure in 3 modes: 1) Directory overview - file tree with LOC/function/class counts to max_depth. 2) File details - functions, classes, imports. 3) Symbol focus - call graphs across directory to max_depth (requires directory path, case-sensitive). Typical flow: directory → files → symbols. Functions called >3x show •N."
)]
pub async fn analyze(
&self,
params: Parameters<AnalyzeParams>,
) -> Result<CallToolResult, ErrorData> {
let params = params.0;
let path = self.resolve_path(&params.path)?;
self.code_analyzer
.analyze(params, path, &self.ignore_patterns)
}
/// Process an image file from disk.
///
/// The image will be:
@@ -1366,6 +1402,7 @@ mod tests {
let running_service = serve_directly(server.clone(), create_test_transport(), None);
let peer = running_service.peer().clone();
// Test directly on the server instead of using peer.call_tool
let result = server
.shell(
Parameters(ShellParams {