From 480741abb3eb1236942e5fee5c1f4e8dd5bfb209 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 07:49:23 +0000 Subject: [PATCH] feat: implement full spy-code codebase (git, parser, resolvers, graph, mcp, indexer, cli) Agent-Logs-Url: https://github.com/Psyborgs-git/spy-code/sessions/13b2dd1e-b57b-4695-ab51-e299e0a9a3eb Co-authored-by: Psyborgs-git <49641518+Psyborgs-git@users.noreply.github.com> --- Cargo.lock | 70 +++- Cargo.toml | 4 +- crates/spy-cli/Cargo.toml | 2 + crates/spy-cli/src/main.rs | 47 ++- crates/spy-git/src/lib.rs | 217 +++++++++- crates/spy-graph/Cargo.toml | 1 + crates/spy-graph/src/lib.rs | 124 +++++- crates/spy-indexer/Cargo.toml | 4 + crates/spy-indexer/src/lib.rs | 289 +++++++++---- crates/spy-indexer/tests/integration.rs | 110 +++++ crates/spy-mcp/Cargo.toml | 7 + crates/spy-mcp/src/lib.rs | 409 ++++++++++++++++++- crates/spy-parser/Cargo.toml | 6 +- crates/spy-parser/src/lib.rs | 70 +++- crates/spy-resolvers/Cargo.toml | 4 + crates/spy-resolvers/src/go.rs | 371 +++++++++++++++++ crates/spy-resolvers/src/lib.rs | 11 +- crates/spy-resolvers/src/python.rs | 522 ++++++++++++++++++++++++ crates/spy-resolvers/src/ts.rs | 469 +++++++++++++++++++++ crates/spy-storage/src/lib.rs | 78 ++++ tests/fixtures/go_sample/animals.go | 21 + tests/fixtures/go_sample/math.go | 13 + tests/fixtures/python_sample/animals.py | 16 + tests/fixtures/python_sample/math.py | 11 + tests/fixtures/rust_sample/math.rs | 11 + tests/fixtures/rust_sample/traits.rs | 30 ++ tests/fixtures/ts_sample/animals.ts | 15 + tests/fixtures/ts_sample/math.ts | 11 + 28 files changed, 2824 insertions(+), 119 deletions(-) create mode 100644 crates/spy-indexer/tests/integration.rs create mode 100644 crates/spy-resolvers/src/go.rs create mode 100644 crates/spy-resolvers/src/python.rs create mode 100644 crates/spy-resolvers/src/ts.rs create mode 100644 tests/fixtures/go_sample/animals.go create mode 100644 tests/fixtures/go_sample/math.go create mode 100644 tests/fixtures/python_sample/animals.py create mode 100644 tests/fixtures/python_sample/math.py create mode 100644 tests/fixtures/rust_sample/math.rs create mode 100644 tests/fixtures/rust_sample/traits.rs create mode 100644 tests/fixtures/ts_sample/animals.ts create mode 100644 tests/fixtures/ts_sample/math.ts diff --git a/Cargo.lock b/Cargo.lock index 9c3c3e1..6d3d8c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1450,6 +1450,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ + "indexmap", "itoa", "memchr", "serde", @@ -1558,8 +1559,10 @@ dependencies = [ "serde", "serde_json", "spy-core", + "spy-git", "spy-graph", "spy-indexer", + "spy-mcp", "spy-storage", "tokio", "tower-http", @@ -1594,6 +1597,7 @@ dependencies = [ "serde", "serde_json", "spy-core", + "spy-git", "spy-storage", "tokio", "tower", @@ -1607,9 +1611,11 @@ dependencies = [ "anyhow", "blake3", "spy-core", + "spy-git", "spy-parser", "spy-resolvers", "spy-storage", + "tempfile", "walkdir", ] @@ -1618,6 +1624,13 @@ name = "spy-mcp" version = "0.1.0" dependencies = [ "anyhow", + "serde", + "serde_json", + "spy-core", + "spy-git", + "spy-graph", + "spy-storage", + "tokio", ] [[package]] @@ -1627,7 +1640,11 @@ dependencies = [ "anyhow", "spy-core", "tree-sitter", + "tree-sitter-go", + "tree-sitter-javascript", + "tree-sitter-python", "tree-sitter-rust", + "tree-sitter-typescript", ] [[package]] @@ -1639,7 +1656,11 @@ dependencies = [ "spy-core", "spy-parser", "tree-sitter", + "tree-sitter-go", + "tree-sitter-javascript", + "tree-sitter-python", "tree-sitter-rust", + "tree-sitter-typescript", ] [[package]] @@ -1922,28 +1943,69 @@ dependencies = [ [[package]] name = "tree-sitter" -version = "0.24.7" +version = "0.26.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5387dffa7ffc7d2dae12b50c6f7aab8ff79d6210147c6613561fc3d474c6f75" +checksum = "887bd495d0582c5e3e0d8ece2233666169fa56a9644d172fc22ad179ab2d0538" dependencies = [ "cc", "regex", "regex-syntax", + "serde_json", "streaming-iterator", "tree-sitter-language", ] +[[package]] +name = "tree-sitter-go" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8560a4d2f835cc0d4d2c2e03cbd0dde2f6114b43bc491164238d333e28b16ea" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68204f2abc0627a90bdf06e605f5c470aa26fdcb2081ea553a04bdad756693f5" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-language" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" +[[package]] +name = "tree-sitter-python" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bf85fd39652e740bf60f46f4cda9492c3a9ad75880575bf14960f775cb74a1c" +dependencies = [ + "cc", + "tree-sitter-language", +] + [[package]] name = "tree-sitter-rust" -version = "0.23.3" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439e577dbe07423ec2582ac62c7531120dbfccfa6e5f92406f93dd271a120e45" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca8ccb3e3a3495c8a943f6c3fd24c3804c471fd7f4f16087623c7fa4c0068e8a" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" dependencies = [ "cc", "tree-sitter-language", diff --git a/Cargo.toml b/Cargo.toml index 17ac2e3..bfc4657 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,8 +38,8 @@ blake3 = "1" walkdir = "2" glob = "0.3" rusqlite = { version = "0.31", features = ["bundled"] } -tree-sitter = "0.24" -tree-sitter-rust = "0.23" +tree-sitter = "0.26" +tree-sitter-rust = "0.24" async-graphql = { version = "7", features = ["chrono"] } async-graphql-axum = "7" axum = "0.8" diff --git a/crates/spy-cli/Cargo.toml b/crates/spy-cli/Cargo.toml index 236b722..dc249a8 100644 --- a/crates/spy-cli/Cargo.toml +++ b/crates/spy-cli/Cargo.toml @@ -13,6 +13,8 @@ spy-core = { workspace = true } spy-storage = { workspace = true } spy-indexer = { workspace = true } spy-graph = { workspace = true } +spy-git = { workspace = true } +spy-mcp = { workspace = true } clap = { workspace = true } anyhow = { workspace = true } serde = { workspace = true } diff --git a/crates/spy-cli/src/main.rs b/crates/spy-cli/src/main.rs index eff5c18..6be57af 100644 --- a/crates/spy-cli/src/main.rs +++ b/crates/spy-cli/src/main.rs @@ -181,13 +181,13 @@ async fn cmd_search(text: String, kind: Option) -> Result<()> { Ok(()) } -async fn cmd_callers(node_id: String, _depth: i32) -> Result<()> { +async fn cmd_callers(node_id: String, depth: i32) -> Result<()> { let config = load_config()?; let storage = Storage::open(&config.db_path)?; let edges = storage.get_incoming_edges(&node_id, EdgeKind::Calls)?; - println!("Callers of {}:", node_id); + println!("Callers of {} (depth {}):", node_id, depth); for edge in edges { println!(" {} (confidence: {:.2})", edge.from_id, edge.confidence); } @@ -195,13 +195,13 @@ async fn cmd_callers(node_id: String, _depth: i32) -> Result<()> { Ok(()) } -async fn cmd_callees(node_id: String, _depth: i32) -> Result<()> { +async fn cmd_callees(node_id: String, depth: i32) -> Result<()> { let config = load_config()?; let storage = Storage::open(&config.db_path)?; let edges = storage.get_edges(&node_id, EdgeKind::Calls)?; - println!("Callees of {}:", node_id); + println!("Callees of {} (depth {}):", node_id, depth); for edge in edges { println!(" {} (confidence: {:.2})", edge.to_id, edge.confidence); } @@ -209,8 +209,41 @@ async fn cmd_callees(node_id: String, _depth: i32) -> Result<()> { Ok(()) } -async fn cmd_changed(_git_ref: String) -> Result<()> { - println!("Changed since: Not implemented (git stub)"); +async fn cmd_changed(git_ref: String) -> Result<()> { + let config = load_config()?; + let storage = Storage::open(&config.db_path)?; + + let repo = spy_git::GitRepo::discover(std::path::Path::new(".")) + .context("Failed to inspect git repository")?; + + let Some(repo) = repo else { + anyhow::bail!("Not inside a git repository"); + }; + + let changed_paths = repo + .files_changed_since_ref(&git_ref) + .context("Failed to compute changed files")?; + + if changed_paths.is_empty() { + println!("No changed files since {}", git_ref); + return Ok(()); + } + + let path_strings: Vec = changed_paths + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + + let nodes = storage.get_nodes_for_files(&path_strings)?; + + println!("Nodes changed since {}:", git_ref); + for node in &nodes { + println!(" {} ({}) — {}", node.node_id, node.kind, node.name); + } + if nodes.is_empty() { + println!(" (no indexed nodes in changed files)"); + } + Ok(()) } @@ -233,7 +266,7 @@ async fn cmd_stats() -> Result<()> { async fn cmd_serve(mcp: bool, http: bool, port: u16) -> Result<()> { if mcp { - println!("MCP server: Not implemented (stub)"); + spy_mcp::run_mcp_server(std::path::Path::new("spy.config.json")).await?; return Ok(()); } diff --git a/crates/spy-git/src/lib.rs b/crates/spy-git/src/lib.rs index bc63b75..756417c 100644 --- a/crates/spy-git/src/lib.rs +++ b/crates/spy-git/src/lib.rs @@ -1,3 +1,216 @@ -pub fn stub() { - println!("spy-git: stub implementation"); +use anyhow::{Context, Result}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Status of a file change between two git refs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FileChangeStatus { + Added, + Modified, + Deleted, + Renamed { old_path: PathBuf }, } + +/// A file that changed between two git refs. +#[derive(Debug, Clone)] +pub struct FileDiff { + pub path: PathBuf, + pub status: FileChangeStatus, +} + +/// Wrapper around a git repository for spy-code operations. +/// +/// All git operations are performed by shelling out to `git`, which is always +/// present in any environment that has a repository. +pub struct GitRepo { + workdir: PathBuf, +} + +impl GitRepo { + /// Discover the nearest git repository at or above `path`. + /// + /// Returns `Ok(None)` when `path` is not inside a git repository. + pub fn discover(path: &Path) -> Result> { + let out = Command::new("git") + .arg("-C") + .arg(path) + .args(["rev-parse", "--show-toplevel"]) + .output() + .context("Failed to spawn git")?; + + if out.status.success() { + let raw = String::from_utf8_lossy(&out.stdout); + let workdir = PathBuf::from(raw.trim()); + Ok(Some(GitRepo { workdir })) + } else { + Ok(None) + } + } + + /// Return the current HEAD SHA, or `None` when HEAD is unborn. + pub fn current_sha(&self) -> Option { + let out = Command::new("git") + .arg("-C") + .arg(&self.workdir) + .args(["rev-parse", "HEAD"]) + .output() + .ok()?; + + if out.status.success() { + let sha = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if sha.is_empty() { None } else { Some(sha) } + } else { + None + } + } + + /// Return `true` if the working tree has uncommitted changes. + pub fn is_dirty(&self) -> bool { + Command::new("git") + .arg("-C") + .arg(&self.workdir) + .args(["status", "--porcelain"]) + .output() + .map(|out| !out.stdout.is_empty()) + .unwrap_or(false) + } + + /// Return the list of files that differ between `old_sha` and HEAD. + /// + /// Returns an error when `old_sha` is not reachable (e.g. force-pushed + /// history or shallow clone boundary). Callers should fall back to a full + /// re-index in that case. + pub fn diff_files_since(&self, old_sha: &str) -> Result> { + let range = format!("{}..HEAD", old_sha); + let out = Command::new("git") + .arg("-C") + .arg(&self.workdir) + .args(["diff", "--name-status", &range]) + .output() + .context("Failed to spawn git diff")?; + + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + anyhow::bail!("git diff failed: {}", stderr.trim()); + } + + parse_name_status(&String::from_utf8_lossy(&out.stdout)) + } + + /// Return the set of file paths (absolute) changed since `git_ref`. + /// + /// Used by the `changedSince` GraphQL / MCP query. + pub fn files_changed_since_ref(&self, git_ref: &str) -> Result> { + let range = format!("{}..HEAD", git_ref); + let out = Command::new("git") + .arg("-C") + .arg(&self.workdir) + .args(["diff", "--name-only", &range]) + .output() + .context("Failed to spawn git diff")?; + + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + anyhow::bail!("git diff failed: {}", stderr.trim()); + } + + Ok(String::from_utf8_lossy(&out.stdout) + .lines() + .filter(|l| !l.is_empty()) + .map(|l| self.workdir.join(l.trim())) + .collect()) + } + + /// The working-tree root of this repository. + pub fn workdir(&self) -> &Path { + &self.workdir + } +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// Parse `git diff --name-status` output into `FileDiff` entries. +fn parse_name_status(output: &str) -> Result> { + let mut diffs = Vec::new(); + + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + // Fields are tab-separated: \t or for renames + // \t\t + let mut fields = line.splitn(3, '\t'); + let status_code = match fields.next() { + Some(s) => s, + None => continue, + }; + + if status_code.starts_with('R') || status_code.starts_with('C') { + let old = fields.next().unwrap_or("").trim(); + let new = fields.next().unwrap_or("").trim(); + if !old.is_empty() && !new.is_empty() { + diffs.push(FileDiff { + path: PathBuf::from(new), + status: FileChangeStatus::Renamed { + old_path: PathBuf::from(old), + }, + }); + } + } else { + let path_str = match fields.next() { + Some(p) => p.trim(), + None => continue, + }; + let status = match status_code.chars().next().unwrap_or(' ') { + 'A' => FileChangeStatus::Added, + 'M' => FileChangeStatus::Modified, + 'D' => FileChangeStatus::Deleted, + _ => continue, + }; + diffs.push(FileDiff { + path: PathBuf::from(path_str), + status, + }); + } + } + + Ok(diffs) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_name_status_basic() { + let input = "M\tsrc/main.rs\nA\tsrc/new.rs\nD\tsrc/old.rs\n"; + let diffs = parse_name_status(input).unwrap(); + assert_eq!(diffs.len(), 3); + assert_eq!(diffs[0].status, FileChangeStatus::Modified); + assert_eq!(diffs[1].status, FileChangeStatus::Added); + assert_eq!(diffs[2].status, FileChangeStatus::Deleted); + } + + #[test] + fn test_parse_name_status_rename() { + let input = "R100\told/file.rs\tnew/file.rs\n"; + let diffs = parse_name_status(input).unwrap(); + assert_eq!(diffs.len(), 1); + assert!(matches!( + &diffs[0].status, + FileChangeStatus::Renamed { old_path } if old_path == &PathBuf::from("old/file.rs") + )); + assert_eq!(diffs[0].path, PathBuf::from("new/file.rs")); + } + + #[test] + fn test_parse_name_status_empty() { + let diffs = parse_name_status("").unwrap(); + assert!(diffs.is_empty()); + } +} + diff --git a/crates/spy-graph/Cargo.toml b/crates/spy-graph/Cargo.toml index e619413..c192b6d 100644 --- a/crates/spy-graph/Cargo.toml +++ b/crates/spy-graph/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [dependencies] spy-core = { workspace = true } spy-storage = { workspace = true } +spy-git = { workspace = true } async-graphql = { workspace = true } async-graphql-axum = { workspace = true } axum = { workspace = true } diff --git a/crates/spy-graph/src/lib.rs b/crates/spy-graph/src/lib.rs index d8df3ab..58c3cc7 100644 --- a/crates/spy-graph/src/lib.rs +++ b/crates/spy-graph/src/lib.rs @@ -61,9 +61,8 @@ impl QueryRoot { let storage = ctx.data::>>()?; let storage = storage.lock().unwrap(); - let _depth = depth.unwrap_or(1); - let edges = storage.get_incoming_edges(&id, EdgeKind::Calls)?; - + let depth = depth.unwrap_or(1).max(1) as usize; + let edges = collect_incoming_edges(&storage, &id, EdgeKind::Calls, depth)?; Ok(edges.into_iter().map(|e| e.into()).collect()) } @@ -76,9 +75,8 @@ impl QueryRoot { let storage = ctx.data::>>()?; let storage = storage.lock().unwrap(); - let _depth = depth.unwrap_or(1); - let edges = storage.get_edges(&id, EdgeKind::Calls)?; - + let depth = depth.unwrap_or(1).max(1) as usize; + let edges = collect_outgoing_edges(&storage, &id, EdgeKind::Calls, depth)?; Ok(edges.into_iter().map(|e| e.into()).collect()) } @@ -96,19 +94,114 @@ impl QueryRoot { }) } - async fn files(&self, _ctx: &Context<'_>) -> async_graphql::Result> { - Ok(vec![]) + async fn files(&self, ctx: &Context<'_>) -> async_graphql::Result> { + let storage = ctx.data::>>()?; + let storage = storage.lock().unwrap(); + Ok(storage.list_files()?) } async fn changed_since( &self, - _ctx: &Context<'_>, - _git_ref: String, + ctx: &Context<'_>, + #[graphql(name = "ref")] git_ref: String, ) -> async_graphql::Result> { - Ok(vec![]) + let storage = ctx.data::>>()?; + let storage = storage.lock().unwrap(); + + // Resolve changed file paths using git + let changed_paths = spy_git::GitRepo::discover(std::path::Path::new(".")) + .map_err(|e| async_graphql::Error::new(e.to_string()))? + .map(|repo| repo.files_changed_since_ref(&git_ref)) + .transpose() + .map_err(|e| async_graphql::Error::new(e.to_string()))? + .unwrap_or_default(); + + if changed_paths.is_empty() { + return Ok(vec![]); + } + + let path_strings: Vec = changed_paths + .into_iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + + let nodes = storage.get_nodes_for_files(&path_strings)?; + Ok(nodes.into_iter().map(Into::into).collect()) } } +// --------------------------------------------------------------------------- +// Multi-hop BFS helpers +// --------------------------------------------------------------------------- + +fn collect_outgoing_edges( + storage: &Storage, + start_id: &str, + kind: EdgeKind, + depth: usize, +) -> anyhow::Result> { + let mut all_edges = Vec::new(); + let mut frontier = vec![start_id.to_string()]; + let mut visited = std::collections::HashSet::new(); + visited.insert(start_id.to_string()); + + for _ in 0..depth { + let mut next_frontier = Vec::new(); + for node_id in &frontier { + let edges = storage.get_edges(node_id, kind)?; + for e in edges { + let to = e.to_id.to_string(); + if visited.insert(to.clone()) { + next_frontier.push(to); + } + all_edges.push(e); + } + } + if next_frontier.is_empty() { + break; + } + frontier = next_frontier; + } + + Ok(all_edges) +} + +fn collect_incoming_edges( + storage: &Storage, + start_id: &str, + kind: EdgeKind, + depth: usize, +) -> anyhow::Result> { + let mut all_edges = Vec::new(); + let mut frontier = vec![start_id.to_string()]; + let mut visited = std::collections::HashSet::new(); + visited.insert(start_id.to_string()); + + for _ in 0..depth { + let mut next_frontier = Vec::new(); + for node_id in &frontier { + let edges = storage.get_incoming_edges(node_id, kind)?; + for e in edges { + let from = e.from_id.to_string(); + if visited.insert(from.clone()) { + next_frontier.push(from); + } + all_edges.push(e); + } + } + if next_frontier.is_empty() { + break; + } + frontier = next_frontier; + } + + Ok(all_edges) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + fn matches_kind(node_kind: &NodeKind, gql_kind: &NodeKindGQL) -> bool { match (node_kind, gql_kind) { (NodeKind::Function, NodeKindGQL::Function) => true, @@ -118,6 +211,10 @@ fn matches_kind(node_kind: &NodeKind, gql_kind: &NodeKindGQL) -> bool { } } +// --------------------------------------------------------------------------- +// GQL enums +// --------------------------------------------------------------------------- + #[derive(async_graphql::Enum, Copy, Clone, Eq, PartialEq)] pub enum NodeKindGQL { Function, @@ -163,6 +260,10 @@ impl From for EdgeKindGQL { } } +// --------------------------------------------------------------------------- +// GQL object types +// --------------------------------------------------------------------------- + #[derive(SimpleObject)] pub struct Param { name: String, @@ -266,3 +367,4 @@ pub struct IndexStatsGQL { last_indexed: Option, last_git_sha: Option, } + diff --git a/crates/spy-indexer/Cargo.toml b/crates/spy-indexer/Cargo.toml index 574e845..c91d8fe 100644 --- a/crates/spy-indexer/Cargo.toml +++ b/crates/spy-indexer/Cargo.toml @@ -9,6 +9,10 @@ spy-core = { workspace = true } spy-parser = { workspace = true } spy-resolvers = { workspace = true } spy-storage = { workspace = true } +spy-git = { workspace = true } anyhow = { workspace = true } walkdir = { workspace = true } blake3 = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/spy-indexer/src/lib.rs b/crates/spy-indexer/src/lib.rs index 6873b1a..afacc0a 100644 --- a/crates/spy-indexer/src/lib.rs +++ b/crates/spy-indexer/src/lib.rs @@ -1,12 +1,12 @@ use anyhow::{Context, Result}; use spy_core::{Config, Language, ProjectScope}; +use spy_git::{FileChangeStatus, GitRepo}; use spy_storage::{FileRecord, Storage}; use std::path::Path; use walkdir::WalkDir; pub struct Indexer { storage: Storage, - #[allow(dead_code)] config: Config, } @@ -17,57 +17,63 @@ impl Indexer { pub fn index(&mut self, root_path: &Path, full: bool) -> Result { let mut stats = IndexStats::default(); - let mut scope = ProjectScope::new(); - let files = self.discover_files(root_path)?; - stats.files_scanned = files.len(); + // Determine which files to parse (either all, or just the diff from git) + let files_to_parse = if full { + let all = self.discover_files(root_path)?; + stats.files_scanned = all.len(); + all + } else { + self.incremental_files(root_path, &mut stats)? + }; - for file_path in &files { - if let Some(lang) = self.detect_language(file_path) { - let should_parse = if full { - true - } else { - self.should_reparse(file_path)? - }; + // Pass 1 — extract nodes from each file and build project scope + let mut scope = ProjectScope::new(); + for file_path in &files_to_parse { + if let Some(lang) = detect_language(file_path) { + stats.files_parsed += 1; + let source = std::fs::read(file_path)?; + let content_hash = compute_file_hash(&source); - if should_parse { - stats.files_parsed += 1; - - let source = std::fs::read(file_path)?; - let content_hash = compute_file_hash(&source); - - match self.parse_and_extract_nodes(file_path, source.clone(), lang) { - Ok(nodes) => { - for node in nodes { - scope.add_node(node.clone()); - self.storage.upsert_node(&node)?; - stats.nodes_extracted += 1; - } - - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH)? - .as_secs() as i64; - - self.storage.upsert_file(&FileRecord { - path: file_path.to_string_lossy().to_string(), - language: lang.as_str().to_string(), - content_hash, - last_indexed: now, - git_sha: None, - })?; - } - Err(e) => { - eprintln!("Failed to parse {}: {}", file_path.display(), e); - stats.files_failed += 1; + match self.parse_and_extract_nodes(file_path, source.clone(), lang) { + Ok(nodes) => { + // Remove stale nodes for this file then insert fresh ones + self.storage + .delete_nodes_for_file(&file_path.to_string_lossy())?; + + for node in nodes { + scope.add_node(node.clone()); + self.storage.upsert_node(&node)?; + stats.nodes_extracted += 1; } + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs() as i64; + + self.storage.upsert_file(&FileRecord { + path: file_path.to_string_lossy().to_string(), + language: lang.as_str().to_string(), + content_hash, + last_indexed: now, + git_sha: None, + })?; + } + Err(e) => { + eprintln!("Failed to parse {}: {}", file_path.display(), e); + stats.files_failed += 1; } } } } - for file_path in &files { - if let Some(lang) = self.detect_language(file_path) { - let source = std::fs::read(file_path)?; + // Pass 2 — extract edges now that the full scope is known + for file_path in &files_to_parse { + if let Some(lang) = detect_language(file_path) { + let source = match std::fs::read(file_path) { + Ok(s) => s, + Err(_) => continue, + }; match self.extract_edges(file_path, source, lang, &scope) { Ok(edges) => { for edge in edges { @@ -76,25 +82,153 @@ impl Indexer { } } Err(e) => { - eprintln!("Failed to extract edges from {}: {}", file_path.display(), e); + eprintln!( + "Failed to extract edges from {}: {}", + file_path.display(), + e + ); } } } } + // Persist the HEAD SHA so the next incremental run can diff against it + if let Ok(Some(repo)) = GitRepo::discover(root_path) { + if let Some(sha) = repo.current_sha() { + let stored_sha = if repo.is_dirty() { + format!("{}+dirty", sha) + } else { + sha + }; + self.storage.set_meta("last_git_sha", &stored_sha)?; + } + } + Ok(stats) } + // ----------------------------------------------------------------------- + // File discovery + // ----------------------------------------------------------------------- + + /// For incremental mode: use git diff when available, fall back to + /// content-hash comparison. + fn incremental_files( + &mut self, + root_path: &Path, + stats: &mut IndexStats, + ) -> Result> { + let all_files = self.discover_files(root_path)?; + stats.files_scanned = all_files.len(); + + // Try git-based incremental first + if self.config.git.enabled { + if let Ok(Some(repo)) = GitRepo::discover(root_path) { + if let Some(last_sha) = self.storage.get_meta("last_git_sha")? { + // Strip the +dirty suffix before passing to git diff + let clean_sha = last_sha.trim_end_matches("+dirty").to_string(); + + match repo.diff_files_since(&clean_sha) { + Ok(diffs) => { + return self.apply_git_diff(diffs, root_path, &all_files); + } + Err(e) => { + eprintln!( + "Warning: git diff failed ({}), falling back to full scan", + e + ); + } + } + } + } + } + + // Fall back: only re-parse files whose content hash changed + let mut changed = Vec::new(); + for path in all_files { + if self.should_reparse(&path)? { + changed.push(path); + } + } + Ok(changed) + } + + /// Process deleted files from the diff and return the set of files to + /// (re-)parse. + fn apply_git_diff( + &mut self, + diffs: Vec, + workdir: &Path, + _all_files: &[std::path::PathBuf], + ) -> Result> { + let mut to_parse = Vec::new(); + + for diff in diffs { + let abs = workdir.join(&diff.path); + match &diff.status { + FileChangeStatus::Deleted => { + self.storage + .delete_nodes_for_file(&abs.to_string_lossy())?; + } + FileChangeStatus::Renamed { old_path } => { + let old_abs = workdir.join(old_path); + self.storage + .delete_nodes_for_file(&old_abs.to_string_lossy())?; + if detect_language(&abs).is_some() { + to_parse.push(abs); + } + } + FileChangeStatus::Added | FileChangeStatus::Modified => { + if detect_language(&abs).is_some() && abs.exists() { + to_parse.push(abs); + } + } + } + } + + Ok(to_parse) + } + fn discover_files(&self, root: &Path) -> Result> { + let ignore_dirs: &[&str] = &[ + "target", + ".git", + "__pycache__", + ".venv", + "node_modules", + ".mypy_cache", + "dist", + "build", + ]; + let mut files = Vec::new(); - for entry in WalkDir::new(root).follow_links(false) { + for entry in WalkDir::new(root).follow_links(self.config.git.follow_symlinks) { let entry = entry?; - if entry.file_type().is_file() { - if let Some(ext) = entry.path().extension() { - if ext == "rs" { - files.push(entry.path().to_path_buf()); - } + if entry.file_type().is_dir() { + let name = entry.file_name().to_string_lossy(); + if ignore_dirs.iter().any(|d| *d == name.as_ref()) { + // walkdir doesn't support skip-dir natively; we filter below + } + continue; + } + + // Skip files inside ignored directories + let path = entry.path(); + let in_ignored = path.ancestors().any(|a| { + a.file_name() + .map(|n| ignore_dirs.iter().any(|d| *d == n.to_string_lossy().as_ref())) + .unwrap_or(false) + }); + if in_ignored { + continue; + } + + if detect_language(path).is_some() { + let file_size = entry.metadata().map(|m| m.len()).unwrap_or(0); + let max_bytes = self.config.indexing.max_file_size_kb * 1024; + if file_size <= max_bytes { + files.push(path.to_path_buf()); } } } @@ -102,30 +236,20 @@ impl Indexer { Ok(files) } - fn detect_language(&self, path: &Path) -> Option { - path.extension() - .and_then(|ext| ext.to_str()) - .and_then(|ext| match ext { - "rs" => Some(Language::Rust), - "py" => Some(Language::Python), - "ts" => Some(Language::TypeScript), - "js" => Some(Language::JavaScript), - "go" => Some(Language::Go), - _ => None, - }) - } - fn should_reparse(&self, path: &Path) -> Result { let source = std::fs::read(path)?; let current_hash = compute_file_hash(&source); - - if let Some(file_record) = self.storage.get_file(&path.to_string_lossy())? { - Ok(file_record.content_hash != current_hash) + if let Some(rec) = self.storage.get_file(&path.to_string_lossy())? { + Ok(rec.content_hash != current_hash) } else { Ok(true) } } + // ----------------------------------------------------------------------- + // Parse / edge helpers + // ----------------------------------------------------------------------- + fn parse_and_extract_nodes( &self, path: &Path, @@ -133,10 +257,8 @@ impl Indexer { lang: Language, ) -> Result> { let ctx = spy_parser::parse_file(path, source, lang)?; - - let resolver = spy_resolvers::get_resolver(lang) - .context("No resolver available for language")?; - + let resolver = + spy_resolvers::get_resolver(lang).context("No resolver available for language")?; resolver.extract_nodes(&ctx) } @@ -148,17 +270,31 @@ impl Indexer { scope: &ProjectScope, ) -> Result> { let ctx = spy_parser::parse_file(path, source, lang)?; - - let resolver = spy_resolvers::get_resolver(lang) - .context("No resolver available for language")?; - + let resolver = + spy_resolvers::get_resolver(lang).context("No resolver available for language")?; resolver.extract_edges(&ctx, scope) } } +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +pub fn detect_language(path: &Path) -> Option { + path.extension() + .and_then(|ext| ext.to_str()) + .and_then(|ext| match ext { + "rs" => Some(Language::Rust), + "py" => Some(Language::Python), + "ts" | "tsx" => Some(Language::TypeScript), + "js" | "jsx" | "mjs" | "cjs" => Some(Language::JavaScript), + "go" => Some(Language::Go), + _ => None, + }) +} + fn compute_file_hash(source: &[u8]) -> String { - let hash = blake3::hash(source); - hash.to_hex().to_string() + blake3::hash(source).to_hex().to_string() } #[derive(Debug, Default, Clone)] @@ -169,3 +305,4 @@ pub struct IndexStats { pub nodes_extracted: usize, pub edges_extracted: usize, } + diff --git a/crates/spy-indexer/tests/integration.rs b/crates/spy-indexer/tests/integration.rs new file mode 100644 index 0000000..5294e53 --- /dev/null +++ b/crates/spy-indexer/tests/integration.rs @@ -0,0 +1,110 @@ +use anyhow::Result; +use spy_core::{Config, Language}; +use spy_indexer::{detect_language, Indexer}; +use spy_storage::Storage; +use std::path::Path; +use tempfile::TempDir; + +fn make_storage() -> (TempDir, Storage) { + let dir = TempDir::new().unwrap(); + let db_path = dir.path().join("graph.db"); + let storage = Storage::open(&db_path).unwrap(); + (dir, storage) +} + +fn fixtures_path(lang: &str) -> std::path::PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent().unwrap() + .parent().unwrap() + .join("tests/fixtures") + .join(lang) +} + +#[test] +fn test_detect_language() { + assert_eq!(detect_language(Path::new("foo.rs")), Some(Language::Rust)); + assert_eq!(detect_language(Path::new("foo.py")), Some(Language::Python)); + assert_eq!(detect_language(Path::new("foo.ts")), Some(Language::TypeScript)); + assert_eq!(detect_language(Path::new("foo.tsx")), Some(Language::TypeScript)); + assert_eq!(detect_language(Path::new("foo.js")), Some(Language::JavaScript)); + assert_eq!(detect_language(Path::new("foo.go")), Some(Language::Go)); + assert_eq!(detect_language(Path::new("foo.txt")), None); +} + +#[test] +fn test_index_rust_fixtures() -> Result<()> { + let root = fixtures_path("rust_sample"); + if !root.exists() { + return Ok(()); // skip if fixtures not present + } + let (_dir, storage) = make_storage(); + let mut indexer = Indexer::new(storage, Config::default()); + let stats = indexer.index(&root, true)?; + // math.rs: add, subtract, MAX_VALUE = at least 3 nodes + // traits.rs: Animal (trait), Dog (struct), multiple methods + assert!(stats.nodes_extracted >= 3, "Expected >=3 nodes, got {}", stats.nodes_extracted); + Ok(()) +} + +#[test] +fn test_index_python_fixtures() -> Result<()> { + let root = fixtures_path("python_sample"); + if !root.exists() { + return Ok(()); + } + let (_dir, storage) = make_storage(); + let mut indexer = Indexer::new(storage, Config::default()); + let stats = indexer.index(&root, true)?; + assert!(stats.nodes_extracted >= 4, "Expected >=4 nodes, got {}", stats.nodes_extracted); + Ok(()) +} + +#[test] +fn test_index_typescript_fixtures() -> Result<()> { + let root = fixtures_path("ts_sample"); + if !root.exists() { + return Ok(()); + } + let (_dir, storage) = make_storage(); + let mut indexer = Indexer::new(storage, Config::default()); + let stats = indexer.index(&root, true)?; + // add, subtract, MAX_VALUE, Animal, Dog, speak x2, constructor + assert!(stats.nodes_extracted >= 4, "Expected >=4 nodes, got {}", stats.nodes_extracted); + Ok(()) +} + +#[test] +fn test_index_go_fixtures() -> Result<()> { + let root = fixtures_path("go_sample"); + if !root.exists() { + return Ok(()); + } + let (_dir, storage) = make_storage(); + let mut indexer = Indexer::new(storage, Config::default()); + let stats = indexer.index(&root, true)?; + // Add, Subtract, MaxValue, Animal, Dog, Speak x2 + assert!(stats.nodes_extracted >= 4, "Expected >=4 nodes, got {}", stats.nodes_extracted); + Ok(()) +} + +#[test] +fn test_incremental_index_skips_unchanged() -> Result<()> { + let root = fixtures_path("rust_sample"); + if !root.exists() { + return Ok(()); + } + let (_dir, storage) = make_storage(); + let mut indexer = Indexer::new(storage, Config::default()); + + // First index + let stats1 = indexer.index(&root, true)?; + assert!(stats1.files_parsed > 0); + + // Second incremental index — nothing changed, should parse 0 files + let stats2 = indexer.index(&root, false)?; + assert_eq!( + stats2.files_parsed, 0, + "Incremental index should parse 0 unchanged files" + ); + Ok(()) +} diff --git a/crates/spy-mcp/Cargo.toml b/crates/spy-mcp/Cargo.toml index cb9bc81..f922122 100644 --- a/crates/spy-mcp/Cargo.toml +++ b/crates/spy-mcp/Cargo.toml @@ -5,4 +5,11 @@ edition.workspace = true license.workspace = true [dependencies] +spy-core = { workspace = true } +spy-storage = { workspace = true } +spy-git = { workspace = true } +spy-graph = { workspace = true } anyhow = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } diff --git a/crates/spy-mcp/src/lib.rs b/crates/spy-mcp/src/lib.rs index df708be..0db27a9 100644 --- a/crates/spy-mcp/src/lib.rs +++ b/crates/spy-mcp/src/lib.rs @@ -1,3 +1,408 @@ -pub fn stub() { - println!("spy-mcp: stub implementation"); +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use spy_core::Config; +use spy_storage::Storage; +use std::path::Path; +use std::sync::{Arc, Mutex}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + +// --------------------------------------------------------------------------- +// MCP JSON-RPC types +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +struct Request { + #[allow(dead_code)] + jsonrpc: String, + id: Option, + method: String, + params: Option, } + +#[derive(Debug, Serialize)] +struct Response { + jsonrpc: String, + id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Debug, Serialize)] +struct JsonRpcError { + code: i32, + message: String, +} + +impl Response { + fn ok(id: Option, result: Value) -> Self { + Response { + jsonrpc: "2.0".to_string(), + id, + result: Some(result), + error: None, + } + } + + fn err(id: Option, code: i32, message: impl Into) -> Self { + Response { + jsonrpc: "2.0".to_string(), + id, + result: None, + error: Some(JsonRpcError { + code, + message: message.into(), + }), + } + } +} + +// --------------------------------------------------------------------------- +// Public entry point +// --------------------------------------------------------------------------- + +/// Run the MCP stdio server until EOF. Reads `spy.config.json` from the +/// current directory (or `config_path`) to locate the SQLite database. +pub async fn run_mcp_server(config_path: &Path) -> Result<()> { + let config_str = std::fs::read_to_string(config_path) + .context("Failed to read config. Run 'spy-code init' first.")?; + let config: Config = serde_json::from_str(&config_str)?; + + let storage = Storage::open(&config.db_path) + .context("Failed to open database")?; + let storage = Arc::new(Mutex::new(storage)); + + let schema = spy_graph::create_schema(Arc::clone(&storage)); + + let stdin = tokio::io::stdin(); + let mut reader = BufReader::new(stdin).lines(); + let mut stdout = tokio::io::stdout(); + + while let Some(line) = reader.next_line().await? { + let line = line.trim().to_string(); + if line.is_empty() { + continue; + } + + let response = match serde_json::from_str::(&line) { + Err(e) => Response::err(None, -32700, format!("Parse error: {}", e)), + Ok(req) => handle_request(req, &storage, &schema, &config).await, + }; + + let mut out = serde_json::to_string(&response)?; + out.push('\n'); + stdout.write_all(out.as_bytes()).await?; + stdout.flush().await?; + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Request dispatch +// --------------------------------------------------------------------------- + +async fn handle_request( + req: Request, + storage: &Arc>, + schema: &spy_graph::SpySchema, + config: &Config, +) -> Response { + match req.method.as_str() { + "initialize" => Response::ok( + req.id, + json!({ + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": {}, + "resources": {} + }, + "serverInfo": { + "name": "spy-code", + "version": env!("CARGO_PKG_VERSION") + } + }), + ), + "initialized" => Response::ok(req.id, json!(null)), + "tools/list" => Response::ok(req.id, tools_list()), + "tools/call" => match handle_tool_call(req.params, storage, schema).await { + Ok(result) => Response::ok(req.id, result), + Err(e) => Response::err(req.id, -32603, e.to_string()), + }, + "resources/list" => Response::ok(req.id, resources_list()), + "resources/read" => match handle_resource_read(req.params, storage, schema, config).await { + Ok(result) => Response::ok(req.id, result), + Err(e) => Response::err(req.id, -32603, e.to_string()), + }, + other => Response::err(req.id, -32601, format!("Method not found: {}", other)), + } +} + +// --------------------------------------------------------------------------- +// Tools +// --------------------------------------------------------------------------- + +fn tools_list() -> Value { + json!({ + "tools": [ + { + "name": "query_graph", + "description": "Run a raw GraphQL query against the codebase graph. Use this for complex multi-hop queries. Schema is documented at spy-code://schema. Prefer the specialized tools below for common ops.", + "inputSchema": { + "type": "object", + "properties": { + "query": { "type": "string" }, + "variables": { "type": "object" } + }, + "required": ["query"] + } + }, + { + "name": "get_node", + "description": "Fetch one node by its ID. Node IDs are 'dir:file:class:symbol'. Returns name, description, signatures (params + returns), and location.", + "inputSchema": { + "type": "object", + "properties": { + "node_id": { "type": "string" } + }, + "required": ["node_id"] + } + }, + { + "name": "search", + "description": "Find nodes by fuzzy name/description match. Use this when you know roughly what something is called but not its exact ID.", + "inputSchema": { + "type": "object", + "properties": { + "query": { "type": "string" }, + "kind": { "type": "string", "enum": ["function", "class", "constant"] }, + "limit": { "type": "integer" } + }, + "required": ["query"] + } + }, + { + "name": "find_callers", + "description": "List all functions/methods that call the given node. Use depth > 1 to walk transitively up the call graph.", + "inputSchema": { + "type": "object", + "properties": { + "node_id": { "type": "string" }, + "depth": { "type": "integer" } + }, + "required": ["node_id"] + } + }, + { + "name": "find_callees", + "description": "List all functions/methods called by the given node. Use depth > 1 to walk transitively down.", + "inputSchema": { + "type": "object", + "properties": { + "node_id": { "type": "string" }, + "depth": { "type": "integer" } + }, + "required": ["node_id"] + } + }, + { + "name": "changed_since", + "description": "List nodes whose source changed since the given git ref. Use this to find what an AI agent needs to re-read after a rebase.", + "inputSchema": { + "type": "object", + "properties": { + "git_ref": { "type": "string" } + }, + "required": ["git_ref"] + } + }, + { + "name": "stats", + "description": "Return index statistics (node count, edge count, file count, last indexed SHA).", + "inputSchema": { + "type": "object", + "properties": {} + } + } + ] + }) +} + +async fn handle_tool_call( + params: Option, + storage: &Arc>, + schema: &spy_graph::SpySchema, +) -> Result { + let params = params.unwrap_or_default(); + let name = params + .get("name") + .and_then(|v| v.as_str()) + .context("Missing tool name")?; + let args = params.get("arguments").cloned().unwrap_or_default(); + + match name { + "query_graph" => { + let query = args + .get("query") + .and_then(|v| v.as_str()) + .context("Missing 'query'")?; + let result = schema.execute(query).await; + let json = serde_json::to_value(&result)?; + Ok(json!({ "content": [{ "type": "text", "text": json.to_string() }] })) + } + + "get_node" => { + let node_id = args + .get("node_id") + .and_then(|v| v.as_str()) + .context("Missing 'node_id'")?; + let st = storage.lock().unwrap(); + let node = st.get_node(node_id)?; + Ok(json!({ "content": [{ "type": "text", "text": serde_json::to_string(&node)? }] })) + } + + "search" => { + let query = args + .get("query") + .and_then(|v| v.as_str()) + .context("Missing 'query'")?; + let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize; + let kind_filter = args.get("kind").and_then(|v| v.as_str()); + let st = storage.lock().unwrap(); + let mut results = st.search_nodes(query, limit)?; + if let Some(k) = kind_filter { + results.retain(|(n, _)| n.kind.as_str() == k); + } + Ok(json!({ "content": [{ "type": "text", "text": serde_json::to_string(&results.iter().map(|(n, s)| json!({ "node_id": n.node_id.as_str(), "name": &n.name, "kind": n.kind.as_str(), "score": s })).collect::>())? }] })) + } + + "find_callers" => { + let gql = format!( + r#"{{ callers(id: "{}", depth: {}) {{ fromId toId confidence }} }}"#, + args.get("node_id").and_then(|v| v.as_str()).unwrap_or(""), + args.get("depth").and_then(|v| v.as_i64()).unwrap_or(1) + ); + let result = schema.execute(&gql).await; + Ok(json!({ "content": [{ "type": "text", "text": serde_json::to_value(&result)?.to_string() }] })) + } + + "find_callees" => { + let gql = format!( + r#"{{ callees(id: "{}", depth: {}) {{ fromId toId confidence }} }}"#, + args.get("node_id").and_then(|v| v.as_str()).unwrap_or(""), + args.get("depth").and_then(|v| v.as_i64()).unwrap_or(1) + ); + let result = schema.execute(&gql).await; + Ok(json!({ "content": [{ "type": "text", "text": serde_json::to_value(&result)?.to_string() }] })) + } + + "changed_since" => { + let git_ref = args + .get("git_ref") + .and_then(|v| v.as_str()) + .context("Missing 'git_ref'")?; + let paths = spy_git::GitRepo::discover(Path::new(".")) + .context("Failed to open git repo")? + .map(|repo| repo.files_changed_since_ref(git_ref)) + .transpose() + .context("git diff failed")? + .unwrap_or_default(); + let path_strings: Vec = paths + .into_iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + let nodes = storage.lock().unwrap().get_nodes_for_files(&path_strings)?; + Ok(json!({ "content": [{ "type": "text", "text": serde_json::to_string(&nodes.iter().map(|n| json!({ "node_id": n.node_id.as_str(), "name": &n.name, "file_path": &n.file_path })).collect::>())? }] })) + } + + "stats" => { + let stats = storage.lock().unwrap().get_stats()?; + Ok(json!({ "content": [{ "type": "text", "text": serde_json::to_string(&json!({ "node_count": stats.node_count, "edge_count": stats.edge_count, "file_count": stats.file_count, "last_git_sha": stats.last_git_sha }))? }] })) + } + + other => anyhow::bail!("Unknown tool: {}", other), + } +} + +// --------------------------------------------------------------------------- +// Resources +// --------------------------------------------------------------------------- + +fn resources_list() -> Value { + json!({ + "resources": [ + { + "uri": "spy-code://schema", + "name": "GraphQL Schema", + "description": "Full GraphQL SDL for the spy-code graph API", + "mimeType": "text/plain" + }, + { + "uri": "spy-code://stats", + "name": "Index Stats", + "description": "Current index statistics", + "mimeType": "application/json" + }, + { + "uri": "spy-code://config", + "name": "Config", + "description": "Loaded configuration (sanitized)", + "mimeType": "application/json" + } + ] + }) +} + +async fn handle_resource_read( + params: Option, + storage: &Arc>, + schema: &spy_graph::SpySchema, + config: &Config, +) -> Result { + let uri = params + .as_ref() + .and_then(|p| p.get("uri")) + .and_then(|v| v.as_str()) + .context("Missing 'uri'")?; + + match uri { + "spy-code://schema" => { + let sdl = schema.sdl(); + Ok(json!({ + "contents": [{ + "uri": uri, + "mimeType": "text/plain", + "text": sdl + }] + })) + } + "spy-code://stats" => { + let stats = storage.lock().unwrap().get_stats()?; + Ok(json!({ + "contents": [{ + "uri": uri, + "mimeType": "application/json", + "text": serde_json::to_string(&json!({ + "node_count": stats.node_count, + "edge_count": stats.edge_count, + "file_count": stats.file_count, + "last_git_sha": stats.last_git_sha + }))? + }] + })) + } + "spy-code://config" => { + Ok(json!({ + "contents": [{ + "uri": uri, + "mimeType": "application/json", + "text": serde_json::to_string_pretty(config)? + }] + })) + } + other => anyhow::bail!("Unknown resource URI: {}", other), + } +} + diff --git a/crates/spy-parser/Cargo.toml b/crates/spy-parser/Cargo.toml index 61e8100..ef9f81f 100644 --- a/crates/spy-parser/Cargo.toml +++ b/crates/spy-parser/Cargo.toml @@ -6,6 +6,10 @@ license.workspace = true [dependencies] spy-core = { workspace = true } -tree-sitter = { workspace = true } +tree-sitter = "0.26" tree-sitter-rust = { workspace = true } +tree-sitter-python = "0.25" +tree-sitter-typescript = "0.23" +tree-sitter-javascript = "0.25" +tree-sitter-go = "0.25" anyhow = { workspace = true } diff --git a/crates/spy-parser/src/lib.rs b/crates/spy-parser/src/lib.rs index 4fee625..6ba6a31 100644 --- a/crates/spy-parser/src/lib.rs +++ b/crates/spy-parser/src/lib.rs @@ -5,18 +5,23 @@ use tree_sitter::Parser; pub fn parse_file(path: &Path, source: Vec, language: Language) -> Result { let mut parser = Parser::new(); - + let ts_lang = match language { Language::Rust => tree_sitter_rust::LANGUAGE.into(), - _ => anyhow::bail!("Unsupported language: {:?}", language), + Language::Python => tree_sitter_python::LANGUAGE.into(), + Language::TypeScript => tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), + Language::JavaScript => tree_sitter_javascript::LANGUAGE.into(), + Language::Go => tree_sitter_go::LANGUAGE.into(), }; - - parser.set_language(&ts_lang) + + parser + .set_language(&ts_lang) .context("Failed to set parser language")?; - - let tree = parser.parse(&source, None) + + let tree = parser + .parse(&source, None) .context("Failed to parse source")?; - + Ok(FileContext { tree, source, @@ -32,19 +37,58 @@ pub fn node_text<'a>(node: &tree_sitter::Node, source: &'a [u8]) -> &'a str { #[cfg(test)] mod tests { use super::*; - + #[test] fn test_parse_rust() -> Result<()> { let source = b"fn main() {}"; + let ctx = parse_file(Path::new("test.rs"), source.to_vec(), Language::Rust)?; + assert_eq!(ctx.language, Language::Rust); + assert!(ctx.tree.root_node().child_count() > 0); + Ok(()) + } + + #[test] + fn test_parse_python() -> Result<()> { + let source = b"def hello(): pass"; + let ctx = parse_file(Path::new("test.py"), source.to_vec(), Language::Python)?; + assert_eq!(ctx.language, Language::Python); + assert!(ctx.tree.root_node().child_count() > 0); + Ok(()) + } + + #[test] + fn test_parse_typescript() -> Result<()> { + let source = b"function greet(): void {}"; let ctx = parse_file( - Path::new("test.rs"), + Path::new("test.ts"), source.to_vec(), - Language::Rust, + Language::TypeScript, )?; - - assert_eq!(ctx.language, Language::Rust); + assert_eq!(ctx.language, Language::TypeScript); + assert!(ctx.tree.root_node().child_count() > 0); + Ok(()) + } + + #[test] + fn test_parse_javascript() -> Result<()> { + let source = b"function foo() {}"; + let ctx = parse_file( + Path::new("test.js"), + source.to_vec(), + Language::JavaScript, + )?; + assert_eq!(ctx.language, Language::JavaScript); + assert!(ctx.tree.root_node().child_count() > 0); + Ok(()) + } + + #[test] + fn test_parse_go() -> Result<()> { + let source = b"package main\nfunc main() {}"; + let ctx = parse_file(Path::new("test.go"), source.to_vec(), Language::Go)?; + assert_eq!(ctx.language, Language::Go); assert!(ctx.tree.root_node().child_count() > 0); - Ok(()) } } + diff --git a/crates/spy-resolvers/Cargo.toml b/crates/spy-resolvers/Cargo.toml index 95c9585..b007253 100644 --- a/crates/spy-resolvers/Cargo.toml +++ b/crates/spy-resolvers/Cargo.toml @@ -8,6 +8,10 @@ license.workspace = true spy-core = { workspace = true } tree-sitter = { workspace = true } tree-sitter-rust = { workspace = true } +tree-sitter-python = "0.25" +tree-sitter-typescript = "0.23" +tree-sitter-javascript = "0.25" +tree-sitter-go = "0.25" blake3 = { workspace = true } anyhow = { workspace = true } diff --git a/crates/spy-resolvers/src/go.rs b/crates/spy-resolvers/src/go.rs new file mode 100644 index 0000000..e4a16ad --- /dev/null +++ b/crates/spy-resolvers/src/go.rs @@ -0,0 +1,371 @@ +use anyhow::Result; +use spy_core::{ + Edge, EdgeKind, FileContext, Language, Node, NodeId, NodeKind, Param, ProjectScope, Resolver, + Signature, +}; +use tree_sitter::Node as TSNode; + +pub struct GoResolver; + +impl Resolver for GoResolver { + fn language(&self) -> Language { + Language::Go + } + + fn extensions(&self) -> &[&str] { + &["go"] + } + + fn extract_nodes(&self, ctx: &FileContext) -> Result> { + let mut nodes = Vec::new(); + let root = ctx.tree.root_node(); + + let dir = ctx + .path + .parent() + .and_then(|p| p.to_str()) + .unwrap_or("."); + let file = ctx.path.file_name().and_then(|f| f.to_str()).unwrap_or("_"); + + walk_nodes(&root, &ctx.source, dir, file, &mut nodes, ctx)?; + Ok(nodes) + } + + fn extract_edges(&self, ctx: &FileContext, scope: &ProjectScope) -> Result> { + let mut edges = Vec::new(); + let root = ctx.tree.root_node(); + walk_for_edges(&root, &ctx.source, ctx, scope, &mut edges)?; + Ok(edges) + } +} + +// --------------------------------------------------------------------------- +// Node extraction +// --------------------------------------------------------------------------- + +fn walk_nodes( + node: &TSNode, + source: &[u8], + dir: &str, + file: &str, + nodes: &mut Vec, + ctx: &FileContext, +) -> Result<()> { + match node.kind() { + "function_declaration" => { + if let Some(name_node) = node.child_by_field_name("name") { + let name = node_text(&name_node, source); + let description = extract_preceding_comments(node, source); + let sig = extract_function_signature(node, source); + let content_hash = compute_hash(node, source); + let node_id = NodeId::new(dir, file, "_", name)?; + nodes.push(Node { + node_id, + kind: NodeKind::Function, + name: name.to_string(), + description, + signatures: vec![sig], + language: Language::Go, + file_path: ctx.path.to_string_lossy().to_string(), + start_line: node.start_position().row as u32 + 1, + end_line: node.end_position().row as u32 + 1, + content_hash, + git_sha: None, + renamed_from: None, + }); + } + } + "method_declaration" => { + if let Some(name_node) = node.child_by_field_name("name") { + let name = node_text(&name_node, source); + let description = extract_preceding_comments(node, source); + let sig = extract_function_signature(node, source); + let content_hash = compute_hash(node, source); + + // Receiver type becomes the "class" component + let receiver_type = extract_receiver_type(node, source); + let node_id = NodeId::new(dir, file, &receiver_type, name)?; + nodes.push(Node { + node_id, + kind: NodeKind::Function, + name: name.to_string(), + description, + signatures: vec![sig], + language: Language::Go, + file_path: ctx.path.to_string_lossy().to_string(), + start_line: node.start_position().row as u32 + 1, + end_line: node.end_position().row as u32 + 1, + content_hash, + git_sha: None, + renamed_from: None, + }); + } + } + "type_declaration" => { + // type Foo struct {...} or type Bar interface {...} + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() == "type_spec" { + if let Some(name_node) = child.child_by_field_name("name") { + let name = node_text(&name_node, source); + if let Some(type_node) = child.child_by_field_name("type") { + if matches!(type_node.kind(), "struct_type" | "interface_type") { + let description = extract_preceding_comments(node, source); + let content_hash = compute_hash(node, source); + let node_id = NodeId::new(dir, file, "_", name)?; + nodes.push(Node { + node_id, + kind: NodeKind::Class, + name: name.to_string(), + description, + signatures: vec![], + language: Language::Go, + file_path: ctx.path.to_string_lossy().to_string(), + start_line: node.start_position().row as u32 + 1, + end_line: node.end_position().row as u32 + 1, + content_hash, + git_sha: None, + renamed_from: None, + }); + } + } + } + } + } + } + "const_declaration" => { + // Package-level const + let mut cursor = node.walk(); + for spec in node.children(&mut cursor) { + if spec.kind() == "const_spec" { + if let Some(name_node) = spec.child_by_field_name("name") { + let name = node_text(&name_node, source); + let description = extract_preceding_comments(node, source); + let content_hash = compute_hash(node, source); + let node_id = NodeId::new(dir, file, "_", name)?; + nodes.push(Node { + node_id, + kind: NodeKind::Constant, + name: name.to_string(), + description, + signatures: vec![], + language: Language::Go, + file_path: ctx.path.to_string_lossy().to_string(), + start_line: node.start_position().row as u32 + 1, + end_line: node.end_position().row as u32 + 1, + content_hash, + git_sha: None, + renamed_from: None, + }); + } + } + } + } + _ => {} + } + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + walk_nodes(&child, source, dir, file, nodes, ctx)?; + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Edge extraction +// --------------------------------------------------------------------------- + +fn walk_for_edges( + node: &TSNode, + source: &[u8], + ctx: &FileContext, + scope: &ProjectScope, + edges: &mut Vec, +) -> Result<()> { + if node.kind() == "call_expression" { + if let Some(func_node) = node.child_by_field_name("function") { + let func_text = node_text(&func_node, source); + // Strip package qualifier: fmt.Println → Println + let bare = func_text.split('.').last().unwrap_or(func_text); + if let Some(from_id) = infer_containing_function(node, source, ctx)? { + let candidates = scope.find_nodes_by_name(bare); + if candidates.len() == 1 { + edges.push(Edge { + from_id, + to_id: candidates[0].node_id.clone(), + kind: EdgeKind::Calls, + confidence: 1.0, + }); + } else if !candidates.is_empty() { + edges.push(Edge { + from_id, + to_id: candidates[0].node_id.clone(), + kind: EdgeKind::Calls, + confidence: 0.4, + }); + } + } + } + } + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + walk_for_edges(&child, source, ctx, scope, edges)?; + } + + Ok(()) +} + +fn infer_containing_function( + node: &TSNode, + source: &[u8], + ctx: &FileContext, +) -> Result> { + let dir = ctx + .path + .parent() + .and_then(|p| p.to_str()) + .unwrap_or("."); + let file = ctx.path.file_name().and_then(|f| f.to_str()).unwrap_or("_"); + + let mut current = node.parent(); + while let Some(parent) = current { + match parent.kind() { + "function_declaration" => { + if let Some(n) = parent.child_by_field_name("name") { + let name = node_text(&n, source); + return Ok(Some(NodeId::new(dir, file, "_", name)?)); + } + } + "method_declaration" => { + if let Some(n) = parent.child_by_field_name("name") { + let name = node_text(&n, source); + let receiver = extract_receiver_type(&parent, source); + return Ok(Some(NodeId::new(dir, file, &receiver, name)?)); + } + } + _ => {} + } + current = parent.parent(); + } + + Ok(None) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn extract_receiver_type(node: &TSNode, source: &[u8]) -> String { + if let Some(recv) = node.child_by_field_name("receiver") { + // parameter_list → parameter_declaration → type + let mut cursor = recv.walk(); + for child in recv.children(&mut cursor) { + if child.kind() == "parameter_declaration" { + if let Some(t) = child.child_by_field_name("type") { + let raw = node_text(&t, source); + // Strip pointer: *Foo → Foo + return raw.trim_start_matches('*').to_string(); + } + } + } + } + "_".to_string() +} + +fn extract_preceding_comments(node: &TSNode, source: &[u8]) -> Option { + let start_row = node.start_position().row; + let parent = node.parent()?; + let mut comments = Vec::new(); + + let mut cursor = parent.walk(); + for sibling in parent.children(&mut cursor) { + if sibling.kind() == "comment" && sibling.end_position().row < start_row { + let text = node_text(&sibling, source); + let stripped = text.trim_start_matches("//").trim(); + if !stripped.is_empty() { + comments.push(stripped.to_string()); + } + } + } + + if comments.is_empty() { + None + } else { + Some(comments.join(" ")) + } +} + +fn extract_function_signature(node: &TSNode, source: &[u8]) -> Signature { + let mut params = Vec::new(); + + if let Some(params_node) = node.child_by_field_name("parameters") { + let mut cursor = params_node.walk(); + for child in params_node.children(&mut cursor) { + if child.kind() == "parameter_declaration" { + let type_ = child + .child_by_field_name("type") + .map(|n| node_text(&n, source).to_string()); + + // A parameter_declaration can have multiple names: a, b int + let mut c2 = child.walk(); + for nc in child.children(&mut c2) { + if nc.kind() == "identifier" { + params.push(Param { + name: node_text(&nc, source).to_string(), + type_: type_.clone(), + }); + } + } + } + } + } + + // Return type(s): could be a single type or (type, error) + let returns = node + .child_by_field_name("result") + .map(|n| node_text(&n, source).to_string()); + + Signature { params, returns } +} + +fn compute_hash(node: &TSNode, source: &[u8]) -> String { + let slice = &source[node.start_byte()..node.end_byte()]; + blake3::hash(slice).to_hex().to_string() +} + +fn node_text<'a>(node: &TSNode, source: &'a [u8]) -> &'a str { + node.utf8_text(source).unwrap_or("") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + fn parse(source: &[u8]) -> FileContext { + spy_parser::parse_file(Path::new("test.go"), source.to_vec(), Language::Go).unwrap() + } + + #[test] + fn test_go_function() { + let ctx = parse(b"package main\nfunc Hello(name string) string { return name }"); + let nodes = GoResolver.extract_nodes(&ctx).unwrap(); + assert!(nodes.iter().any(|n| n.name == "Hello" && n.kind == NodeKind::Function)); + } + + #[test] + fn test_go_method() { + let ctx = parse(b"package main\ntype Foo struct{}\nfunc (f *Foo) Bar() {}"); + let nodes = GoResolver.extract_nodes(&ctx).unwrap(); + assert!(nodes.iter().any(|n| n.name == "Bar" && n.kind == NodeKind::Function)); + } + + #[test] + fn test_go_struct() { + let ctx = parse(b"package main\ntype Server struct { addr string }"); + let nodes = GoResolver.extract_nodes(&ctx).unwrap(); + assert!(nodes.iter().any(|n| n.name == "Server" && n.kind == NodeKind::Class)); + } +} diff --git a/crates/spy-resolvers/src/lib.rs b/crates/spy-resolvers/src/lib.rs index 30a78ef..37f79ce 100644 --- a/crates/spy-resolvers/src/lib.rs +++ b/crates/spy-resolvers/src/lib.rs @@ -1,10 +1,19 @@ +mod go; +mod python; mod rust; +mod ts; +pub use go::GoResolver; +pub use python::PythonResolver; pub use rust::RustResolver; +pub use ts::{JavaScriptResolver, TypeScriptResolver}; pub fn get_resolver(lang: spy_core::Language) -> Option> { match lang { spy_core::Language::Rust => Some(Box::new(RustResolver)), - _ => None, + spy_core::Language::Python => Some(Box::new(PythonResolver)), + spy_core::Language::TypeScript => Some(Box::new(TypeScriptResolver)), + spy_core::Language::JavaScript => Some(Box::new(JavaScriptResolver)), + spy_core::Language::Go => Some(Box::new(GoResolver)), } } diff --git a/crates/spy-resolvers/src/python.rs b/crates/spy-resolvers/src/python.rs new file mode 100644 index 0000000..cc45318 --- /dev/null +++ b/crates/spy-resolvers/src/python.rs @@ -0,0 +1,522 @@ +use anyhow::Result; +use spy_core::{ + Edge, EdgeKind, FileContext, Language, Node, NodeId, NodeKind, Param, ProjectScope, Resolver, + Signature, +}; +use tree_sitter::Node as TSNode; + +pub struct PythonResolver; + +impl Resolver for PythonResolver { + fn language(&self) -> Language { + Language::Python + } + + fn extensions(&self) -> &[&str] { + &["py"] + } + + fn extract_nodes(&self, ctx: &FileContext) -> Result> { + let mut nodes = Vec::new(); + let root = ctx.tree.root_node(); + + let dir = ctx + .path + .parent() + .and_then(|p| p.to_str()) + .unwrap_or("."); + let file = ctx.path.file_name().and_then(|f| f.to_str()).unwrap_or("_"); + + walk_nodes(&root, &ctx.source, dir, file, "_", &mut nodes, ctx)?; + + // Collapse @overload-decorated functions into a single node with multiple signatures + collapse_overloads(&mut nodes); + + Ok(nodes) + } + + fn extract_edges(&self, ctx: &FileContext, scope: &ProjectScope) -> Result> { + let mut edges = Vec::new(); + let root = ctx.tree.root_node(); + + // Build import map: alias/name → module + let import_map = build_import_map(&root, &ctx.source); + + walk_for_edges(&root, &ctx.source, ctx, scope, &import_map, &mut edges)?; + Ok(edges) + } +} + +// --------------------------------------------------------------------------- +// Node extraction +// --------------------------------------------------------------------------- + +fn walk_nodes( + node: &TSNode, + source: &[u8], + dir: &str, + file: &str, + parent_class: &str, + nodes: &mut Vec, + ctx: &FileContext, +) -> Result<()> { + match node.kind() { + "function_definition" => { + if let Some(name_node) = node.child_by_field_name("name") { + let name = node_text(&name_node, source); + let description = extract_docstring(node, source); + let sig = extract_function_signature(node, source); + let content_hash = compute_hash(node, source); + let is_overload = has_overload_decorator(node, source); + + let node_id = NodeId::new(dir, file, parent_class, name)?; + nodes.push(Node { + node_id, + kind: NodeKind::Function, + name: name.to_string(), + description, + signatures: vec![sig], + language: Language::Python, + file_path: ctx.path.to_string_lossy().to_string(), + start_line: node.start_position().row as u32 + 1, + end_line: node.end_position().row as u32 + 1, + content_hash: if is_overload { + format!("overload:{}", content_hash) + } else { + content_hash + }, + git_sha: None, + renamed_from: None, + }); + + // Walk body for nested functions/classes + if let Some(body) = node.child_by_field_name("body") { + let mut cursor = body.walk(); + for child in body.children(&mut cursor) { + walk_nodes(&child, source, dir, file, parent_class, nodes, ctx)?; + } + } + return Ok(()); + } + } + "class_definition" => { + if let Some(name_node) = node.child_by_field_name("name") { + let name = node_text(&name_node, source); + let description = extract_docstring(node, source); + let content_hash = compute_hash(node, source); + + let node_id = NodeId::new(dir, file, "_", name)?; + nodes.push(Node { + node_id, + kind: NodeKind::Class, + name: name.to_string(), + description, + signatures: vec![], + language: Language::Python, + file_path: ctx.path.to_string_lossy().to_string(), + start_line: node.start_position().row as u32 + 1, + end_line: node.end_position().row as u32 + 1, + content_hash, + git_sha: None, + renamed_from: None, + }); + + // Walk class body for methods + if let Some(body) = node.child_by_field_name("body") { + let mut cursor = body.walk(); + for child in body.children(&mut cursor) { + walk_nodes(&child, source, dir, file, name, nodes, ctx)?; + } + } + return Ok(()); + } + } + "expression_statement" => { + // Module-level assignment of a literal → Constant + if let Some(assign) = node.named_child(0) { + if assign.kind() == "assignment" { + if let (Some(left), Some(right)) = ( + assign.child_by_field_name("left"), + assign.child_by_field_name("right"), + ) { + if is_literal(&right) && parent_class == "_" { + let name = node_text(&left, source); + if is_valid_identifier(name) { + let content_hash = compute_hash(node, source); + let node_id = NodeId::new(dir, file, "_", name)?; + nodes.push(Node { + node_id, + kind: NodeKind::Constant, + name: name.to_string(), + description: None, + signatures: vec![], + language: Language::Python, + file_path: ctx.path.to_string_lossy().to_string(), + start_line: node.start_position().row as u32 + 1, + end_line: node.end_position().row as u32 + 1, + content_hash, + git_sha: None, + renamed_from: None, + }); + } + } + } + } + } + } + _ => {} + } + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + walk_nodes(&child, source, dir, file, parent_class, nodes, ctx)?; + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// @overload collapse +// --------------------------------------------------------------------------- + +/// Merge nodes that represent `@typing.overload` variants into a single node +/// with multiple signatures, keyed by `(file_path, parent_class, name)`. +fn collapse_overloads(nodes: &mut Vec) { + use std::collections::HashMap; + + // Group overload variants + let mut overload_map: HashMap> = HashMap::new(); + for (i, node) in nodes.iter().enumerate() { + if node.content_hash.starts_with("overload:") { + let key = format!("{}:{}:{}", node.file_path, node.kind.as_str(), node.name); + overload_map.entry(key).or_default().push(i); + } + } + + // For each group, keep the first occurrence and merge signatures + let mut to_remove = Vec::new(); + for indices in overload_map.values() { + if indices.len() < 2 { + continue; + } + let first = indices[0]; + let extra_sigs: Vec = indices[1..] + .iter() + .flat_map(|&i| nodes[i].signatures.clone()) + .collect(); + nodes[first].signatures.extend(extra_sigs); + nodes[first].content_hash = nodes[first] + .content_hash + .trim_start_matches("overload:") + .to_string(); + to_remove.extend_from_slice(&indices[1..]); + } + + // Remove merged duplicates (highest index first to preserve positions) + to_remove.sort_unstable_by(|a, b| b.cmp(a)); + to_remove.dedup(); + for idx in to_remove { + nodes.remove(idx); + } +} + +// --------------------------------------------------------------------------- +// Edge extraction +// --------------------------------------------------------------------------- + +/// Build a map from imported name/alias → module string. +fn build_import_map(root: &TSNode, source: &[u8]) -> std::collections::HashMap { + let mut map = std::collections::HashMap::new(); + let mut cursor = root.walk(); + + for child in root.children(&mut cursor) { + match child.kind() { + "import_statement" => { + // import foo, import foo as bar + let mut c2 = child.walk(); + for name_node in child.children(&mut c2) { + if name_node.kind() == "dotted_name" { + let name = node_text(&name_node, source); + map.insert(name.split('.').last().unwrap_or(name).to_string(), name.to_string()); + } else if name_node.kind() == "aliased_import" { + if let (Some(n), Some(a)) = ( + name_node.child_by_field_name("name"), + name_node.child_by_field_name("alias"), + ) { + map.insert(node_text(&a, source).to_string(), node_text(&n, source).to_string()); + } + } + } + } + "import_from_statement" => { + // from foo import bar, baz + let module = child + .child_by_field_name("module_name") + .map(|n| node_text(&n, source).to_string()) + .unwrap_or_default(); + + let mut c2 = child.walk(); + for name_node in child.children(&mut c2) { + if name_node.kind() == "dotted_name" { + let name = node_text(&name_node, source); + map.insert(name.to_string(), format!("{}.{}", module, name)); + } else if name_node.kind() == "aliased_import" { + if let Some(a) = name_node.child_by_field_name("alias") { + map.insert(node_text(&a, source).to_string(), module.clone()); + } + } + } + } + _ => {} + } + } + map +} + +fn walk_for_edges( + node: &TSNode, + source: &[u8], + ctx: &FileContext, + scope: &ProjectScope, + import_map: &std::collections::HashMap, + edges: &mut Vec, +) -> Result<()> { + match node.kind() { + "call" => { + if let Some(func_node) = node.child_by_field_name("function") { + let func_text = node_text(&func_node, source); + // Strip attribute access: foo.bar → try "bar" + let bare_name = func_text.split('.').last().unwrap_or(func_text); + let from_id = infer_containing_function(node, source, ctx)?; + if let Some(from_id) = from_id { + let candidates = scope.find_nodes_by_name(bare_name); + if candidates.len() == 1 { + edges.push(Edge { + from_id, + to_id: candidates[0].node_id.clone(), + kind: EdgeKind::Calls, + confidence: 1.0, + }); + } else if !candidates.is_empty() { + edges.push(Edge { + from_id, + to_id: candidates[0].node_id.clone(), + kind: EdgeKind::Calls, + confidence: 0.4, + }); + } + let _ = import_map; // used for future module-qualified resolution + } + } + } + "import_statement" | "import_from_statement" => { + // Emit import edges from a file-level sentinel node (future work) + // For now, skip. + } + _ => {} + } + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + walk_for_edges(&child, source, ctx, scope, import_map, edges)?; + } + + Ok(()) +} + +fn infer_containing_function( + node: &TSNode, + source: &[u8], + ctx: &FileContext, +) -> Result> { + let dir = ctx + .path + .parent() + .and_then(|p| p.to_str()) + .unwrap_or("."); + let file = ctx.path.file_name().and_then(|f| f.to_str()).unwrap_or("_"); + + let mut current = node.parent(); + let mut class_name = "_"; + + while let Some(parent) = current { + if parent.kind() == "class_definition" { + if let Some(n) = parent.child_by_field_name("name") { + class_name = node_text(&n, source); + } + } + if parent.kind() == "function_definition" { + if let Some(name_node) = parent.child_by_field_name("name") { + let name = node_text(&name_node, source); + return Ok(Some(NodeId::new(dir, file, class_name, name)?)); + } + } + current = parent.parent(); + } + + Ok(None) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn extract_docstring(node: &TSNode, source: &[u8]) -> Option { + // First child of the body that is an expression_statement containing a string + let body = node.child_by_field_name("body")?; + let mut cursor = body.walk(); + for child in body.children(&mut cursor) { + if child.kind() == "expression_statement" { + if let Some(expr) = child.named_child(0) { + if expr.kind() == "string" { + let raw = node_text(&expr, source); + // Strip quotes + let stripped = raw + .trim_start_matches("\"\"\"") + .trim_end_matches("\"\"\"") + .trim_start_matches("'''") + .trim_end_matches("'''") + .trim_start_matches('"') + .trim_end_matches('"') + .trim_start_matches('\'') + .trim_end_matches('\'') + .trim(); + if !stripped.is_empty() { + return Some(stripped.to_string()); + } + } + } + } + break; // only first statement + } + None +} + +fn extract_function_signature(node: &TSNode, source: &[u8]) -> Signature { + let mut params = Vec::new(); + + if let Some(params_node) = node.child_by_field_name("parameters") { + let mut cursor = params_node.walk(); + for child in params_node.children(&mut cursor) { + match child.kind() { + "identifier" => { + // positional param without annotation + let name = node_text(&child, source); + if name != "self" && name != "cls" { + params.push(Param { name: name.to_string(), type_: None }); + } + } + "typed_parameter" => { + let name = child + .child_by_field_name("name") + .or_else(|| child.named_child(0)) + .map(|n| node_text(&n, source).to_string()) + .unwrap_or_default(); + let type_ = child + .child_by_field_name("type") + .map(|n| node_text(&n, source).to_string()); + if !name.is_empty() && name != "self" && name != "cls" { + params.push(Param { name, type_ }); + } + } + "default_parameter" | "typed_default_parameter" => { + let name = child + .child_by_field_name("name") + .map(|n| node_text(&n, source).to_string()) + .unwrap_or_default(); + let type_ = child + .child_by_field_name("type") + .map(|n| node_text(&n, source).to_string()); + if !name.is_empty() && name != "self" && name != "cls" { + params.push(Param { name, type_ }); + } + } + _ => {} + } + } + } + + let returns = node + .child_by_field_name("return_type") + .map(|n| node_text(&n, source).to_string()); + + Signature { params, returns } +} + +fn has_overload_decorator(node: &TSNode, source: &[u8]) -> bool { + if let Some(parent) = node.parent() { + let mut cursor = parent.walk(); + for sibling in parent.children(&mut cursor) { + if sibling.kind() == "decorator" { + let text = node_text(&sibling, source); + if text.contains("overload") { + return true; + } + } + } + } + false +} + +fn is_literal(node: &TSNode) -> bool { + matches!( + node.kind(), + "integer" + | "float" + | "string" + | "true" + | "false" + | "none" + | "concatenated_string" + ) +} + +fn is_valid_identifier(s: &str) -> bool { + !s.is_empty() + && s.chars().next().map(|c| c.is_alphabetic() || c == '_').unwrap_or(false) + && s.chars().all(|c| c.is_alphanumeric() || c == '_') +} + +fn compute_hash(node: &TSNode, source: &[u8]) -> String { + let slice = &source[node.start_byte()..node.end_byte()]; + blake3::hash(slice).to_hex().to_string() +} + +fn node_text<'a>(node: &TSNode, source: &'a [u8]) -> &'a str { + node.utf8_text(source).unwrap_or("") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + fn parse(source: &[u8]) -> FileContext { + spy_parser::parse_file(Path::new("test.py"), source.to_vec(), Language::Python).unwrap() + } + + #[test] + fn test_extract_function() { + let ctx = parse(b"def hello(x, y): pass"); + let nodes = PythonResolver.extract_nodes(&ctx).unwrap(); + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].name, "hello"); + assert_eq!(nodes[0].kind, NodeKind::Function); + assert_eq!(nodes[0].signatures[0].params.len(), 2); + } + + #[test] + fn test_extract_class() { + let ctx = parse(b"class Foo:\n def bar(self): pass"); + let nodes = PythonResolver.extract_nodes(&ctx).unwrap(); + assert!(nodes.iter().any(|n| n.name == "Foo" && n.kind == NodeKind::Class)); + assert!(nodes.iter().any(|n| n.name == "bar" && n.kind == NodeKind::Function)); + } + + #[test] + fn test_extract_constant() { + let ctx = parse(b"MAX = 100"); + let nodes = PythonResolver.extract_nodes(&ctx).unwrap(); + assert!(nodes.iter().any(|n| n.name == "MAX" && n.kind == NodeKind::Constant)); + } +} diff --git a/crates/spy-resolvers/src/ts.rs b/crates/spy-resolvers/src/ts.rs new file mode 100644 index 0000000..4e6dac2 --- /dev/null +++ b/crates/spy-resolvers/src/ts.rs @@ -0,0 +1,469 @@ +use anyhow::Result; +use spy_core::{ + Edge, EdgeKind, FileContext, Language, Node, NodeId, NodeKind, Param, ProjectScope, Resolver, + Signature, +}; +use tree_sitter::Node as TSNode; + +pub struct TypeScriptResolver; +pub struct JavaScriptResolver; + +impl Resolver for TypeScriptResolver { + fn language(&self) -> Language { + Language::TypeScript + } + fn extensions(&self) -> &[&str] { + &["ts", "tsx"] + } + fn extract_nodes(&self, ctx: &FileContext) -> Result> { + extract_js_nodes(ctx) + } + fn extract_edges(&self, ctx: &FileContext, scope: &ProjectScope) -> Result> { + extract_js_edges(ctx, scope) + } +} + +impl Resolver for JavaScriptResolver { + fn language(&self) -> Language { + Language::JavaScript + } + fn extensions(&self) -> &[&str] { + &["js", "jsx", "mjs", "cjs"] + } + fn extract_nodes(&self, ctx: &FileContext) -> Result> { + extract_js_nodes(ctx) + } + fn extract_edges(&self, ctx: &FileContext, scope: &ProjectScope) -> Result> { + extract_js_edges(ctx, scope) + } +} + +// --------------------------------------------------------------------------- +// Node extraction +// --------------------------------------------------------------------------- + +fn extract_js_nodes(ctx: &FileContext) -> Result> { + let mut nodes = Vec::new(); + let root = ctx.tree.root_node(); + + let dir = ctx + .path + .parent() + .and_then(|p| p.to_str()) + .unwrap_or("."); + let file = ctx.path.file_name().and_then(|f| f.to_str()).unwrap_or("_"); + + walk_nodes(&root, &ctx.source, dir, file, "_", &mut nodes, ctx)?; + + // Collapse TypeScript function overloads (same name, consecutive declarations) + collapse_overloads(&mut nodes); + + Ok(nodes) +} + +fn walk_nodes( + node: &TSNode, + source: &[u8], + dir: &str, + file: &str, + parent_class: &str, + nodes: &mut Vec, + ctx: &FileContext, +) -> Result<()> { + match node.kind() { + "function_declaration" | "generator_function_declaration" => { + if let Some(name_node) = node.child_by_field_name("name") { + let name = node_text(&name_node, source); + let description = extract_jsdoc(node, source); + let sig = extract_function_signature(node, source); + let content_hash = compute_hash(node, source); + let node_id = NodeId::new(dir, file, parent_class, name)?; + nodes.push(Node { + node_id, + kind: NodeKind::Function, + name: name.to_string(), + description, + signatures: vec![sig], + language: ctx.language, + file_path: ctx.path.to_string_lossy().to_string(), + start_line: node.start_position().row as u32 + 1, + end_line: node.end_position().row as u32 + 1, + content_hash, + git_sha: None, + renamed_from: None, + }); + } + } + "class_declaration" | "abstract_class_declaration" => { + if let Some(name_node) = node.child_by_field_name("name") { + let name = node_text(&name_node, source); + let description = extract_jsdoc(node, source); + let content_hash = compute_hash(node, source); + let node_id = NodeId::new(dir, file, "_", name)?; + nodes.push(Node { + node_id, + kind: NodeKind::Class, + name: name.to_string(), + description, + signatures: vec![], + language: ctx.language, + file_path: ctx.path.to_string_lossy().to_string(), + start_line: node.start_position().row as u32 + 1, + end_line: node.end_position().row as u32 + 1, + content_hash, + git_sha: None, + renamed_from: None, + }); + // Walk class body + if let Some(body) = node.child_by_field_name("body") { + let mut cursor = body.walk(); + for child in body.children(&mut cursor) { + walk_nodes(&child, source, dir, file, name, nodes, ctx)?; + } + } + return Ok(()); + } + } + "method_definition" => { + if let Some(name_node) = node.child_by_field_name("name") { + let name = node_text(&name_node, source); + let description = extract_jsdoc(node, source); + let sig = extract_function_signature(node, source); + let content_hash = compute_hash(node, source); + let node_id = NodeId::new(dir, file, parent_class, name)?; + nodes.push(Node { + node_id, + kind: NodeKind::Function, + name: name.to_string(), + description, + signatures: vec![sig], + language: ctx.language, + file_path: ctx.path.to_string_lossy().to_string(), + start_line: node.start_position().row as u32 + 1, + end_line: node.end_position().row as u32 + 1, + content_hash, + git_sha: None, + renamed_from: None, + }); + } + } + "lexical_declaration" | "variable_declaration" => { + // const FOO = ... or const foo = function() {} / () => {} + let mut cursor = node.walk(); + for decl in node.children(&mut cursor) { + if decl.kind() == "variable_declarator" { + if let Some(name_node) = decl.child_by_field_name("name") { + let name = node_text(&name_node, source); + if let Some(value) = decl.child_by_field_name("value") { + match value.kind() { + "arrow_function" | "function" | "generator_function" => { + let description = extract_jsdoc(node, source); + let sig = extract_function_signature(&value, source); + let content_hash = compute_hash(&value, source); + let node_id = NodeId::new(dir, file, parent_class, name)?; + nodes.push(Node { + node_id, + kind: NodeKind::Function, + name: name.to_string(), + description, + signatures: vec![sig], + language: ctx.language, + file_path: ctx.path.to_string_lossy().to_string(), + start_line: node.start_position().row as u32 + 1, + end_line: node.end_position().row as u32 + 1, + content_hash, + git_sha: None, + renamed_from: None, + }); + } + _ => { + // Only emit as Constant if it's a literal and top-level + if parent_class == "_" && is_literal(&value) { + let description = extract_jsdoc(node, source); + let content_hash = compute_hash(node, source); + let node_id = NodeId::new(dir, file, "_", name)?; + nodes.push(Node { + node_id, + kind: NodeKind::Constant, + name: name.to_string(), + description, + signatures: vec![], + language: ctx.language, + file_path: ctx.path.to_string_lossy().to_string(), + start_line: node.start_position().row as u32 + 1, + end_line: node.end_position().row as u32 + 1, + content_hash, + git_sha: None, + renamed_from: None, + }); + } + } + } + } + } + } + } + } + _ => {} + } + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + walk_nodes(&child, source, dir, file, parent_class, nodes, ctx)?; + } + + Ok(()) +} + +fn collapse_overloads(nodes: &mut Vec) { + use std::collections::HashMap; + + let mut groups: HashMap> = HashMap::new(); + for (i, n) in nodes.iter().enumerate() { + // Two consecutive declarations with the same name and no body are TS overloads + let key = format!("{}:{}:{}", n.file_path, n.kind.as_str(), n.name); + groups.entry(key).or_default().push(i); + } + + let mut to_remove = Vec::new(); + for indices in groups.values() { + if indices.len() < 2 { + continue; + } + let first = indices[0]; + let extra_sigs: Vec = indices[1..] + .iter() + .flat_map(|&i| nodes[i].signatures.clone()) + .collect(); + nodes[first].signatures.extend(extra_sigs); + to_remove.extend_from_slice(&indices[1..]); + } + + to_remove.sort_unstable_by(|a, b| b.cmp(a)); + to_remove.dedup(); + for idx in to_remove { + nodes.remove(idx); + } +} + +// --------------------------------------------------------------------------- +// Edge extraction +// --------------------------------------------------------------------------- + +fn extract_js_edges(ctx: &FileContext, scope: &ProjectScope) -> Result> { + let mut edges = Vec::new(); + let root = ctx.tree.root_node(); + walk_for_edges(&root, &ctx.source, ctx, scope, &mut edges)?; + Ok(edges) +} + +fn walk_for_edges( + node: &TSNode, + source: &[u8], + ctx: &FileContext, + scope: &ProjectScope, + edges: &mut Vec, +) -> Result<()> { + if node.kind() == "call_expression" { + if let Some(func_node) = node.child_by_field_name("function") { + let func_text = node_text(&func_node, source); + let bare = func_text.split('.').last().unwrap_or(func_text); + if let Some(from_id) = infer_containing_function(node, source, ctx)? { + let candidates = scope.find_nodes_by_name(bare); + if candidates.len() == 1 { + edges.push(Edge { + from_id, + to_id: candidates[0].node_id.clone(), + kind: EdgeKind::Calls, + confidence: 1.0, + }); + } else if !candidates.is_empty() { + edges.push(Edge { + from_id, + to_id: candidates[0].node_id.clone(), + kind: EdgeKind::Calls, + confidence: 0.4, + }); + } + } + } + } + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + walk_for_edges(&child, source, ctx, scope, edges)?; + } + + Ok(()) +} + +fn infer_containing_function( + node: &TSNode, + source: &[u8], + ctx: &FileContext, +) -> Result> { + let dir = ctx + .path + .parent() + .and_then(|p| p.to_str()) + .unwrap_or("."); + let file = ctx.path.file_name().and_then(|f| f.to_str()).unwrap_or("_"); + + let mut current = node.parent(); + let mut class_name = "_".to_string(); + + while let Some(parent) = current { + if matches!(parent.kind(), "class_declaration" | "abstract_class_declaration") { + if let Some(n) = parent.child_by_field_name("name") { + class_name = node_text(&n, source).to_string(); + } + } + if matches!( + parent.kind(), + "function_declaration" + | "arrow_function" + | "function" + | "method_definition" + | "generator_function_declaration" + ) { + if let Some(name_node) = parent.child_by_field_name("name") { + let name = node_text(&name_node, source); + return Ok(Some(NodeId::new(dir, file, &class_name, name)?)); + } + } + current = parent.parent(); + } + + Ok(None) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn extract_jsdoc(node: &TSNode, source: &[u8]) -> Option { + let start_row = node.start_position().row; + let parent = node.parent()?; + let mut cursor = parent.walk(); + + for sibling in parent.children(&mut cursor) { + if sibling.kind() == "comment" && sibling.end_position().row < start_row { + let text = node_text(&sibling, source); + if text.starts_with("/**") { + let stripped = text + .trim_start_matches("/**") + .trim_end_matches("*/") + .lines() + .map(|l| l.trim_start_matches('*').trim()) + .filter(|l| !l.is_empty()) + .collect::>() + .join(" "); + if !stripped.is_empty() { + return Some(stripped); + } + } + } + } + None +} + +fn extract_function_signature(node: &TSNode, source: &[u8]) -> Signature { + let mut params = Vec::new(); + + if let Some(params_node) = node.child_by_field_name("parameters") { + let mut cursor = params_node.walk(); + for child in params_node.children(&mut cursor) { + match child.kind() { + "identifier" => { + params.push(Param { + name: node_text(&child, source).to_string(), + type_: None, + }); + } + "required_parameter" | "optional_parameter" => { + let name = child + .child_by_field_name("pattern") + .map(|n| node_text(&n, source).to_string()) + .unwrap_or_default(); + let type_ = child + .child_by_field_name("type") + .map(|n| node_text(&n, source).to_string()); + if !name.is_empty() { + params.push(Param { name, type_ }); + } + } + "assignment_pattern" => { + if let Some(left) = child.child_by_field_name("left") { + params.push(Param { + name: node_text(&left, source).to_string(), + type_: None, + }); + } + } + _ => {} + } + } + } + + let returns = node + .child_by_field_name("return_type") + .map(|n| node_text(&n, source).to_string()); + + Signature { params, returns } +} + +fn is_literal(node: &TSNode) -> bool { + matches!( + node.kind(), + "number" | "string" | "template_string" | "true" | "false" | "null" | "undefined" + ) +} + +fn compute_hash(node: &TSNode, source: &[u8]) -> String { + let slice = &source[node.start_byte()..node.end_byte()]; + blake3::hash(slice).to_hex().to_string() +} + +fn node_text<'a>(node: &TSNode, source: &'a [u8]) -> &'a str { + node.utf8_text(source).unwrap_or("") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + fn parse_ts(source: &[u8]) -> FileContext { + spy_parser::parse_file(Path::new("test.ts"), source.to_vec(), Language::TypeScript) + .unwrap() + } + + fn parse_js(source: &[u8]) -> FileContext { + spy_parser::parse_file(Path::new("test.js"), source.to_vec(), Language::JavaScript) + .unwrap() + } + + #[test] + fn test_ts_function() { + let ctx = parse_ts(b"function greet(name: string): void {}"); + let nodes = TypeScriptResolver.extract_nodes(&ctx).unwrap(); + assert_eq!(nodes.len(), 1); + assert_eq!(nodes[0].name, "greet"); + assert_eq!(nodes[0].kind, NodeKind::Function); + } + + #[test] + fn test_ts_class() { + let ctx = parse_ts(b"class Foo { bar(): void {} }"); + let nodes = TypeScriptResolver.extract_nodes(&ctx).unwrap(); + assert!(nodes.iter().any(|n| n.name == "Foo" && n.kind == NodeKind::Class)); + assert!(nodes.iter().any(|n| n.name == "bar" && n.kind == NodeKind::Function)); + } + + #[test] + fn test_js_arrow_function() { + let ctx = parse_js(b"const add = (a, b) => a + b;"); + let nodes = JavaScriptResolver.extract_nodes(&ctx).unwrap(); + assert!(nodes.iter().any(|n| n.name == "add" && n.kind == NodeKind::Function)); + } +} diff --git a/crates/spy-storage/src/lib.rs b/crates/spy-storage/src/lib.rs index 584f617..eed3ee4 100644 --- a/crates/spy-storage/src/lib.rs +++ b/crates/spy-storage/src/lib.rs @@ -443,6 +443,84 @@ impl Storage { Ok(results) } + pub fn list_files(&self) -> Result> { + let mut stmt = self.conn.prepare("SELECT path FROM files ORDER BY path")?; + let rows = stmt.query_map([], |row| row.get(0))?; + let mut paths = Vec::new(); + for row in rows { + paths.push(row?); + } + Ok(paths) + } + + pub fn get_nodes_for_files(&self, file_paths: &[String]) -> Result> { + if file_paths.is_empty() { + return Ok(vec![]); + } + let placeholders: String = file_paths + .iter() + .enumerate() + .map(|(i, _)| format!("?{}", i + 1)) + .collect::>() + .join(", "); + let query = format!( + "SELECT node_id, kind, name, description, signatures, language, + file_path, start_line, end_line, content_hash, git_sha, renamed_from + FROM nodes WHERE file_path IN ({})", + placeholders + ); + let mut stmt = self.conn.prepare(&query)?; + let rows = stmt.query_map( + rusqlite::params_from_iter(file_paths.iter()), + |row| { + let signatures_str: String = row.get(4)?; + let signatures = serde_json::from_str(&signatures_str) + .map_err(|_| rusqlite::Error::InvalidQuery)?; + let kind_str: String = row.get(1)?; + let kind = match kind_str.as_str() { + "function" => spy_core::NodeKind::Function, + "class" => spy_core::NodeKind::Class, + "constant" => spy_core::NodeKind::Constant, + _ => return Err(rusqlite::Error::InvalidQuery), + }; + let lang_str: String = row.get(5)?; + let language = match lang_str.as_str() { + "rust" => spy_core::Language::Rust, + "python" => spy_core::Language::Python, + "typescript" => spy_core::Language::TypeScript, + "javascript" => spy_core::Language::JavaScript, + "go" => spy_core::Language::Go, + _ => return Err(rusqlite::Error::InvalidQuery), + }; + let renamed_from_str: Option = row.get(11)?; + let renamed_from = renamed_from_str + .map(|s| NodeId::from_string(s)) + .transpose() + .map_err(|_| rusqlite::Error::InvalidQuery)?; + Ok(Node { + node_id: NodeId::from_string(row.get(0)?) + .map_err(|_| rusqlite::Error::InvalidQuery)?, + kind, + name: row.get(2)?, + description: row.get(3)?, + signatures, + language, + file_path: row.get(6)?, + start_line: row.get(7)?, + end_line: row.get(8)?, + content_hash: row.get(9)?, + git_sha: row.get(10)?, + renamed_from, + }) + }, + )?; + let mut results = Vec::new(); + for row in rows { + results.push(row?); + } + Ok(results) + } + pub fn get_stats(&self) -> Result { let node_count: i64 = self.conn.query_row("SELECT COUNT(*) FROM nodes", [], |row| row.get(0))?; let edge_count: i64 = self.conn.query_row( diff --git a/tests/fixtures/go_sample/animals.go b/tests/fixtures/go_sample/animals.go new file mode 100644 index 0000000..67291ad --- /dev/null +++ b/tests/fixtures/go_sample/animals.go @@ -0,0 +1,21 @@ +package animals + +// Animal represents a generic animal. +type Animal struct { + Name string +} + +// Speak makes the animal produce a sound. +func (a *Animal) Speak() string { + return "" +} + +// Dog is a dog. +type Dog struct { + Animal +} + +// Speak makes the dog bark. +func (d *Dog) Speak() string { + return d.Name + " says woof" +} diff --git a/tests/fixtures/go_sample/math.go b/tests/fixtures/go_sample/math.go new file mode 100644 index 0000000..1b49af5 --- /dev/null +++ b/tests/fixtures/go_sample/math.go @@ -0,0 +1,13 @@ +package math + +// Add returns the sum of two integers. +func Add(a, b int) int { + return a + b +} + +// Subtract returns a minus b. +func Subtract(a, b int) int { + return a - b +} + +const MaxValue = 1000 diff --git a/tests/fixtures/python_sample/animals.py b/tests/fixtures/python_sample/animals.py new file mode 100644 index 0000000..a9f8c0a --- /dev/null +++ b/tests/fixtures/python_sample/animals.py @@ -0,0 +1,16 @@ +class Animal: + """Base class for all animals.""" + + def __init__(self, name: str) -> None: + self.name = name + + def speak(self) -> str: + """Make the animal speak.""" + return "" + + +class Dog(Animal): + """A dog.""" + + def speak(self) -> str: + return f"{self.name} says woof" diff --git a/tests/fixtures/python_sample/math.py b/tests/fixtures/python_sample/math.py new file mode 100644 index 0000000..9c0430b --- /dev/null +++ b/tests/fixtures/python_sample/math.py @@ -0,0 +1,11 @@ +def add(x: int, y: int) -> int: + """Add two numbers.""" + return x + y + + +def subtract(x: int, y: int) -> int: + """Subtract y from x.""" + return x - y + + +MAX = 1000 diff --git a/tests/fixtures/rust_sample/math.rs b/tests/fixtures/rust_sample/math.rs new file mode 100644 index 0000000..9f6b651 --- /dev/null +++ b/tests/fixtures/rust_sample/math.rs @@ -0,0 +1,11 @@ +/// A simple adder +pub fn add(a: i32, b: i32) -> i32 { + a + b +} + +/// Subtract two numbers +pub fn subtract(a: i32, b: i32) -> i32 { + a - b +} + +pub const MAX_VALUE: i32 = 1000; diff --git a/tests/fixtures/rust_sample/traits.rs b/tests/fixtures/rust_sample/traits.rs new file mode 100644 index 0000000..8304a83 --- /dev/null +++ b/tests/fixtures/rust_sample/traits.rs @@ -0,0 +1,30 @@ +use std::fmt; + +/// A simple animal trait +pub trait Animal { + fn name(&self) -> &str; + fn sound(&self) -> &str; + fn describe(&self) -> String { + format!("{} says {}", self.name(), self.sound()) + } +} + +pub struct Dog { + pub name: String, +} + +impl Animal for Dog { + fn name(&self) -> &str { + &self.name + } + + fn sound(&self) -> &str { + "woof" + } +} + +impl fmt::Display for Dog { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Dog({})", self.name) + } +} diff --git a/tests/fixtures/ts_sample/animals.ts b/tests/fixtures/ts_sample/animals.ts new file mode 100644 index 0000000..c307aa6 --- /dev/null +++ b/tests/fixtures/ts_sample/animals.ts @@ -0,0 +1,15 @@ +/** Base animal class */ +export class Animal { + constructor(public name: string) {} + + /** Make the animal speak */ + speak(): string { + return ""; + } +} + +export class Dog extends Animal { + speak(): string { + return `${this.name} says woof`; + } +} diff --git a/tests/fixtures/ts_sample/math.ts b/tests/fixtures/ts_sample/math.ts new file mode 100644 index 0000000..9c38d86 --- /dev/null +++ b/tests/fixtures/ts_sample/math.ts @@ -0,0 +1,11 @@ +/** Add two numbers */ +export function add(a: number, b: number): number { + return a + b; +} + +/** Subtract b from a */ +export function subtract(a: number, b: number): number { + return a - b; +} + +export const MAX_VALUE = 1000;