From 45da2eda1a0b94dcb2db14458fb26e803c1a0fd5 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Sun, 25 Jan 2026 15:08:11 +0800 Subject: [PATCH 1/4] refactor: introduce NodeKind enum for improved type handling across the codebase; update references to node kinds in various modules and enhance edge filtering logic in the LSP implementation. --- src/lsp/goto.rs | 10 ++++ src/lsp/hierarchy.rs | 4 +- src/lsp/symbols.rs | 2 +- src/mcp/mod.rs | 42 ++++++++------- src/model/graph.rs | 73 ++++++++++++++++++++++---- src/parser/java/ast.rs | 99 +++++++++++++++++++++++++++++++---- src/parser/java/lsp.rs | 31 +++++------ src/parser/mod.rs | 21 +++----- src/parser/utils.rs | 4 +- src/query/dsl.rs | 49 ++++++++--------- src/query/engine.rs | 32 +++++------ src/resolver/lang/java/mod.rs | 8 +-- 12 files changed, 260 insertions(+), 115 deletions(-) diff --git a/src/lsp/goto.rs b/src/lsp/goto.rs index 781fa53..23c0d50 100644 --- a/src/lsp/goto.rs +++ b/src/lsp/goto.rs @@ -235,6 +235,16 @@ pub async fn references( .detach(); while let Some((edge_idx, neighbor_idx)) = incoming.next(&index.topology) { let edge = &index.topology[edge_idx]; + + // Filter edges for references + match edge.edge_type { + crate::model::graph::EdgeType::Calls | + crate::model::graph::EdgeType::Instantiates | + crate::model::graph::EdgeType::TypedAs | + crate::model::graph::EdgeType::DecoratedBy => {}, + _ => continue, + } + let source_node = &index.topology[neighbor_idx]; if let (Some(source_path), Some(range)) = (source_node.file_path(), &edge.range) diff --git a/src/lsp/hierarchy.rs b/src/lsp/hierarchy.rs index 40b73d4..0d1c19f 100644 --- a/src/lsp/hierarchy.rs +++ b/src/lsp/hierarchy.rs @@ -1,7 +1,7 @@ use tower_lsp::jsonrpc::Result; use tower_lsp::lsp_types::*; use crate::lsp::LspServer; -use crate::model::graph::EdgeType; +use crate::model::graph::{EdgeType, NodeKind}; pub async fn prepare_call_hierarchy(server: &LspServer, params: CallHierarchyPrepareParams) -> Result>> { @@ -45,7 +45,7 @@ pub async fn prepare_call_hierarchy(server: &LspServer, params: CallHierarchyPre for idx in matches { let node = &index.topology[idx]; let kind = node.kind(); - if kind == "method" || kind == "constructor" { + if kind == NodeKind::Method || kind == NodeKind::Constructor { if let (Some(target_path), Some(range)) = (node.file_path(), node.range()) { let lsp_range = Range { start: Position::new(range.start_line as u32, range.start_col as u32), diff --git a/src/lsp/symbols.rs b/src/lsp/symbols.rs index db40f5e..cc05470 100644 --- a/src/lsp/symbols.rs +++ b/src/lsp/symbols.rs @@ -63,7 +63,7 @@ pub async fn workspace_symbol(server: &LspServer, params: WorkspaceSymbolParams) if node.name().to_lowercase().contains(&query) || node.fqn().to_string().to_lowercase().contains(&query) { if let (Some(path), Some(range)) = (node.file_path(), node.range()) { let kind = server.resolver.get_lsp_parser(node.language()) - .map(|parser| parser.symbol_kind(node.kind())) + .map(|parser| parser.symbol_kind(&node.kind())) .unwrap_or(SymbolKind::VARIABLE); #[allow(deprecated)] diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index b4d598e..69347b1 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -7,7 +7,7 @@ use rmcp::{ use crate::query::GraphQuery; use crate::index::Naviscope; use crate::query::QueryEngine; -use crate::model::graph::EdgeType; +use crate::model::graph::{EdgeType, NodeKind}; use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::sync::RwLock; @@ -58,6 +58,8 @@ pub struct LsArgs { pub fqn: Option, /// Optional: Filter results by element type (e.g., ["class", "method"]) pub kind: Option>, + /// Optional: Filter results by modifiers (e.g. ["public", "static"]) + pub modifiers: Option>, } #[derive(Deserialize, JsonSchema)] @@ -67,9 +69,13 @@ pub struct InspectArgs { } #[derive(Deserialize, JsonSchema)] -pub struct EdgeArgs { +pub struct DepsArgs { /// The Fully Qualified Name (FQN) of the target code element pub fqn: String, + /// If true, find incoming dependencies (who depends on me). + /// If false (default), find outgoing dependencies (who do I depend on). + #[serde(default)] + pub rev: bool, /// Optional: Filter by relationship types (e.g., ["Calls", "InheritsFrom"]) pub edge_type: Option>, } @@ -114,12 +120,19 @@ impl McpServer { } } + fn to_node_kinds(kinds: Option>) -> Vec { + kinds.unwrap_or_default() + .iter() + .map(|s| NodeKind::from(s.as_str())) + .collect() + } + #[tool(description = "Search for code elements (classes, methods, fields, etc.) across the project using a name pattern or regex. Use this to find definitions when you only know a name or part of it.")] pub async fn grep(&self, params: Parameters) -> Result { let args = params.0; self.execute_query(GraphQuery::Grep { pattern: args.pattern, - kind: args.kind.unwrap_or_default(), + kind: Self::to_node_kinds(args.kind), limit: args.limit.unwrap_or(20) }).await } @@ -129,31 +142,24 @@ impl McpServer { let args = params.0; self.execute_query(GraphQuery::Ls { fqn: args.fqn, - kind: args.kind.unwrap_or_default() + kind: Self::to_node_kinds(args.kind), + modifiers: args.modifiers.unwrap_or_default(), }).await } #[tool(description = "Retrieve detailed information about a specific code element by its Fully Qualified Name (FQN), including its source code snippet, location, and metadata.")] pub async fn inspect(&self, params: Parameters) -> Result { let args = params.0; - self.execute_query(GraphQuery::Inspect { fqn: args.fqn }).await - } - - #[tool(description = "Find all code elements that depend on, call, or reference the specified FQN. Use this to perform impact analysis or find usages of a class/method.")] - pub async fn incoming(&self, params: Parameters) -> Result { - let args = params.0; - self.execute_query(GraphQuery::Incoming { - fqn: args.fqn, - edge_type: args.edge_type.unwrap_or_default() - }).await + self.execute_query(GraphQuery::Cat { fqn: args.fqn }).await } - #[tool(description = "Find all code elements that the specified FQN depends on, calls, or references. Use this to understand the dependencies or implementation details of a class/method.")] - pub async fn outgoing(&self, params: Parameters) -> Result { + #[tool(description = "Analyze dependencies for a given FQN. By default, shows outgoing dependencies (who I depend on). Use rev=true for incoming dependencies (who depends on me/impact analysis).")] + pub async fn deps(&self, params: Parameters) -> Result { let args = params.0; - self.execute_query(GraphQuery::Outgoing { + self.execute_query(GraphQuery::Deps { fqn: args.fqn, - edge_type: args.edge_type.unwrap_or_default() + rev: args.rev, + edge_types: args.edge_type.unwrap_or_default() }).await } } diff --git a/src/model/graph.rs b/src/model/graph.rs index 6e44acc..979e7d1 100644 --- a/src/model/graph.rs +++ b/src/model/graph.rs @@ -29,6 +29,60 @@ impl Range { } } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum NodeKind { + Class, + Interface, + Enum, + Annotation, + Method, + Constructor, + Field, + // Build specific + Dependency, + Task, + Plugin, + // Fallback + Other, +} + +impl From<&str> for NodeKind { + fn from(s: &str) -> Self { + match s.to_lowercase().as_str() { + "class" => NodeKind::Class, + "interface" => NodeKind::Interface, + "enum" => NodeKind::Enum, + "annotation" => NodeKind::Annotation, + "method" => NodeKind::Method, + "constructor" => NodeKind::Constructor, + "field" => NodeKind::Field, + "dependency" => NodeKind::Dependency, + "task" => NodeKind::Task, + "plugin" => NodeKind::Plugin, + _ => NodeKind::Other, + } + } +} + +impl ToString for NodeKind { + fn to_string(&self) -> String { + match self { + NodeKind::Class => "class".to_string(), + NodeKind::Interface => "interface".to_string(), + NodeKind::Enum => "enum".to_string(), + NodeKind::Annotation => "annotation".to_string(), + NodeKind::Method => "method".to_string(), + NodeKind::Constructor => "constructor".to_string(), + NodeKind::Field => "field".to_string(), + NodeKind::Dependency => "dependency".to_string(), + NodeKind::Task => "task".to_string(), + NodeKind::Plugin => "plugin".to_string(), + NodeKind::Other => "other".to_string(), + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone)] pub enum GraphNode { Code(CodeElement), @@ -73,17 +127,17 @@ impl GraphNode { } } - pub fn kind(&self) -> &str { + pub fn kind(&self) -> NodeKind { match self { GraphNode::Code(CodeElement::Java { element, .. }) => match element { - JavaElement::Class(_) => "class", - JavaElement::Interface(_) => "interface", - JavaElement::Enum(_) => "enum", - JavaElement::Annotation(_) => "annotation", - JavaElement::Method(_) => "method", - JavaElement::Field(_) => "field", + JavaElement::Class(_) => NodeKind::Class, + JavaElement::Interface(_) => NodeKind::Interface, + JavaElement::Enum(_) => NodeKind::Enum, + JavaElement::Annotation(_) => NodeKind::Annotation, + JavaElement::Method(m) => if m.is_constructor { NodeKind::Constructor } else { NodeKind::Method }, + JavaElement::Field(_) => NodeKind::Field, }, - GraphNode::Build(BuildElement::Gradle { element, .. }) => element.kind(), + GraphNode::Build(BuildElement::Gradle { element, .. }) => NodeKind::from(element.kind()), } } @@ -166,8 +220,9 @@ pub enum EdgeType { Implements, // Usage/Reference Calls, - References, Instantiates, + TypedAs, + DecoratedBy, // Build system relationships UsesDependency, } diff --git a/src/parser/java/ast.rs b/src/parser/java/ast.rs index 92f6e03..cf9ef0f 100644 --- a/src/parser/java/ast.rs +++ b/src/parser/java/ast.rs @@ -215,20 +215,37 @@ impl JavaParser { element: &mut JavaElement, relations: &mut Vec, ) { - // Modifiers + // Modifiers & Annotations if let Some(mods_node) = mat.captures.iter().find(|c| c.index == self.indices.mods).map(|c| c.node) { let mut cursor = mods_node.walk(); for child in mods_node.children(&mut cursor) { - if let Ok(m) = child.utf8_text(source.as_bytes()) { - let m_str = m.to_string(); - match element { - JavaElement::Class(c) => if !c.modifiers.contains(&m_str) { c.modifiers.push(m_str); } - JavaElement::Interface(i) => if !i.modifiers.contains(&m_str) { i.modifiers.push(m_str); } - JavaElement::Enum(e) => if !e.modifiers.contains(&m_str) { e.modifiers.push(m_str); } - JavaElement::Annotation(a) => if !a.modifiers.contains(&m_str) { a.modifiers.push(m_str); } - JavaElement::Method(m_node) => if !m_node.modifiers.contains(&m_str) { m_node.modifiers.push(m_str); } - JavaElement::Field(f) => if !f.modifiers.contains(&m_str) { f.modifiers.push(m_str); } + let kind = child.kind(); + if kind.contains("annotation") { + // It's an annotation. Try to extract the name. + // Structure usually: (marker_annotation name: (identifier)) + // or (annotation name: (identifier) arguments: (...)) + let name_node = child.child_by_field_name("name").unwrap_or(child); + if let Ok(name) = name_node.utf8_text(source.as_bytes()) { + let name_str = name.to_string(); + // Add DecoratedBy edge + relations.push(JavaRelation { + source_fqn: fqn.clone(), + target_name: name_str.clone(), // We might want to resolve this to FQN if possible + rel_type: EdgeType::DecoratedBy, + range: Some(range_from_ts(name_node.range())), + }); + // Also add to modifiers list as string representation (e.g. "@Override") + // We need to reconstruct the full annotation text or just the name with @ + // Existing logic added full text, let's keep it simple: just add the name prefixed with @ if not present + // Actually, child.utf8_text() gives the full annotation text "@Override" + if let Ok(full_text) = child.utf8_text(source.as_bytes()) { + let m_str = full_text.to_string(); + self.add_modifier(element, m_str); + } } + } else if let Ok(m) = child.utf8_text(source.as_bytes()) { + let m_str = m.to_string(); + self.add_modifier(element, m_str); } } } @@ -280,6 +297,8 @@ impl JavaParser { JavaElement::Method(m) => { if let Some(ret) = mat.captures.iter().find(|c| c.index == self.indices.method_ret) { m.return_type = self.parse_type_node(ret.node, source); + // Generate TypedAs edge for return type + self.generate_typed_as_edges(ret.node, source, &fqn, relations); } if let (Some(t_node), Some(n_node)) = ( mat.captures.iter().find(|c| c.index == self.indices.param_type).map(|c| c.node), @@ -290,13 +309,73 @@ impl JavaParser { if !m.parameters.iter().any(|p| p.name == n && p.type_ref == t_ref) { m.parameters.push(JavaParameter { type_ref: t_ref, name: n }); } + // Generate TypedAs edge for parameter type + self.generate_typed_as_edges(t_node, source, &fqn, relations); } } JavaElement::Field(f) => { if let Some(t) = mat.captures.iter().find(|c| c.index == self.indices.field_type) { f.type_ref = self.parse_type_node(t.node, source); + // Generate TypedAs edge for field type + self.generate_typed_as_edges(t.node, source, &fqn, relations); } } } } + + fn add_modifier(&self, element: &mut JavaElement, m_str: String) { + match element { + JavaElement::Class(c) => if !c.modifiers.contains(&m_str) { c.modifiers.push(m_str); } + JavaElement::Interface(i) => if !i.modifiers.contains(&m_str) { i.modifiers.push(m_str); } + JavaElement::Enum(e) => if !e.modifiers.contains(&m_str) { e.modifiers.push(m_str); } + JavaElement::Annotation(a) => if !a.modifiers.contains(&m_str) { a.modifiers.push(m_str); } + JavaElement::Method(m_node) => if !m_node.modifiers.contains(&m_str) { m_node.modifiers.push(m_str); } + JavaElement::Field(f) => if !f.modifiers.contains(&m_str) { f.modifiers.push(m_str); } + } + } + + /// Recursively extracts type references and generates TypedAs edges + fn generate_typed_as_edges( + &self, + type_node: Node, + source: &str, + source_fqn: &str, + relations: &mut Vec, + ) { + let kind = type_node.kind(); + + // Base case: simple type identifier + if kind == "type_identifier" { + let type_name = type_node.utf8_text(source.as_bytes()).unwrap_or_default().to_string(); + // Ignore primitive types for edge generation + if !self.is_primitive(&type_name) { + relations.push(JavaRelation { + source_fqn: source_fqn.to_string(), + target_name: type_name, + rel_type: EdgeType::TypedAs, + range: Some(range_from_ts(type_node.range())), + }); + } + return; + } + + // Recursive cases + let mut cursor = type_node.walk(); + for child in type_node.children(&mut cursor) { + let child_kind = child.kind(); + match child_kind { + "type_identifier" | "generic_type" | "type_arguments" | "wildcard" | "array_type" => { + self.generate_typed_as_edges(child, source, source_fqn, relations); + }, + _ => {} + } + } + } + + fn is_primitive(&self, type_name: &str) -> bool { + matches!( + type_name, + "byte" | "short" | "int" | "long" | "float" | "double" | "boolean" | "char" | "void" + ) + } } diff --git a/src/parser/java/lsp.rs b/src/parser/java/lsp.rs index 7a062b2..4e73fe5 100644 --- a/src/parser/java/lsp.rs +++ b/src/parser/java/lsp.rs @@ -1,5 +1,6 @@ use crate::parser::LspParser; use crate::parser::utils::{RawSymbol, build_symbol_hierarchy}; +use crate::model::graph::NodeKind; use tree_sitter::Tree; use super::JavaParser; @@ -19,17 +20,17 @@ impl LspParser for JavaParser { .into_iter() .map(|e| { let kind = match e.element { - crate::model::lang::java::JavaElement::Class(_) => "class", - crate::model::lang::java::JavaElement::Interface(_) => "interface", - crate::model::lang::java::JavaElement::Enum(_) => "enum", - crate::model::lang::java::JavaElement::Annotation(_) => "annotation", - crate::model::lang::java::JavaElement::Method(ref m) => if m.is_constructor { "constructor" } else { "method" }, - crate::model::lang::java::JavaElement::Field(_) => "field", + crate::model::lang::java::JavaElement::Class(_) => NodeKind::Class, + crate::model::lang::java::JavaElement::Interface(_) => NodeKind::Interface, + crate::model::lang::java::JavaElement::Enum(_) => NodeKind::Enum, + crate::model::lang::java::JavaElement::Annotation(_) => NodeKind::Annotation, + crate::model::lang::java::JavaElement::Method(ref m) => if m.is_constructor { NodeKind::Constructor } else { NodeKind::Method }, + crate::model::lang::java::JavaElement::Field(_) => NodeKind::Field, }; RawSymbol { name: e.element.name().to_string(), - kind: kind.to_string(), + kind, range: e.element.range().cloned().unwrap_or(crate::model::graph::Range { start_line: 0, start_col: 0, end_line: 0, end_col: 0 }), selection_range: e.element.name_range().cloned().unwrap_or(crate::model::graph::Range { start_line: 0, start_col: 0, end_line: 0, end_col: 0 }), node: e.node, @@ -40,16 +41,16 @@ impl LspParser for JavaParser { build_symbol_hierarchy(raw_symbols) } - fn symbol_kind(&self, kind: &str) -> tower_lsp::lsp_types::SymbolKind { + fn symbol_kind(&self, kind: &NodeKind) -> tower_lsp::lsp_types::SymbolKind { use tower_lsp::lsp_types::SymbolKind; match kind { - "class" => SymbolKind::CLASS, - "interface" => SymbolKind::INTERFACE, - "enum" => SymbolKind::ENUM, - "annotation" => SymbolKind::INTERFACE, - "method" => SymbolKind::METHOD, - "constructor" => SymbolKind::CONSTRUCTOR, - "field" => SymbolKind::FIELD, + NodeKind::Class => SymbolKind::CLASS, + NodeKind::Interface => SymbolKind::INTERFACE, + NodeKind::Enum => SymbolKind::ENUM, + NodeKind::Annotation => SymbolKind::INTERFACE, + NodeKind::Method => SymbolKind::METHOD, + NodeKind::Constructor => SymbolKind::CONSTRUCTOR, + NodeKind::Field => SymbolKind::FIELD, _ => SymbolKind::VARIABLE, } } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index e94191e..2fda928 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -1,4 +1,4 @@ -use crate::model::graph::{GraphNode, Range}; +use crate::model::graph::{GraphNode, NodeKind, Range}; use tree_sitter::Tree; use std::path::Path; use crate::error::Result; @@ -12,17 +12,12 @@ pub enum SymbolIntent { Unknown, } -pub fn matches_intent(node_kind: &str, intent: SymbolIntent) -> bool { +pub fn matches_intent(node_kind: &NodeKind, intent: SymbolIntent) -> bool { match intent { - SymbolIntent::Type => { - node_kind == "class" - || node_kind == "interface" - || node_kind == "enum" - || node_kind == "annotation" - } - SymbolIntent::Method => node_kind == "method" || node_kind == "constructor", - SymbolIntent::Field => node_kind == "field", - SymbolIntent::Variable => node_kind == "variable" || node_kind == "parameter", + SymbolIntent::Type => matches!(node_kind, NodeKind::Class | NodeKind::Interface | NodeKind::Enum | NodeKind::Annotation), + SymbolIntent::Method => matches!(node_kind, NodeKind::Method | NodeKind::Constructor), + SymbolIntent::Field => matches!(node_kind, NodeKind::Field), + SymbolIntent::Variable => false, // Graph nodes are rarely variables, usually only Definitions SymbolIntent::Unknown => true, } } @@ -37,7 +32,7 @@ pub trait LspParser: Send + Sync { fn parse(&self, source: &str, old_tree: Option<&tree_sitter::Tree>) -> Option; fn extract_symbols(&self, tree: &Tree, source: &str) -> Vec; /// Maps a language-specific symbol kind string to an LSP SymbolKind - fn symbol_kind(&self, kind: &str) -> tower_lsp::lsp_types::SymbolKind; + fn symbol_kind(&self, kind: &NodeKind) -> tower_lsp::lsp_types::SymbolKind; } /// Result of a global file parsing for indexing. @@ -56,7 +51,7 @@ pub trait IndexParser: Send + Sync { #[derive(Debug, Clone)] pub struct DocumentSymbol { pub name: String, - pub kind: String, + pub kind: NodeKind, pub range: Range, pub selection_range: Range, pub children: Vec, diff --git a/src/parser/utils.rs b/src/parser/utils.rs index a933591..3e3be9c 100644 --- a/src/parser/utils.rs +++ b/src/parser/utils.rs @@ -1,6 +1,6 @@ use crate::error::{NaviscopeError, Result}; use tree_sitter::{Language, Query}; -use crate::model::graph::Range; +use crate::model::graph::{Range, NodeKind}; /// Converts a tree-sitter range to our internal Range model. pub fn range_from_ts(range: tree_sitter::Range) -> Range { @@ -28,7 +28,7 @@ pub fn get_capture_index(query: &Query, name: &str) -> Result { /// A raw symbol representation used during tree construction. pub struct RawSymbol<'a> { pub name: String, - pub kind: String, + pub kind: NodeKind, pub range: crate::model::graph::Range, pub selection_range: crate::model::graph::Range, pub node: tree_sitter::Node<'a>, diff --git a/src/query/dsl.rs b/src/query/dsl.rs index f701ccc..778b4a1 100644 --- a/src/query/dsl.rs +++ b/src/query/dsl.rs @@ -1,46 +1,43 @@ -use crate::model::graph::EdgeType; +use crate::model::graph::{EdgeType, NodeKind}; use serde::{Deserialize, Serialize}; use schemars::JsonSchema; #[derive(Serialize, Deserialize, Debug, JsonSchema)] #[serde(tag = "command", rename_all = "snake_case")] pub enum GraphQuery { + /// List members or structure (Rich Listing) + Ls { + /// Target node FQN, defaults to project modules if null + fqn: Option, + #[serde(default)] + kind: Vec, + #[serde(default)] + modifiers: Vec, + }, + + /// Search for symbols Grep { - /// Search pattern (simple string or regex) pattern: String, - /// Optional: Filter by type. - /// Valid Java kinds: ["class", "interface", "enum", "annotation", "method", "field"] - /// Valid Build kinds: ["package", "dependency"] #[serde(default)] - kind: Vec, + kind: Vec, #[serde(default = "default_limit")] limit: usize, }, - - Ls { - /// Target node FQN, defaults to project modules if null - fqn: Option, - /// Optional: Filter by type. - /// Valid Java kinds: ["class", "interface", "enum", "annotation", "method", "field"] - /// Valid Build kinds: ["package", "dependency"] - #[serde(default)] - kind: Vec, - }, - - Inspect { + + /// Inspect node details (Source & Metadata) + Cat { fqn: String, }, - - Incoming { + + /// Find dependencies (outgoing) or dependents (incoming) + Deps { fqn: String, + /// If true, find incoming dependencies (who depends on me). + /// If false (default), find outgoing dependencies (who do I depend on). #[serde(default)] - edge_type: Vec, - }, - - Outgoing { - fqn: String, + rev: bool, #[serde(default)] - edge_type: Vec, + edge_types: Vec, }, } diff --git a/src/query/engine.rs b/src/query/engine.rs index 850b798..7ab0dac 100644 --- a/src/query/engine.rs +++ b/src/query/engine.rs @@ -1,9 +1,9 @@ use crate::error::{NaviscopeError, Result}; use crate::index::CodeGraph; -use crate::model::graph::EdgeType; +use crate::model::graph::{EdgeType, NodeKind}; use crate::query::dsl::GraphQuery; use crate::query::model::NodeSummary; -use petgraph::Direction; +use petgraph::Direction as PetDirection; use regex::RegexBuilder; pub struct QueryEngine<'a> { @@ -34,7 +34,7 @@ impl<'a> QueryEngine<'a> { // Check if either FQN or Name matches the pattern if regex.is_match(&summary.fqn) || regex.is_match(&summary.name) { - if kind.is_empty() || kind.contains(&summary.kind) { + if kind.is_empty() || kind.contains(&node.kind()) { results.push(summary); } } @@ -45,9 +45,9 @@ impl<'a> QueryEngine<'a> { } Ok(serde_json::to_value(results)?) } - GraphQuery::Ls { fqn, kind } => { + GraphQuery::Ls { fqn, kind, modifiers: _ } => { if let Some(target_fqn) = fqn { - self.traverse_neighbors(target_fqn, &[EdgeType::Contains], Direction::Outgoing, kind) + self.traverse_neighbors(target_fqn, &[EdgeType::Contains], PetDirection::Outgoing, kind) } else { // When FQN is missing, list all top-level modules (Gradle Package nodes with file paths) let mut results = Vec::new(); @@ -60,7 +60,7 @@ impl<'a> QueryEngine<'a> { // Java package nodes also use GradleElement::Package but do not have a file_path if file_path.is_some() { let summary = NodeSummary::from(node); - if kind.is_empty() || kind.contains(&summary.kind) { + if kind.is_empty() || kind.contains(&node.kind()) { results.push(summary); } } @@ -69,7 +69,7 @@ impl<'a> QueryEngine<'a> { Ok(serde_json::to_value(results)?) } } - GraphQuery::Inspect { fqn } => { + GraphQuery::Cat { fqn } => { if let Some(&idx) = self.graph.fqn_map.get(fqn) { let node = &self.graph.topology[idx]; Ok(serde_json::to_value(node)?) @@ -77,11 +77,13 @@ impl<'a> QueryEngine<'a> { Ok(serde_json::Value::Null) } } - GraphQuery::Incoming { fqn, edge_type } => { - self.traverse_neighbors(fqn, edge_type, Direction::Incoming, &[]) - } - GraphQuery::Outgoing { fqn, edge_type } => { - self.traverse_neighbors(fqn, edge_type, Direction::Outgoing, &[]) + GraphQuery::Deps { fqn, rev, edge_types } => { + let direction = if *rev { + PetDirection::Incoming + } else { + PetDirection::Outgoing + }; + self.traverse_neighbors(fqn, edge_types, direction, &[]) } } } @@ -90,8 +92,8 @@ impl<'a> QueryEngine<'a> { &self, fqn: &str, edge_filter: &[EdgeType], - dir: Direction, - kind_filter: &[String], + dir: PetDirection, + kind_filter: &[NodeKind], ) -> Result { let start_idx = self .graph @@ -112,7 +114,7 @@ impl<'a> QueryEngine<'a> { let neighbor_node = &self.graph.topology[neighbor_idx]; let summary = NodeSummary::from(neighbor_node); - if kind_filter.is_empty() || kind_filter.contains(&summary.kind) { + if kind_filter.is_empty() || kind_filter.contains(&neighbor_node.kind()) { results.push(summary); } } diff --git a/src/resolver/lang/java/mod.rs b/src/resolver/lang/java/mod.rs index 15c1463..c40ff59 100644 --- a/src/resolver/lang/java/mod.rs +++ b/src/resolver/lang/java/mod.rs @@ -1,6 +1,6 @@ use crate::resolver::{LangResolver, ProjectContext}; use crate::error::Result; -use crate::model::graph::{EdgeType, GraphEdge, GraphNode, ResolvedUnit}; +use crate::model::graph::{EdgeType, GraphEdge, GraphNode, NodeKind, ResolvedUnit}; use crate::resolver::SemanticResolver; use crate::index::CodeGraph; use crate::project::scanner::{ParsedContent, ParsedFile}; @@ -32,7 +32,7 @@ impl JavaResolver { fn is_top_level_node(&self, node: &GraphNode) -> bool { let kind = node.kind(); - kind == "class" || kind == "interface" || kind == "enum" || kind == "annotation" + matches!(kind, NodeKind::Class | NodeKind::Interface | NodeKind::Enum | NodeKind::Annotation) } fn get_active_scopes<'a>(&'a self, ctx: &'a ResolutionContext) -> Vec> { @@ -115,7 +115,7 @@ impl SemanticResolver for JavaResolver { SymbolResolution::Precise(fqn, intent) => { if let Some(&idx) = index.fqn_map.get(fqn) { if let Some(node) = index.topology.node_weight(idx) { - if *intent == SymbolIntent::Unknown || matches_intent(node.kind(), *intent) { + if *intent == SymbolIntent::Unknown || matches_intent(&node.kind(), *intent) { return vec![idx]; } } @@ -152,7 +152,7 @@ impl SemanticResolver for JavaResolver { } } _ => { - if matches_intent(node.kind(), SymbolIntent::Type) { + if matches_intent(&node.kind(), SymbolIntent::Type) { type_resolutions.push(resolution.clone()); } } From 123768b84e0e7081255e3b062626f228d7190d69 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Mon, 26 Jan 2026 00:56:03 +0800 Subject: [PATCH 2/4] refactor: implement interactive shell for querying code knowledge graph; replace JSON DSL query command with shell commands, enhance command handling, and add context management for improved user experience. --- Cargo.lock | 492 ++++++++++++++++++---- Cargo.toml | 7 +- README.md | 13 +- src/cli/mod.rs | 20 +- src/cli/query.rs | 18 - src/cli/shell/command.rs | 168 ++++++++ src/cli/shell/completer.rs | 102 +++++ src/cli/shell/context.rs | 177 ++++++++ src/cli/shell/handlers.rs | 115 +++++ src/cli/shell/mod.rs | 228 ++++++++++ src/cli/shell/prompt.rs | 68 +++ src/cli/shell/view.rs | 158 +++++++ src/index.rs | 73 ++-- src/lsp/hover.rs | 57 ++- src/model/graph.rs | 12 +- src/model/lang/gradle.rs | 21 +- src/model/lang/java.rs | 11 + src/model/signature.rs | 1 - src/parser/gradle.rs | 167 +++++--- src/parser/java/ast.rs | 2 + src/parser/java/lsp.rs | 2 + src/parser/queries/gradle_definitions.rs | 7 + src/parser/queries/gradle_definitions.scm | 47 +++ src/project/scanner.rs | 22 +- src/query/engine.rs | 96 +++-- src/query/mod.rs | 2 +- src/query/model.rs | 137 +----- src/resolver/lang/gradle.rs | 260 ++++++++++-- src/resolver/lang/java/mod.rs | 26 +- 29 files changed, 2088 insertions(+), 421 deletions(-) delete mode 100644 src/cli/query.rs create mode 100644 src/cli/shell/command.rs create mode 100644 src/cli/shell/completer.rs create mode 100644 src/cli/shell/context.rs create mode 100644 src/cli/shell/handlers.rs create mode 100644 src/cli/shell/mod.rs create mode 100644 src/cli/shell/prompt.rs create mode 100644 src/cli/shell/view.rs diff --git a/Cargo.lock b/Cargo.lock index 1b320d7..148fbf2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -81,15 +81,6 @@ dependencies = [ "syn", ] -[[package]] -name = "atomic-polyfill" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" -dependencies = [ - "critical-section", -] - [[package]] name = "atomic-waker" version = "1.1.2" @@ -207,6 +198,9 @@ name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +dependencies = [ + "serde_core", +] [[package]] name = "block-buffer" @@ -234,10 +228,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] -name = "byteorder" -version = "1.5.0" +name = "bytecount" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] name = "bytes" @@ -338,15 +332,6 @@ dependencies = [ "cc", ] -[[package]] -name = "cobs" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" -dependencies = [ - "thiserror 2.0.17", -] - [[package]] name = "colorchoice" version = "1.0.4" @@ -363,6 +348,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -398,12 +392,6 @@ dependencies = [ "libc", ] -[[package]] -name = "critical-section" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" - [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -438,6 +426,34 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.10.0", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "serde", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -524,6 +540,28 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -534,6 +572,27 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -545,6 +604,15 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dunce" version = "1.0.5" @@ -563,18 +631,6 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -[[package]] -name = "embedded-io" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" - -[[package]] -name = "embedded-io" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" - [[package]] name = "encoding_rs" version = "0.8.35" @@ -590,6 +646,27 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix", + "windows-sys 0.52.0", +] + [[package]] name = "find-msvc-tools" version = "0.1.8" @@ -796,15 +873,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "hash32" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" -dependencies = [ - "byteorder", -] - [[package]] name = "hashbrown" version = "0.14.5" @@ -826,20 +894,6 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -[[package]] -name = "heapless" -version = "0.7.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" -dependencies = [ - "atomic-polyfill", - "hash32", - "rustc_version", - "serde", - "spin", - "stable_deref_trait", -] - [[package]] name = "heck" version = "0.5.0" @@ -1156,6 +1210,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -1236,12 +1299,34 @@ version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags 2.10.0", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "litemap" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -1323,19 +1408,24 @@ dependencies = [ "cc", "clap", "dashmap 6.1.0", + "dirs", "futures", "ignore", "log", "notify", + "nu-ansi-term", "petgraph", - "postcard", "rayon", + "reedline", "regex", "reqwest", "rmcp", + "rmp-serde", "schemars", "serde", "serde_json", + "shlex", + "tabled", "thiserror 2.0.17", "tokio", "tokio-tungstenite", @@ -1415,6 +1505,33 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "papergrid" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6978128c8b51d8f4080631ceb2302ab51e32cc6e8615f735ee2f83fd269ae3f1" +dependencies = [ + "bytecount", + "fnv", + "unicode-width", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + [[package]] name = "parking_lot_core" version = "0.9.12" @@ -1485,19 +1602,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" -[[package]] -name = "postcard" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" -dependencies = [ - "cobs", - "embedded-io 0.4.0", - "embedded-io 0.6.1", - "heapless", - "serde", -] - [[package]] name = "potential_utf" version = "0.1.4" @@ -1522,6 +1626,28 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.105" @@ -1660,6 +1786,38 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.17", +] + +[[package]] +name = "reedline" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67478e45862a0c29fd99658e382c07b1b80b9c1b7d946ce6bd2e4a679141554b" +dependencies = [ + "chrono", + "crossterm", + "fd-lock", + "itertools", + "nu-ansi-term", + "serde", + "strip-ansi-escapes", + "strum", + "strum_macros", + "thiserror 2.0.17", + "unicase", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "ref-cast" version = "1.0.25" @@ -1799,6 +1957,25 @@ dependencies = [ "syn", ] +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -1814,6 +1991,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.36" @@ -2095,6 +2285,37 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "slab" version = "0.4.11" @@ -2117,15 +2338,6 @@ dependencies = [ "windows-sys 0.60.2", ] -[[package]] -name = "spin" -version = "0.9.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" -dependencies = [ - "lock_api", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -2138,12 +2350,40 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" +[[package]] +name = "strip-ansi-escapes" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "subtle" version = "2.6.1" @@ -2202,6 +2442,39 @@ dependencies = [ "libc", ] +[[package]] +name = "tabled" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e39a2ee1fbcd360805a771e1b300f78cc88fec7b8d3e2f71cd37bbf23e725c7d" +dependencies = [ + "papergrid", + "tabled_derive", + "testing_table", +] + +[[package]] +name = "tabled_derive" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea5d1b13ca6cff1f9231ffd62f15eefd72543dab5e468735f1a456728a02846" +dependencies = [ + "heck", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "testing_table" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f8daae29995a24f65619e19d8d31dea5b389f3d853d8bf297bbf607cd0014cc" +dependencies = [ + "unicode-width", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -2585,12 +2858,30 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "untrusted" version = "0.9.0" @@ -2640,6 +2931,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vte" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" +dependencies = [ + "memchr", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -2775,6 +3075,22 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2784,6 +3100,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index 73d0b13..76a1a5a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,6 @@ regex = "1.11.1" tokio = { version = "1.49.0", features = ["rt-multi-thread", "macros", "sync", "time"] } schemars = "1.2.0" rmcp = { version = "0.13.0", features = ["macros", "server", "transport-io"] } -postcard = { version = "1.1.3", features = ["use-std"] } tower-lsp = "0.20" futures = "0.3" dashmap = "6.1.0" @@ -31,6 +30,12 @@ tracing = "0.1.44" tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } tracing-appender = "0.2.4" tokio-tungstenite = "0.28.0" +reedline = "0.45.0" +dirs = "6.0.0" +nu-ansi-term = "0.50.3" +shlex = "1.3.0" +tabled = "0.20.0" +rmp-serde = "1.3.1" [build-dependencies] cc = "1.2" diff --git a/README.md b/README.md index 0e2012e..0ca3537 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ npm run package ### CLI Commands - `naviscope index `: Build a persistent index for a project (stored in `~/.naviscope/indices`). -- `naviscope query `: Execute a structured DSL query manually. +- `naviscope shell [PATH]`: Start an interactive shell to query the code knowledge graph. - `naviscope watch `: Start a background service to keep the index updated as you edit files. - `naviscope schema`: Display the JSON schema and examples for the GraphQuery DSL. - `naviscope clear [PATH]`: Remove specific project index or clear all cached indices. @@ -112,12 +112,11 @@ Naviscope acts as a high-performance LSP server for Java, offering a lightweight ## 🛠️ Query API Examples -The Query DSL (used by `naviscope query` and MCP) supports several commands for structured exploration: -- `grep`: `{"command": "grep", "pattern": "UserService", "kind": ["class"]}` -- `ls`: `{"command": "ls", "fqn": "com.example.service"}` -- `inspect`: `{"command": "inspect", "fqn": "com.example.service.UserService"}` -- `incoming`: `{"command": "incoming", "fqn": "com.example.service.UserService#save", "edge_type": ["Calls"]}` -- `outgoing`: `{"command": "outgoing", "fqn": "com.example.service.UserService"}` +The Query DSL (used by `naviscope shell` and MCP) supports several commands for structured exploration: +- `grep`: `grep "UserService"` (or JSON `{"command": "grep", "pattern": "UserService", "kind": ["class"]}`) +- `ls`: `ls "com.example.service"` (or JSON `{"command": "ls", "fqn": "com.example.service"}`) +- `cat`: `cat "com.example.service.UserService"` (or JSON `{"command": "cat", "fqn": "com.example.service.UserService"}`) +- `deps`: `deps "com.example.service.UserService"` (or JSON `{"command": "deps", "fqn": "com.example.service.UserService"}`) ## 📈 Roadmap (V1) - [x] Core Graph Storage (`petgraph`) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 526c8ea..9774e5f 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,5 +1,5 @@ mod index; -mod query; +mod shell; mod schema; mod watch; mod clear; @@ -35,17 +35,13 @@ pub enum Commands { #[arg(long)] debug: bool, }, - /// Query the code knowledge graph using JSON DSL - #[command(long_about = "Executes a structured query against an existing index. \ - The query should be a JSON object following the GraphQuery schema.")] - Query { - /// Path to the project root (used to locate the default index) + /// Start an interactive shell to query the code knowledge graph + #[command(long_about = "Starts an interactive shell where you can execute structured queries \ + against the index using both JSON DSL and shorthand commands.")] + Shell { + /// Path to the project root (used to locate the default index). Defaults to current directory. #[arg(value_name = "PROJECT_PATH")] - path: PathBuf, - - /// Structured query in JSON format (e.g., '{"command": "grep", "pattern": "MyClass"}') - #[arg(value_name = "JSON_QUERY")] - query: String, + path: Option, }, /// Show the JSON schema/examples for GraphQuery Schema, @@ -95,7 +91,7 @@ pub fn run() -> Result<(), Box> { path, debug, } => index::run(path, debug), - Commands::Query { path, query } => query::run(path, query), + Commands::Shell { path } => shell::run(path), Commands::Schema => schema::run(), Commands::Watch { path, debug } => watch::run(path, debug), Commands::Clear { path } => clear::run(path), diff --git a/src/cli/query.rs b/src/cli/query.rs deleted file mode 100644 index 30ca47e..0000000 --- a/src/cli/query.rs +++ /dev/null @@ -1,18 +0,0 @@ -use naviscope::index::Naviscope; -use naviscope::query::GraphQuery; -use naviscope::query::QueryEngine; -use std::path::PathBuf; - -pub fn run(path: PathBuf, query: String) -> Result<(), Box> { - let mut engine = Naviscope::new(path); - - // Always perform incremental indexing before querying - engine.build_index()?; - - let query_obj: GraphQuery = serde_json::from_str(&query)?; - let query_engine = QueryEngine::new(engine.graph()); - let result = query_engine.execute(&query_obj)?; - println!("{}", serde_json::to_string_pretty(&result)?); - - Ok(()) -} diff --git a/src/cli/shell/command.rs b/src/cli/shell/command.rs new file mode 100644 index 0000000..3384f4d --- /dev/null +++ b/src/cli/shell/command.rs @@ -0,0 +1,168 @@ +use clap::Parser; +use naviscope::model::graph::{EdgeType, NodeKind}; +use naviscope::query::{GraphQuery, QueryResult}; +use super::view::{ShellNodeView, ShellNodeViewShort, get_kind_weight}; +use tabled::{Table, settings::Style}; +use shlex; + +/// Helper struct for Clap parsing within the shell +#[derive(Parser)] +#[command(no_binary_name = true)] +pub enum ShellCommand { + /// List members or structure + Ls { + /// Target node FQN (optional, defaults to current node) + fqn: Option, + /// Filter by node kind (e.g. class, interface, method) + #[arg(long, value_delimiter = ',')] + kind: Vec, + /// Filter by modifiers (e.g. public, static) + #[arg(long, value_delimiter = ',')] + modifiers: Vec, + /// Use long listing format + #[arg(short, long)] + long: bool, + }, + /// Change current node context (internal shell command) + Cd { + /// Target path + path: String, + }, + /// Print current node context + Pwd, + /// Clear the screen + Clear, + /// Search for symbols + Grep { + /// Pattern to search for + pattern: String, + /// Filter by node kind + #[arg(long, value_delimiter = ',')] + kind: Vec, + /// Limit number of results + #[arg(long, default_value_t = 20)] + limit: usize, + }, + /// Inspect node details + Cat { + /// Target node FQN (optional, defaults to current node) or member name + target: String, + }, + /// Find dependencies + Deps { + /// Target node FQN (optional, defaults to current node) + fqn: Option, + /// If set, find incoming dependencies (who depends on me) + #[arg(long)] + rev: bool, + /// Filter by edge types (e.g. Calls, Extends) + #[arg(long, value_delimiter = ',')] + edge_types: Vec, + }, +} + +use clap::error::ErrorKind; + +pub fn parse_shell_command(input: &str) -> Result, Box> { + // Use shlex to split arguments while respecting quotes + let args = shlex::split(input).ok_or("Invalid quoting")?; + + // Parse using Clap + match ShellCommand::try_parse_from(args) { + Ok(c) => Ok(Some(c)), + Err(e) => { + // Handle help/version display without returning an error + if e.kind() == ErrorKind::DisplayHelp || e.kind() == ErrorKind::DisplayVersion { + println!("{}", e); + return Ok(None); + } + Err(Box::new(e)) + } + } +} + +impl ShellCommand { + pub fn to_graph_query(&self, current_node: &Option) -> Result> { + match self { + ShellCommand::Ls { fqn, kind, modifiers, .. } => { + let target_fqn = fqn.clone().or_else(|| current_node.clone()); + Ok(GraphQuery::Ls { + fqn: target_fqn, + kind: kind.clone(), + modifiers: modifiers.clone(), + }) + }, + ShellCommand::Grep { pattern, kind, limit } => Ok(GraphQuery::Grep { + pattern: pattern.clone(), + kind: kind.clone(), + limit: *limit, + }), + ShellCommand::Cat { target } => { + Ok(GraphQuery::Cat { fqn: target.clone() }) + }, + ShellCommand::Deps { fqn, rev, edge_types } => { + let target_fqn = fqn.clone().or_else(|| current_node.clone()) + .ok_or("No FQN provided and no current context")?; + Ok(GraphQuery::Deps { + fqn: target_fqn, + rev: *rev, + edge_types: edge_types.clone(), + }) + }, + ShellCommand::Cd { .. } | ShellCommand::Pwd | ShellCommand::Clear => { + Err("Internal shell command should be handled by ReplServer".into()) + } + } + } + + pub fn render(&self, result: QueryResult) -> Result> { + if result.is_empty() { + return Ok("NO RECORDS FOUND".to_string()); + } + + match self { + ShellCommand::Ls { long: false, .. } => { + let mut views: Vec = result.nodes.iter().map(|node| { + ShellNodeViewShort { + kind: node.kind().to_string(), + name: if is_container(node.kind()) { format!("{}/", node.name()) } else { node.name().to_string() }, + } + }).collect(); + + views.sort_by(|a, b| { + let wa = get_kind_weight(&a.kind); + let wb = get_kind_weight(&b.kind); + if wa != wb { wa.cmp(&wb) } else { a.name.cmp(&b.name) } + }); + + Ok(Table::new(&views).with(Style::psql()).to_string()) + } + ShellCommand::Cat { .. } if result.nodes.len() == 1 => { + Ok(serde_json::to_string_pretty(&result.nodes[0])?) + } + _ => { + // Default detailed table view for Grep, Deps, and Ls -l + let mut views: Vec = result.nodes.iter().map(|node| { + let relation = result.edges.iter() + .filter(|e| e.to == node.fqn() || e.from == node.fqn()) + .map(|e| format!("{:?}", e.data.edge_type)) + .collect::>() + .join(", "); + ShellNodeView::from_node(node, if relation.is_empty() { None } else { Some(relation) }) + }).collect(); + + views.sort_by(|a, b| { + let wa = get_kind_weight(&a.kind); + let wb = get_kind_weight(&b.kind); + if wa != wb { wa.cmp(&wb) } else { a.name.cmp(&b.name) } + }); + + Ok(Table::new(&views).with(Style::psql()).to_string()) + } + } + } +} + +fn is_container(kind: NodeKind) -> bool { + matches!(kind, NodeKind::Class | NodeKind::Interface | NodeKind::Enum | NodeKind::Annotation | NodeKind::Module | NodeKind::Package) +} diff --git a/src/cli/shell/completer.rs b/src/cli/shell/completer.rs new file mode 100644 index 0000000..273beea --- /dev/null +++ b/src/cli/shell/completer.rs @@ -0,0 +1,102 @@ +use naviscope::query::{GraphQuery, QueryEngine}; +use reedline::{Completer, Suggestion}; +use super::context::ShellContext; + +pub struct NaviscopeCompleter<'a> { + pub commands: Vec, + pub context: ShellContext, + pub _marker: std::marker::PhantomData<&'a ()>, +} + +impl<'a> NaviscopeCompleter<'a> { + pub fn new( + commands: Vec, + context: ShellContext, + ) -> Self { + Self { + commands, + context, + _marker: std::marker::PhantomData, + } + } +} + +impl<'a> Completer for NaviscopeCompleter<'a> { + fn complete(&mut self, line: &str, pos: usize) -> Vec { + let trimmed = line.trim_start(); + + // 1. Command completion (at start of line) + if !trimmed.contains(' ') { + return self.commands.iter() + .filter(|cmd| cmd.starts_with(trimmed)) + .map(|cmd| Suggestion { + value: cmd.clone(), + description: None, + style: None, + extra: None, + span: reedline::Span { start: pos - trimmed.len(), end: pos }, + append_whitespace: true, + match_indices: None, + }) + .collect(); + } + + // 2. Argument completion (for cd, ls, cat, deps) + let parts: Vec<&str> = trimmed.split_whitespace().collect(); + if parts.len() >= 1 { + let cmd = parts[0]; + if matches!(cmd, "cd" | "ls" | "cat" | "deps") { + // Determine the partial FQN being typed + let last_word = if line.ends_with(' ') { "" } else { parts.last().unwrap_or(&"") }; + let span_start = pos - last_word.len(); + + // Get current context + let parent_fqn = self.context.current_fqn(); + + // Query graph for children of current context (or partial match) + // If last_word contains dots, we might need to resolve relative to root or parent + // For simplicity: list children of current_node, filtering by last_word + + let search_fqn = if let Some(parent) = &parent_fqn { + if last_word.is_empty() { + Some(parent.clone()) + } else { + // Naive: just look for children of parent that start with last_word + Some(parent.clone()) + } + } else { + None // Root + }; + + // Use engine to list children + let query = GraphQuery::Ls { + fqn: search_fqn, + kind: vec![], + modifiers: vec![] + }; + + if let Ok(naviscope) = self.context.naviscope.read() { + let engine = QueryEngine::new(naviscope.graph()); + + if let Ok(result) = engine.execute(&query) { + return result.nodes.iter() + .map(|node| node.name()) + .filter(|name| name.starts_with(last_word)) + .map(|name| Suggestion { + value: name.to_string(), + description: None, + style: None, + extra: None, + span: reedline::Span { start: span_start, end: pos }, + append_whitespace: true, + match_indices: None, + }) + .collect(); + } + } + } + } + + vec![] + } +} diff --git a/src/cli/shell/context.rs b/src/cli/shell/context.rs new file mode 100644 index 0000000..1614459 --- /dev/null +++ b/src/cli/shell/context.rs @@ -0,0 +1,177 @@ +use std::sync::{Arc, RwLock}; +use naviscope::index::{Naviscope, CodeGraph}; +use naviscope::query::{QueryEngine, GraphQuery}; +use naviscope::model::graph::GraphNode; + +#[derive(Clone)] +pub struct ShellContext { + pub naviscope: Arc>, + pub current_node: Arc>>, +} + +pub enum ResolveResult { + Found(String), + Ambiguous(Vec), + NotFound, +} + +impl ShellContext { + pub fn new(naviscope: Arc>, current_node: Arc>>) -> Self { + Self { + naviscope, + current_node, + } + } + + pub fn current_fqn(&self) -> Option { + self.current_node.read().unwrap().clone() + } + + pub fn set_current_fqn(&self, fqn: Option) { + *self.current_node.write().unwrap() = fqn; + } + + /// Resolves a user input path (absolute FQN, relative path, or fuzzy name) to a concrete FQN. + pub fn resolve_node(&self, target: &str) -> ResolveResult { + // 1. Handle special paths + if let Some(result) = Self::resolve_special_path(target) { + return result; + } + + let curr = self.current_fqn(); + let engine_guard = self.naviscope.read().unwrap(); + let graph = engine_guard.graph(); + + // 2. Handle Parent (..) navigation + if let Some(result) = Self::resolve_parent(target, &curr, graph) { + return result; + } + + // 3. Try Exact Match (Absolute FQN) + if let Some(result) = Self::resolve_exact_match(target, graph) { + return result; + } + + // 4. Try Child Lookup (Relative / Fuzzy) + Self::resolve_child_lookup(target, &curr, graph) + } + + /// Handles special paths like "/" (root). + fn resolve_special_path(target: &str) -> Option { + if target == "/" { + Some(ResolveResult::Found("".to_string())) // Marker for root + } else { + None + } + } + + /// Handles parent navigation (".."). + fn resolve_parent(target: &str, current_fqn: &Option, graph: &CodeGraph) -> Option { + if target != ".." { + return None; + } + + if let Some(c) = current_fqn { + // Graph-based parent lookup + if let Some(&idx) = graph.fqn_map.get(c) { + let mut incoming = graph.topology + .neighbors_directed(idx, petgraph::Direction::Incoming) + .detach(); + + while let Some((edge_idx, neighbor_idx)) = incoming.next(&graph.topology) { + let edge = &graph.topology[edge_idx]; + if edge.edge_type == naviscope::model::graph::EdgeType::Contains { + if let Some(parent_node) = graph.topology.node_weight(neighbor_idx) { + return Some(ResolveResult::Found(parent_node.fqn().to_string())); + } + } + } + } + + // Fallback: String manipulation + if let Some(last_dot) = c.rfind('.') { + return Some(ResolveResult::Found(c[0..last_dot].to_string())); + } else if c.contains("::") { + let parts: Vec<&str> = c.split("::").collect(); + if parts.len() > 1 { + let parent = parts[..parts.len()-1].join("::"); + if parent == "module" { + return Some(ResolveResult::Found("".to_string())); // Root + } + return Some(ResolveResult::Found(parent)); + } + } + Some(ResolveResult::Found("".to_string())) // Root + } else { + Some(ResolveResult::Found("".to_string())) // Already at root + } + } + + /// Tries exact match against absolute FQN. + fn resolve_exact_match(target: &str, graph: &CodeGraph) -> Option { + if graph.fqn_map.contains_key(target) { + Some(ResolveResult::Found(target.to_string())) + } else { + None + } + } + + /// Tries child lookup with exact and fuzzy name matching. + fn resolve_child_lookup(target: &str, current_fqn: &Option, graph: &CodeGraph) -> ResolveResult { + let query_engine = QueryEngine::new(graph); + let children_query = GraphQuery::Ls { + fqn: current_fqn.clone(), + kind: vec![], + modifiers: vec![], + }; + + if let Ok(res) = query_engine.execute(&children_query) { + // First pass: Exact Name Match + let exact_matches = Self::find_exact_name_match(target, &res.nodes); + if !exact_matches.is_empty() { + if exact_matches.len() == 1 { + return ResolveResult::Found(exact_matches[0].clone()); + } else { + return ResolveResult::Ambiguous(exact_matches); + } + } + + // Second pass: Fuzzy Name Match (e.g. method name without signature) + let fuzzy_matches = Self::find_fuzzy_name_match(target, &res.nodes); + if !fuzzy_matches.is_empty() { + if fuzzy_matches.len() == 1 { + return ResolveResult::Found(fuzzy_matches[0].clone()); + } else { + return ResolveResult::Ambiguous(fuzzy_matches); + } + } + } + + ResolveResult::NotFound + } + + /// Finds nodes with exact name match. + fn find_exact_name_match(target: &str, nodes: &[GraphNode]) -> Vec { + nodes.iter() + .filter(|n| { + let name = n.name(); + // Handle display names with trailing slash + let clean_name = name.trim_end_matches('/'); + clean_name == target + }) + .map(|n| n.fqn().to_string()) + .collect() + } + + /// Finds nodes with fuzzy name match (e.g. method name without signature). + fn find_fuzzy_name_match(target: &str, nodes: &[GraphNode]) -> Vec { + nodes.iter() + .filter(|n| { + let name = n.name(); + let clean_name = name.trim_end_matches('/'); + clean_name.split('(').next().unwrap_or("") == target + }) + .map(|n| n.fqn().to_string()) + .collect() + } +} \ No newline at end of file diff --git a/src/cli/shell/handlers.rs b/src/cli/shell/handlers.rs new file mode 100644 index 0000000..34b9329 --- /dev/null +++ b/src/cli/shell/handlers.rs @@ -0,0 +1,115 @@ +use naviscope::query::QueryEngine; +use naviscope::query::GraphQuery; +use super::command::ShellCommand; +use super::context::{ShellContext, ResolveResult}; + +pub trait CommandHandler { + fn handle(&self, cmd: &ShellCommand, context: &mut ShellContext) -> Result>; +} + +pub struct CdHandler; +impl CommandHandler for CdHandler { + fn handle(&self, cmd: &ShellCommand, context: &mut ShellContext) -> Result> { + if let ShellCommand::Cd { path } = cmd { + match context.resolve_node(path) { + ResolveResult::Found(fqn) => { + let new_curr = if fqn.is_empty() { None } else { Some(fqn) }; + context.set_current_fqn(new_curr); + Ok(String::new()) + }, + ResolveResult::Ambiguous(candidates) => { + let mut msg = format!("Ambiguous path '{}'. Candidates:\n", path); + for c in candidates.iter().take(10) { + msg.push_str(&format!(" - {}\n", c)); + } + Err(msg.into()) + }, + ResolveResult::NotFound => Err(format!("Node '{}' not found.", path).into()), + } + } else { + Ok(String::new()) + } + } +} + +pub struct CatHandler; +impl CommandHandler for CatHandler { + fn handle(&self, cmd: &ShellCommand, context: &mut ShellContext) -> Result> { + if let ShellCommand::Cat { target } = cmd { + // First resolve the target to a concrete FQN + let fqn = match context.resolve_node(target) { + ResolveResult::Found(f) => f, + ResolveResult::Ambiguous(candidates) => { + let mut msg = format!("Ambiguous match for '{}'. Available options:\n\n", target); + // We should probably look up the names for these FQNs to show better hints, + // but for now showing FQNs is correct. + for c in candidates { + msg.push_str(&format!(" - {}\n", c)); + } + msg.push_str("\nPlease specify the full name."); + return Ok(msg); + }, + ResolveResult::NotFound => { + // If not resolved locally, fallback to trying target as raw FQN (handled by engine) + // This covers cases where target is an FQN but not reachable via 'ls' from current node? + // Actually resolve_node step 3 covers Exact Match. So it's truly not found. + return Ok("NO RECORDS FOUND".to_string()); + } + }; + + if fqn.is_empty() { + return Err("Cannot cat root.".into()); + } + + let engine_guard = context.naviscope.read().unwrap(); + let engine = QueryEngine::new(engine_guard.graph()); + let query = GraphQuery::Cat { fqn }; + let result = engine.execute(&query)?; + + // Re-use ShellCommand's render for consistent output format + cmd.render(result) + } else { + Ok(String::new()) + } + } +} + +pub struct GenericQueryHandler; +impl CommandHandler for GenericQueryHandler { + fn handle(&self, cmd: &ShellCommand, context: &mut ShellContext) -> Result> { + let current_node = context.current_fqn(); + let query = cmd.to_graph_query(¤t_node)?; + + let engine_guard = context.naviscope.read().unwrap(); + let engine = QueryEngine::new(engine_guard.graph()); + + let result = engine.execute(&query)?; + cmd.render(result) + } +} + +pub struct PwdHandler; +impl CommandHandler for PwdHandler { + fn handle(&self, _cmd: &ShellCommand, context: &mut ShellContext) -> Result> { + Ok(context.current_fqn().unwrap_or("/".to_string())) + } +} + +pub struct ClearHandler; +impl CommandHandler for ClearHandler { + fn handle(&self, _cmd: &ShellCommand, _context: &mut ShellContext) -> Result> { + // Clear is handled by the Reedline loop mostly, but we can return a marker if needed. + // For now, simple print or empty string. The loop handles `line_editor.clear_screen()`. + Ok(String::new()) + } +} + +pub fn get_handler(cmd: &ShellCommand) -> Box { + match cmd { + ShellCommand::Cd { .. } => Box::new(CdHandler), + ShellCommand::Cat { .. } => Box::new(CatHandler), + ShellCommand::Pwd => Box::new(PwdHandler), + ShellCommand::Clear => Box::new(ClearHandler), + _ => Box::new(GenericQueryHandler), + } +} diff --git a/src/cli/shell/mod.rs b/src/cli/shell/mod.rs new file mode 100644 index 0000000..18f453e --- /dev/null +++ b/src/cli/shell/mod.rs @@ -0,0 +1,228 @@ +mod command; +mod completer; +mod context; +mod handlers; +mod prompt; +mod view; + +use naviscope::index::Naviscope; +use naviscope::project::watcher::Watcher; +use reedline::{ + default_emacs_keybindings, ColumnarMenu, DefaultHinter, Emacs, FileBackedHistory, KeyCode, + KeyModifiers, MenuBuilder, Reedline, ReedlineEvent, ReedlineMenu, Signal, +}; +use std::path::PathBuf; +use std::sync::{Arc, RwLock}; +use std::thread; +use std::time::Duration; +use tracing::{error, info}; + +use self::completer::NaviscopeCompleter; +use self::context::ShellContext; +use self::command::{ShellCommand, parse_shell_command}; +use self::prompt::DefaultPrompt; + +pub struct ReplServer { + context: ShellContext, + project_path: PathBuf, +} + +impl ReplServer { + pub fn new(project_path: PathBuf) -> Self { + let engine = Naviscope::new(project_path.clone()); + let naviscope = Arc::new(RwLock::new(engine)); + let current_node = Arc::new(RwLock::new(None)); + let context = ShellContext::new(naviscope, current_node); + + Self { + context, + project_path, + } + } + + pub fn run(&self) -> Result<(), Box> { + println!("Project: {:?}", self.project_path); + + self.initialize_index()?; + self.start_watcher(); + + println!("Type 'help' for commands."); + + let line_editor = self.setup_line_editor()?; + self.run_loop(line_editor) + } + + fn initialize_index(&self) -> Result<(), Box> { + let mut engine = self.context.naviscope.write().unwrap(); + let start = std::time::Instant::now(); + + // Try to load existing index + match engine.load() { + Ok(true) => { + let index = engine.graph(); + println!( + "Index loaded from disk in {:?}. Nodes: {}, Edges: {}", + start.elapsed(), + index.topology.node_count(), + index.topology.edge_count() + ); + } + Ok(false) => { + println!("No existing index found. Building fresh index..."); + } + Err(e) => { + error!("Failed to load index: {}", e); + println!("Failed to load index: {}. Starting fresh scan...", e); + } + } + + // Sync with filesystem (refresh) synchronously before starting the shell + let sync_start = std::time::Instant::now(); + if let Err(e) = engine.refresh() { + error!("Synchronization failed: {}", e); + println!("Warning: Index synchronization failed: {}", e); + } else { + let index = engine.graph(); + println!("Index synchronized in {:?}. Total nodes: {}", sync_start.elapsed(), index.topology.node_count()); + } + Ok(()) + } + + fn start_watcher(&self) { + let naviscope_clone = self.context.naviscope.clone(); + let path_clone = self.project_path.clone(); + + thread::spawn(move || { + let mut watcher = match Watcher::new(&path_clone) { + Ok(w) => w, + Err(e) => { + error!("Failed to start watcher: {}", e); + return; + } + }; + + loop { + if let Some(event) = watcher.next_event() { + if !event.paths.iter().any(|p| naviscope::project::is_relevant_path(p)) { + continue; + } + + thread::sleep(Duration::from_millis(500)); + while watcher.try_next_event().is_some() {} + + info!("Change detected. Re-indexing..."); + + match naviscope_clone.write() { + Ok(mut engine) => { + if let Err(e) = engine.refresh() { + error!("Error during re-indexing: {}", e); + } else { + let index = engine.graph(); + info!( + "Indexing complete! Nodes: {}, Edges: {}", + index.topology.node_count(), + index.topology.edge_count() + ); + } + } + Err(e) => error!("Failed to acquire lock for re-indexing: {}", e), + } + } + } + }); + } + + fn setup_line_editor(&self) -> Result> { + let commands = vec![ + "help".into(), "exit".into(), "quit".into(), "ls".into(), "cd".into(), + "pwd".into(), "clear".into(), "grep".into(), "cat".into(), "deps".into(), + ]; + + let completer = Box::new(NaviscopeCompleter::new( + commands, + self.context.clone(), + )); + + let completion_menu = Box::new(ColumnarMenu::default().with_name("completion_menu")); + + let mut keybindings = default_emacs_keybindings(); + keybindings.add_binding( + KeyModifiers::NONE, + KeyCode::Tab, + ReedlineEvent::UntilFound(vec![ + ReedlineEvent::Menu("completion_menu".to_string()), + ReedlineEvent::MenuNext, + ]), + ); + + let history_file = dirs::home_dir() + .map(|mut p| { + p.push(".naviscope"); + p.push("shell"); + let _ = std::fs::create_dir_all(&p); + p.push("history"); + p + }) + .unwrap(); + + let history = Box::new( + FileBackedHistory::with_file(500, history_file.clone()) + .unwrap_or_else(|_| FileBackedHistory::new(500).expect("Failed to create history")), + ); + + Ok(Reedline::create() + .with_history(history) + .with_completer(completer) + .with_menu(ReedlineMenu::EngineCompleter(completion_menu)) + .with_hinter(Box::new(DefaultHinter::default().with_style(nu_ansi_term::Style::new().italic().fg(nu_ansi_term::Color::LightGray)))) + .with_edit_mode(Box::new(Emacs::new(keybindings)))) + } + + fn run_loop(&self, mut line_editor: Reedline) -> Result<(), Box> { + let mut context = self.context.clone(); + + loop { + let curr = context.current_fqn(); + let prompt = DefaultPrompt::new(curr.clone()); + let sig = line_editor.read_line(&prompt); + + match sig { + Ok(Signal::Success(buffer)) => { + let trimmed = buffer.trim(); + if trimmed.is_empty() { continue; } + if trimmed == "exit" || trimmed == "quit" { break; } + + match parse_shell_command(trimmed) { + Ok(Some(cmd)) => { + let handler = self::handlers::get_handler(&cmd); + + match handler.handle(&cmd, &mut context) { + Ok(output) => { + if !output.is_empty() { println!("{}", output); } + if matches!(cmd, ShellCommand::Clear) { + let _ = line_editor.clear_screen(); + } + }, + Err(e) => eprintln!("Error: {}", e), + } + }, + Ok(None) => {}, // Help or handled by Clap + Err(e) => eprintln!("Error: {}", e), + } + } + Ok(Signal::CtrlD) | Ok(Signal::CtrlC) => { + println!("Bye!"); + break; + } + x => println!("Event: {:?}", x), + } + } + Ok(()) + } +} + +pub fn run(path: Option) -> Result<(), Box> { + let project_path = path.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))); + let server = ReplServer::new(project_path); + server.run() +} diff --git a/src/cli/shell/prompt.rs b/src/cli/shell/prompt.rs new file mode 100644 index 0000000..d544627 --- /dev/null +++ b/src/cli/shell/prompt.rs @@ -0,0 +1,68 @@ +use reedline::{Prompt, PromptEditMode, PromptHistorySearch}; +use std::borrow::Cow; + +pub struct DefaultPrompt { + current_node: Option, +} + +impl DefaultPrompt { + pub fn new(current_node: Option) -> Self { + Self { current_node } + } +} + +impl Prompt for DefaultPrompt { + fn render_prompt_left(&self) -> Cow<'_, str> { + match &self.current_node { + Some(node) => { + let display_node = if node.len() > 40 { + shorten_fqn(node) + } else { + node.clone() + }; + Cow::Owned(format!("naviscope {} > ", display_node)) + }, + None => Cow::Borrowed("naviscope / > "), + } + } + + fn render_prompt_right(&self) -> Cow<'_, str> { + Cow::Borrowed("") + } + + fn render_prompt_indicator(&self, _edit_mode: PromptEditMode) -> Cow<'_, str> { + Cow::Borrowed("") + } + + fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> { + Cow::Borrowed(".. ") + } + + fn render_prompt_history_search_indicator(&self, _history_search: PromptHistorySearch) -> Cow<'_, str> { + Cow::Borrowed("(search) ") + } +} + +fn shorten_fqn(fqn: &str) -> String { + let parts: Vec<&str> = fqn.split('.').collect(); + if parts.len() <= 2 { + return fqn.to_string(); + } + + let mut result = String::new(); + // Abbreviate all but the last 2 parts + for (i, part) in parts.iter().enumerate() { + if i < parts.len() - 2 { + if let Some(c) = part.chars().next() { + result.push(c); + result.push('.'); + } + } else { + result.push_str(part); + if i < parts.len() - 1 { + result.push('.'); + } + } + } + result +} diff --git a/src/cli/shell/view.rs b/src/cli/shell/view.rs new file mode 100644 index 0000000..fb6d9c5 --- /dev/null +++ b/src/cli/shell/view.rs @@ -0,0 +1,158 @@ +use naviscope::model::graph::{BuildElement, CodeElement, GraphNode, NodeKind}; +use naviscope::model::signature::TypeRef; +use naviscope::model::lang::java::{JavaElement, JavaParameter}; +use naviscope::model::lang::gradle::GradleElement; +use tabled::Tabled; +use std::path::PathBuf; + +/// A terminal-optimized view of a GraphNode (Detailed) +#[derive(Tabled)] +pub struct ShellNodeView { + pub kind: String, + pub name: String, + pub relation: String, + pub signature: String, + pub location: String, + pub fqn: String, +} + +/// A short view of a GraphNode +#[derive(Tabled)] +pub struct ShellNodeViewShort { + pub kind: String, + pub name: String, +} + +impl ShellNodeView { + pub fn from_node(node: &GraphNode, relation: Option) -> Self { + let location = node.file_path().map(|path: &PathBuf| { + let filename = path.file_name() + .and_then(|n| n.to_str()) + .unwrap_or("-"); + + if let Some(range) = node.range() { + return format!("{}:{}", filename, range.start_line + 1); + } + filename.to_string() + }).unwrap_or_else(|| "-".to_string()); + + let is_container = matches!(node.kind(), + NodeKind::Class | + NodeKind::Interface | + NodeKind::Enum | + NodeKind::Annotation); + + let name = if is_container { + format!("{}/", node.name()) + } else { + node.name().to_string() + }; + + let signature = match node { + GraphNode::Code(code_el) => match code_el { + CodeElement::Java { element, .. } => match element { + JavaElement::Method(m) => { + if m.is_constructor { + let params_str = m.parameters.iter() + .map(|p| format!("{}", fmt_type(&p.type_ref))) + .collect::>() + .join(", "); + format!("{}({})", m.name, params_str) + } else { + fmt_shell_signature(&m.parameters, &m.return_type) + } + } + JavaElement::Field(f) => { + format!("{} {}", fmt_type(&f.type_ref), f.name) + } + _ => "-".to_string(), + }, + }, + GraphNode::Build(build_el) => match build_el { + BuildElement::Gradle { element, .. } => match element { + GradleElement::Dependency(d) => { + let group = d.group.as_deref().unwrap_or("?"); + let version = d.version.as_deref().unwrap_or("?"); + format!("{}:{}:{}", group, d.name, version) + } + _ => "-".to_string(), + }, + }, + }; + + Self { + fqn: shorten_fqn(node.fqn()), + name, + kind: node.kind().to_string(), + relation: relation.unwrap_or_else(|| "-".to_string()), + signature, + location, + } + } +} + +pub fn shorten_fqn(fqn: &str) -> String { + let parts: Vec<&str> = fqn.split('.').collect(); + if parts.len() <= 2 { + return fqn.to_string(); + } + let mut result = String::new(); + for (i, part) in parts.iter().enumerate() { + if i < parts.len() - 2 { + if let Some(c) = part.chars().next() { + result.push(c); + result.push('.'); + } + } else { + result.push_str(part); + if i < parts.len() - 1 { + result.push('.'); + } + } + } + result +} + +fn fmt_type(t: &TypeRef) -> String { + match t { + TypeRef::Raw(s) => s.clone(), + TypeRef::Id(s) => s.split('.').last().unwrap_or(s).to_string(), + TypeRef::Generic { base, args } => { + let args_str = args.iter().map(fmt_type).collect::>().join(", "); + format!("{}<{}>", fmt_type(base), args_str) + }, + TypeRef::Array { element, dimensions } => { + format!("{}{}", fmt_type(element), "[]".repeat(*dimensions)) + }, + _ => "?".to_string(), + } +} + +fn fmt_shell_signature(params: &[JavaParameter], return_type: &TypeRef) -> String { + let return_type_str = fmt_type(return_type); + let params_str = params.iter() + .map(|p| fmt_type(&p.type_ref)) + .collect::>() + .join(", "); + + let total_len = params_str.len() + return_type_str.len(); + if total_len <= 50 { + format!("({}) -> {}", params_str, return_type_str) + } else { + format!("(...)\n -> {}", return_type_str) + } +} + +pub fn get_kind_weight(kind: &str) -> i32 { + match kind.to_lowercase().as_str() { + "package" => 1, + "class" => 2, + "interface" => 3, + "enum" => 4, + "annotation" => 5, + "constructor" => 6, + "method" => 7, + "field" => 8, + _ => 99, + } +} diff --git a/src/index.rs b/src/index.rs index d6999e6..dcaf5a4 100644 --- a/src/index.rs +++ b/src/index.rs @@ -143,16 +143,26 @@ impl Naviscope { } /// Loads the index for the project from the fixed storage path. - pub fn load(&mut self) -> Result<()> { + /// Returns Ok(true) if loaded, Ok(false) if file doesn't exist. + pub fn load(&mut self) -> Result { let path = self.get_project_index_path(); if !path.exists() { - return Ok(()); + return Ok(false); } - let bytes = std::fs::read(path)?; - self.graph = postcard::from_bytes(&bytes) - .map_err(|e| NaviscopeError::Parsing(e.to_string()))?; - Ok(()) + let file = std::fs::File::open(path)?; + let reader = std::io::BufReader::new(file); + match rmp_serde::from_read(reader) { + Ok(graph) => { + self.graph = graph; + Ok(true) + } + Err(e) => { + // If loading fails, reset to fresh graph to ensure consistent state for fresh scan + self.graph = CodeGraph::new(); + Err(NaviscopeError::Parsing(e.to_string())) + } + } } /// Saves the index to the fixed storage path. @@ -164,9 +174,10 @@ impl Naviscope { std::fs::create_dir_all(parent)?; } - let bytes = postcard::to_stdvec(&self.graph) + let file = std::fs::File::create(path)?; + let mut writer = std::io::BufWriter::new(file); + rmp_serde::encode::write(&mut writer, &self.graph) .map_err(|e| NaviscopeError::Parsing(e.to_string()))?; - std::fs::write(path, bytes)?; Ok(()) } @@ -179,34 +190,23 @@ impl Naviscope { Ok(()) } - /// Loads an index from a specific file path. - pub fn load_from_file>(path: P) -> Result { - let bytes = std::fs::read(path)?; - let graph: CodeGraph = postcard::from_bytes(&bytes) - .map_err(|e| NaviscopeError::Parsing(e.to_string()))?; - Ok(Self { - graph, - project_root: PathBuf::new(), // Root is unknown when loading from arbitrary file - }) - } - - /// Saves the index to a specific file path. - pub fn save_to_file>(&self, path: P) -> Result<()> { - let bytes = postcard::to_stdvec(&self.graph) - .map_err(|e| NaviscopeError::Parsing(e.to_string()))?; - std::fs::write(path, bytes)?; - Ok(()) + pub fn build_index(&mut self) -> Result<()> { + // Try to load existing index first if memory is empty + if self.graph.file_map.is_empty() { + let _ = self.load(); + } + + self.refresh() } - pub fn build_index(&mut self) -> Result<()> { + /// Scans for changes and updates the graph in memory. + /// Does not reload from disk, but saves to disk if changes are detected. + pub fn refresh(&mut self) -> Result<()> { use crate::model::graph::GraphOp; use crate::resolver::engine::IndexResolver; - // Try to load existing index first - let load_result = self.load(); - - // If load succeeded but version doesn't match, clear and start fresh - if load_result.is_ok() && self.graph.version != CURRENT_VERSION { + // If version doesn't match, clear and start fresh + if self.graph.version != CURRENT_VERSION { let _ = self.clear_project_index(); // Reset to a fresh index with current version self.graph = CodeGraph::new(); @@ -226,9 +226,9 @@ impl Naviscope { } } - for path in deleted_paths { + for path in &deleted_paths { self.apply_graph_op(GraphOp::RemovePath { path: path.clone() })?; - self.graph.file_map.remove(&path); + self.graph.file_map.remove(path); } // Update file metadata for each parsed file @@ -243,12 +243,15 @@ impl Naviscope { let all_ops = resolver.resolve(parse_results)?; // Phase 3: Apply (serial merge into the graph - fast memory operations) + let has_changes = !all_ops.is_empty() || !deleted_paths.is_empty(); for op in all_ops { self.apply_graph_op(op)?; } - // Save updated index - self.save()?; + // Save updated index only if there were changes + if has_changes { + self.save()?; + } Ok(()) } diff --git a/src/lsp/hover.rs b/src/lsp/hover.rs index 5c2b85a..5570fb2 100644 --- a/src/lsp/hover.rs +++ b/src/lsp/hover.rs @@ -1,10 +1,56 @@ use tower_lsp::jsonrpc::Result; use tower_lsp::lsp_types::*; use crate::lsp::LspServer; -use crate::query::model::NodeSummary; - +use crate::model::graph::{GraphNode, CodeElement, BuildElement}; +use crate::model::signature::TypeRef; use crate::parser::SymbolResolution; +fn fmt_type(t: &TypeRef) -> String { + match t { + TypeRef::Raw(s) => s.clone(), + TypeRef::Id(s) => s.split('.').last().unwrap_or(s).to_string(), + TypeRef::Generic { base, args } => { + let args_str = args.iter().map(fmt_type).collect::>().join(", "); + format!("{}<{}>", fmt_type(base), args_str) + }, + TypeRef::Array { element, dimensions } => { + format!("{}{}", fmt_type(element), "[]".repeat(*dimensions)) + }, + _ => "?".to_string(), + } +} + +fn get_node_signature(node: &GraphNode) -> Option { + match node { + GraphNode::Code(code_el) => match code_el { + CodeElement::Java { element, .. } => match element { + crate::model::lang::java::JavaElement::Method(m) => { + let params_str = m.parameters.iter() + .map(|p| format!("{}", fmt_type(&p.type_ref))) + .collect::>() + .join(", "); + let return_type_str = fmt_type(&m.return_type); + Some(format!("({}) -> {}", params_str, return_type_str)) + } + crate::model::lang::java::JavaElement::Field(f) => { + Some(format!("{} {}", fmt_type(&f.type_ref), f.name)) + } + _ => None, + }, + }, + GraphNode::Build(build_el) => match build_el { + BuildElement::Gradle { element, .. } => match element { + crate::model::lang::gradle::GradleElement::Dependency(d) => { + let group = d.group.as_deref().unwrap_or("?"); + let version = d.version.as_deref().unwrap_or("?"); + Some(format!("{}:{}:{}", group, d.name, version)) + } + _ => None, + }, + }, + } +} + pub async fn hover(server: &LspServer, params: HoverParams) -> Result> { let uri = params.text_document_position_params.text_document.uri; let position = params.text_document_position_params.position; @@ -52,21 +98,20 @@ pub async fn hover(server: &LspServer, params: HoverParams) -> Result for NodeKind { "method" => NodeKind::Method, "constructor" => NodeKind::Constructor, "field" => NodeKind::Field, + "package" => NodeKind::Package, + "module" => NodeKind::Module, "dependency" => NodeKind::Dependency, "task" => NodeKind::Task, "plugin" => NodeKind::Plugin, @@ -75,6 +80,8 @@ impl ToString for NodeKind { NodeKind::Method => "method".to_string(), NodeKind::Constructor => "constructor".to_string(), NodeKind::Field => "field".to_string(), + NodeKind::Package => "package".to_string(), + NodeKind::Module => "module".to_string(), NodeKind::Dependency => "dependency".to_string(), NodeKind::Task => "task".to_string(), NodeKind::Plugin => "plugin".to_string(), @@ -136,6 +143,7 @@ impl GraphNode { JavaElement::Annotation(_) => NodeKind::Annotation, JavaElement::Method(m) => if m.is_constructor { NodeKind::Constructor } else { NodeKind::Method }, JavaElement::Field(_) => NodeKind::Field, + JavaElement::Package(_) => NodeKind::Package, }, GraphNode::Build(BuildElement::Gradle { element, .. }) => NodeKind::from(element.kind()), } @@ -211,7 +219,7 @@ impl ResolvedUnit { } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, JsonSchema)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, JsonSchema, ValueEnum)] pub enum EdgeType { // Structural relationships Contains, diff --git a/src/model/lang/gradle.rs b/src/model/lang/gradle.rs index 687db04..78b09d9 100644 --- a/src/model/lang/gradle.rs +++ b/src/model/lang/gradle.rs @@ -2,44 +2,45 @@ use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Debug, Clone)] pub enum GradleElement { - Package(GradlePackage), + Module(GradleModule), Dependency(GradleDependency), } impl GradleElement { pub fn id(&self) -> &str { match self { - GradleElement::Package(p) => &p.id, + GradleElement::Module(m) => &m.id, GradleElement::Dependency(d) => &d.id, } } pub fn name(&self) -> &str { match self { - GradleElement::Package(p) => &p.name, + GradleElement::Module(m) => &m.name, GradleElement::Dependency(d) => &d.name, } } pub fn kind(&self) -> &str { match self { - GradleElement::Package(_) => "package", + GradleElement::Module(_) => "module", GradleElement::Dependency(_) => "dependency", } } } #[derive(Serialize, Deserialize, Debug, Clone)] -pub struct GradlePackage { +pub struct GradleModule { pub name: String, pub id: String, } #[derive(Serialize, Deserialize, Debug, Clone)] pub struct GradleDependency { - pub group: String, + pub group: Option, pub name: String, - pub version: String, + pub version: Option, + pub is_project: bool, pub id: String, } @@ -47,3 +48,9 @@ pub struct GradleDependency { pub struct GradleParseResult { pub dependencies: Vec, } + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GradleSettings { + pub root_project_name: Option, + pub included_projects: Vec, +} diff --git a/src/model/lang/java.rs b/src/model/lang/java.rs index 9fa5b0a..81276a2 100644 --- a/src/model/lang/java.rs +++ b/src/model/lang/java.rs @@ -10,6 +10,7 @@ pub enum JavaElement { Annotation(JavaAnnotation), Method(JavaMethod), Field(JavaField), + Package(JavaPackage), } impl JavaElement { @@ -21,6 +22,7 @@ impl JavaElement { JavaElement::Annotation(e) => &e.id, JavaElement::Method(e) => &e.id, JavaElement::Field(e) => &e.id, + JavaElement::Package(e) => &e.id, } } @@ -32,6 +34,7 @@ impl JavaElement { JavaElement::Annotation(e) => &e.name, JavaElement::Method(e) => &e.name, JavaElement::Field(e) => &e.name, + JavaElement::Package(e) => &e.name, } } @@ -43,6 +46,7 @@ impl JavaElement { JavaElement::Annotation(e) => e.range.as_ref(), JavaElement::Method(e) => e.range.as_ref(), JavaElement::Field(e) => e.range.as_ref(), + JavaElement::Package(_) => None, } } @@ -54,6 +58,7 @@ impl JavaElement { JavaElement::Annotation(e) => e.name_range.as_ref(), JavaElement::Method(e) => e.name_range.as_ref(), JavaElement::Field(e) => e.name_range.as_ref(), + JavaElement::Package(_) => None, } } } @@ -122,3 +127,9 @@ pub struct JavaParameter { pub name: String, pub type_ref: TypeRef, } + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct JavaPackage { + pub name: String, + pub id: String, +} diff --git a/src/model/signature.rs b/src/model/signature.rs index a0ab1d9..59c9902 100644 --- a/src/model/signature.rs +++ b/src/model/signature.rs @@ -2,7 +2,6 @@ use serde::{Deserialize, Serialize}; use schemars::JsonSchema; #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, Hash, JsonSchema)] -#[serde(tag = "kind", content = "data")] pub enum TypeRef { /// Unresolved or primitive type name (e.g., "int", "void", "List") Raw(String), diff --git a/src/parser/gradle.rs b/src/parser/gradle.rs index 9075c3a..d095359 100644 --- a/src/parser/gradle.rs +++ b/src/parser/gradle.rs @@ -1,5 +1,5 @@ use crate::error::{NaviscopeError, Result}; -use crate::model::lang::gradle::GradleDependency; +use crate::model::lang::gradle::{GradleDependency, GradleSettings}; use tree_sitter::{Parser, QueryCursor, StreamingIterator}; unsafe extern "C" { @@ -19,7 +19,6 @@ pub fn parse_dependencies(source_code: &str) -> Result> { .parse(source_code, None) .ok_or_else(|| NaviscopeError::Parsing("Failed to parse gradle file".to_string()))?; - // 1. Load the unified query let query = crate::parser::utils::load_query( &language, include_str!("queries/gradle_definitions.scm"), @@ -28,48 +27,45 @@ pub fn parse_dependencies(source_code: &str) -> Result> { let indices = GradleIndices::new(&query)?; let mut query_cursor = QueryCursor::new(); - let mut item_cursor = QueryCursor::new(); let mut matches = query_cursor.matches(&query, tree.root_node(), source_code.as_bytes()); let mut dependencies = Vec::new(); while let Some(mat) = matches.next() { - // Look for the dependencies block match - let block_node = if let Some(cap) = mat.captures.iter().find(|c| c.index == indices.block) { - cap.node - } else { - continue; - }; - - // 2. Query for items within the blocks - let mut item_matches = item_cursor.matches(&query, block_node, source_code.as_bytes()); - - while let Some(i_mat) = item_matches.next() { - let string_node = if let Some(cap) = i_mat - .captures - .iter() - .find(|c| c.index == indices.dep_string) - { - cap.node - } else { - continue; - }; - - // Parse content - let range = string_node.byte_range(); - if range.end - range.start < 2 { - continue; + // 1. External dependencies + if let Some(_cap) = mat.captures.iter().find(|c| c.index == indices.item) { + if let Some(str_cap) = mat.captures.iter().find(|c| c.index == indices.dep_string) { + let range = str_cap.node.byte_range(); + if range.end - range.start >= 2 { + let dependency_str = &source_code[range.start + 1..range.end - 1]; + let parts: Vec<&str> = dependency_str.split(':').collect(); + if parts.len() == 3 { + dependencies.push(GradleDependency { + group: Some(parts[0].to_string()), + name: parts[1].to_string(), + version: Some(parts[2].to_string()), + is_project: false, + id: String::new(), + }); + } + } } - let dependency_str = &source_code[range.start + 1..range.end - 1]; - - let parts: Vec<&str> = dependency_str.split(':').collect(); - if parts.len() == 3 { - dependencies.push(GradleDependency { - group: parts[0].to_string(), - name: parts[1].to_string(), - version: parts[2].to_string(), - id: String::new(), - }); + } + + // 2. Project dependencies + if let Some(_cap) = mat.captures.iter().find(|c| c.index == indices.project_item) { + if let Some(path_cap) = mat.captures.iter().find(|c| c.index == indices.project_path) { + let range = path_cap.node.byte_range(); + if range.end - range.start >= 2 { + let project_path = &source_code[range.start + 1..range.end - 1]; + dependencies.push(GradleDependency { + group: None, + name: project_path.to_string(), + version: None, + is_project: true, + id: String::new(), + }); + } } } } @@ -77,6 +73,65 @@ pub fn parse_dependencies(source_code: &str) -> Result> { Ok(dependencies) } +pub fn parse_settings(source_code: &str) -> Result { + let mut parser = Parser::new(); + let language = unsafe { tree_sitter_groovy() }; + parser + .set_language(&language) + .map_err(|e| NaviscopeError::Parsing(e.to_string()))?; + + let tree = parser + .parse(source_code, None) + .ok_or_else(|| NaviscopeError::Parsing("Failed to parse gradle settings file".to_string()))?; + + let query = crate::parser::utils::load_query( + &language, + include_str!("queries/gradle_definitions.scm"), + )?; + + let indices = GradleIndices::new(&query)?; + + let mut query_cursor = QueryCursor::new(); + let mut matches = query_cursor.matches(&query, tree.root_node(), source_code.as_bytes()); + + let mut root_project_name = None; + let mut included_projects = Vec::new(); + + while let Some(mat) = matches.next() { + // Root project name + let mut found_root = false; + if let Some(_) = mat.captures.iter().find(|c| c.index == indices.root_assignment) { + found_root = true; + } else if let Some(_) = mat.captures.iter().find(|c| c.index == indices.root_assignment_alt) { + found_root = true; + } + + if found_root { + if let Some(name_cap) = mat.captures.iter().find(|c| c.index == indices.root_name) { + let range = name_cap.node.byte_range(); + if range.end - range.start >= 2 { + root_project_name = Some(source_code[range.start + 1..range.end - 1].to_string()); + } + } + } + + // Included projects + if let Some(_) = mat.captures.iter().find(|c| c.index == indices.include_call) { + if let Some(path_cap) = mat.captures.iter().find(|c| c.index == indices.included_path) { + let range = path_cap.node.byte_range(); + if range.end - range.start >= 2 { + included_projects.push(source_code[range.start + 1..range.end - 1].to_string()); + } + } + } + } + + Ok(GradleSettings { + root_project_name, + included_projects, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -84,35 +139,35 @@ mod tests { #[test] fn test_parse_simple_dependencies() { let gradle_file = r#" - plugins { - id 'java' - } - dependencies { implementation 'com.google.guava:guava:31.1-jre' testImplementation "org.junit.jupiter:junit-jupiter-api:5.8.2" - api("org.apache.commons:commons-lang3:3.12.0") - } - - // Should not be parsed - otherBlock { - implementation 'org.rogue:rogue-dependency:1.0' + implementation project(':core:spring-boot') } "#; let dependencies = parse_dependencies(gradle_file).unwrap(); assert_eq!(dependencies.len(), 3); - assert_eq!(dependencies[0].group, "com.google.guava"); + assert_eq!(dependencies[0].group, Some("com.google.guava".to_string())); assert_eq!(dependencies[0].name, "guava"); - assert_eq!(dependencies[0].version, "31.1-jre"); + assert_eq!(dependencies[0].is_project, false); + + assert_eq!(dependencies[2].name, ":core:spring-boot"); + assert_eq!(dependencies[2].is_project, true); + } - assert_eq!(dependencies[1].group, "org.junit.jupiter"); - assert_eq!(dependencies[1].name, "junit-jupiter-api"); - assert_eq!(dependencies[1].version, "5.8.2"); + #[test] + fn test_parse_settings() { + let settings_file = r#" + rootProject.name = 'spring-boot-build' + include 'core:spring-boot' + include 'module:spring-boot-actuator' + "#; - assert_eq!(dependencies[2].group, "org.apache.commons"); - assert_eq!(dependencies[2].name, "commons-lang3"); - assert_eq!(dependencies[2].version, "3.12.0"); + let settings = parse_settings(settings_file).unwrap(); + assert_eq!(settings.root_project_name, Some("spring-boot-build".to_string())); + assert_eq!(settings.included_projects.len(), 2); + assert_eq!(settings.included_projects[0], "core:spring-boot"); } } diff --git a/src/parser/java/ast.rs b/src/parser/java/ast.rs index cf9ef0f..68ae6db 100644 --- a/src/parser/java/ast.rs +++ b/src/parser/java/ast.rs @@ -320,6 +320,7 @@ impl JavaParser { self.generate_typed_as_edges(t.node, source, &fqn, relations); } } + JavaElement::Package(_) => {} } } @@ -331,6 +332,7 @@ impl JavaParser { JavaElement::Annotation(a) => if !a.modifiers.contains(&m_str) { a.modifiers.push(m_str); } JavaElement::Method(m_node) => if !m_node.modifiers.contains(&m_str) { m_node.modifiers.push(m_str); } JavaElement::Field(f) => if !f.modifiers.contains(&m_str) { f.modifiers.push(m_str); } + JavaElement::Package(_) => {} } } diff --git a/src/parser/java/lsp.rs b/src/parser/java/lsp.rs index 4e73fe5..06fcc72 100644 --- a/src/parser/java/lsp.rs +++ b/src/parser/java/lsp.rs @@ -26,6 +26,7 @@ impl LspParser for JavaParser { crate::model::lang::java::JavaElement::Annotation(_) => NodeKind::Annotation, crate::model::lang::java::JavaElement::Method(ref m) => if m.is_constructor { NodeKind::Constructor } else { NodeKind::Method }, crate::model::lang::java::JavaElement::Field(_) => NodeKind::Field, + crate::model::lang::java::JavaElement::Package(_) => NodeKind::Package, }; RawSymbol { @@ -51,6 +52,7 @@ impl LspParser for JavaParser { NodeKind::Method => SymbolKind::METHOD, NodeKind::Constructor => SymbolKind::CONSTRUCTOR, NodeKind::Field => SymbolKind::FIELD, + NodeKind::Package => SymbolKind::PACKAGE, _ => SymbolKind::VARIABLE, } } diff --git a/src/parser/queries/gradle_definitions.rs b/src/parser/queries/gradle_definitions.rs index ec845b7..22dfa73 100644 --- a/src/parser/queries/gradle_definitions.rs +++ b/src/parser/queries/gradle_definitions.rs @@ -2,4 +2,11 @@ crate::decl_indices!(GradleIndices, { block => "dependencies_block", dep_string => "dep_string", item => "dependency_item", + project_item => "project_dependency_item", + project_path => "project_path", + root_assignment => "root_project_assignment", + root_assignment_alt => "root_project_assignment_alt", + root_name => "root_name", + include_call => "include_call", + included_path => "included_path", }); diff --git a/src/parser/queries/gradle_definitions.scm b/src/parser/queries/gradle_definitions.scm index 71966c8..ee5da7a 100644 --- a/src/parser/queries/gradle_definitions.scm +++ b/src/parser/queries/gradle_definitions.scm @@ -19,3 +19,50 @@ ] (#match? @method_name "^(implementation|api|testImplementation|compileOnly)$") ) @dependency_item + +;; Pattern for project dependencies +( + [ + (function_call + function: (identifier) @method_name + args: (argument_list + (function_call + function: (identifier) @proj_fn + args: (argument_list (string) @project_path)))) + (juxt_function_call + function: (identifier) @method_name + args: (argument_list + (function_call + function: (identifier) @proj_fn + args: (argument_list (string) @project_path)))) + ] + (#match? @method_name "^(implementation|api|testImplementation|compileOnly)$") + (#eq? @proj_fn "project") +) @project_dependency_item + +;; Pattern for settings.gradle +( + (assignment + left: [ + (field_access + object: (identifier) @obj + field: (identifier) @field) + (identifier) @field_id + ] + right: (string) @root_name) + (#match? @obj "rootProject") + (#match? @field "name") + (#match? @field_id "rootProject.name") +) @root_project_assignment + +( + [ + (function_call + function: (identifier) @include_fn + args: (argument_list (string) @included_path)) + (juxt_function_call + function: (identifier) @include_fn + args: (argument_list (string) @included_path)) + ] + (#eq? @include_fn "include") +) @include_call diff --git a/src/project/scanner.rs b/src/project/scanner.rs index fb955e0..186b494 100644 --- a/src/project/scanner.rs +++ b/src/project/scanner.rs @@ -1,6 +1,6 @@ use super::is_relevant_path; use super::source::{BuildTool, Language, SourceFile}; -use crate::model::lang::gradle::GradleParseResult; +use crate::model::lang::gradle::{GradleParseResult, GradleSettings}; use crate::parser::gradle; use crate::parser::java::JavaParser; use crate::parser::{IndexParser, GlobalParseResult}; @@ -16,6 +16,7 @@ use xxhash_rust::xxh3::Xxh3; pub enum ParsedContent { Java(GlobalParseResult), Gradle(GradleParseResult), + GradleSettings(GradleSettings), } pub struct ParsedFile { @@ -25,12 +26,12 @@ pub struct ParsedFile { impl ParsedFile { pub fn is_build(&self) -> bool { - matches!(self.content, ParsedContent::Gradle(..)) + matches!(self.content, ParsedContent::Gradle(..) | ParsedContent::GradleSettings(..)) } pub fn build_tool(&self) -> Option { match self.content { - ParsedContent::Gradle(..) => Some(BuildTool::Gradle), + ParsedContent::Gradle(..) | ParsedContent::GradleSettings(..) => Some(BuildTool::Gradle), _ => None, } } @@ -38,7 +39,7 @@ impl ParsedFile { pub fn language(&self) -> Option { match self.content { ParsedContent::Java(..) => Some(Language::Java), - ParsedContent::Gradle(..) => Some(Language::BuildFile), + ParsedContent::Gradle(..) | ParsedContent::GradleSettings(..) => Some(Language::BuildFile), } } @@ -89,11 +90,22 @@ impl Scanner { let extension = path.extension()?.to_str()?; if file_name == "build.gradle" || file_name == "build.gradle.kts" { - let deps = gradle::parse_dependencies(&content_str).ok()?; + let deps = gradle::parse_dependencies(&content_str).unwrap_or_else(|_| Vec::new()); Some(ParsedFile { file: source_file, content: ParsedContent::Gradle(GradleParseResult { dependencies: deps }), }) + } else if file_name == "settings.gradle" || file_name == "settings.gradle.kts" { + let settings = gradle::parse_settings(&content_str).unwrap_or_else(|_| { + crate::model::lang::gradle::GradleSettings { + root_project_name: None, + included_projects: Vec::new(), + } + }); + Some(ParsedFile { + file: source_file, + content: ParsedContent::GradleSettings(settings), + }) } else if extension == "java" { let parser = JavaParser::new().ok()?; let res = parser diff --git a/src/query/engine.rs b/src/query/engine.rs index 7ab0dac..d46e300 100644 --- a/src/query/engine.rs +++ b/src/query/engine.rs @@ -2,7 +2,7 @@ use crate::error::{NaviscopeError, Result}; use crate::index::CodeGraph; use crate::model::graph::{EdgeType, NodeKind}; use crate::query::dsl::GraphQuery; -use crate::query::model::NodeSummary; +use crate::query::model::{QueryResult, QueryResultEdge}; use petgraph::Direction as PetDirection; use regex::RegexBuilder; @@ -15,7 +15,7 @@ impl<'a> QueryEngine<'a> { Self { graph } } - pub fn execute(&self, query: &GraphQuery) -> Result { + pub fn execute(&self, query: &GraphQuery) -> Result { match query { GraphQuery::Grep { pattern, @@ -27,54 +27,69 @@ impl<'a> QueryEngine<'a> { .build() .map_err(|e| NaviscopeError::Parsing(format!("Invalid regex: {}", e)))?; - let mut results = Vec::new(); + let mut nodes = Vec::new(); for node in self.graph.topology.node_weights() { - let summary = NodeSummary::from(node); - // Check if either FQN or Name matches the pattern - if regex.is_match(&summary.fqn) || regex.is_match(&summary.name) { + if regex.is_match(node.fqn()) || regex.is_match(node.name()) { if kind.is_empty() || kind.contains(&node.kind()) { - results.push(summary); + nodes.push(node.clone()); } } - if results.len() >= *limit { + if nodes.len() >= *limit { break; } } - Ok(serde_json::to_value(results)?) + Ok(QueryResult::new(nodes, vec![])) } GraphQuery::Ls { fqn, kind, modifiers: _ } => { if let Some(target_fqn) = fqn { self.traverse_neighbors(target_fqn, &[EdgeType::Contains], PetDirection::Outgoing, kind) } else { - // When FQN is missing, list all top-level modules (Gradle Package nodes with file paths) - let mut results = Vec::new(); - for node in self.graph.topology.node_weights() { - if let crate::model::graph::GraphNode::Build(crate::model::graph::BuildElement::Gradle { - element: crate::model::lang::gradle::GradleElement::Package(_), - file_path - }) = node { - // Only actual project module nodes are associated with the file_path of build.gradle - // Java package nodes also use GradleElement::Package but do not have a file_path - if file_path.is_some() { - let summary = NodeSummary::from(node); + // When FQN is missing, list all top-level nodes + let mut nodes = Vec::new(); + + // 1. Try to find Modules first (this is what we almost always want in root) + for idx in self.graph.topology.node_indices() { + let node = &self.graph.topology[idx]; + if node.kind() == NodeKind::Module { + let has_parent = self.graph.topology + .edges_directed(idx, PetDirection::Incoming) + .any(|e| e.weight().edge_type == EdgeType::Contains); + + if !has_parent { + nodes.push(node.clone()); + } + } + } + + // 2. If no top-level modules, but user asked for specific kind or we found nothing + if nodes.is_empty() { + for idx in self.graph.topology.node_indices() { + let node = &self.graph.topology[idx]; + let has_parent = self.graph.topology + .edges_directed(idx, PetDirection::Incoming) + .any(|e| e.weight().edge_type == EdgeType::Contains); + + if !has_parent { if kind.is_empty() || kind.contains(&node.kind()) { - results.push(summary); + nodes.push(node.clone()); } } + if nodes.len() >= 50 { break; } } } - Ok(serde_json::to_value(results)?) + + Ok(QueryResult::new(nodes, vec![])) } } GraphQuery::Cat { fqn } => { if let Some(&idx) = self.graph.fqn_map.get(fqn) { let node = &self.graph.topology[idx]; - Ok(serde_json::to_value(node)?) + Ok(QueryResult::new(vec![node.clone()], vec![])) } else { - Ok(serde_json::Value::Null) + Ok(QueryResult::empty()) } } GraphQuery::Deps { fqn, rev, edge_types } => { @@ -94,14 +109,23 @@ impl<'a> QueryEngine<'a> { edge_filter: &[EdgeType], dir: PetDirection, kind_filter: &[NodeKind], - ) -> Result { + ) -> Result { let start_idx = self .graph .fqn_map .get(fqn) - .ok_or_else(|| NaviscopeError::Parsing(format!("Node not found: {}", fqn)))?; + .ok_or_else(|| { + // Debug log to help identify the mismatch + eprintln!("DEBUG: traverse_neighbors failed. Looking for FQN: '{}'", fqn); + eprintln!("DEBUG: Available FQNs count: {}", self.graph.fqn_map.len()); + if let Some(closest) = self.graph.fqn_map.keys().find(|k| k.contains(fqn)) { + eprintln!("DEBUG: Found something containing '{}': '{}'", fqn, closest); + } + NaviscopeError::Parsing(format!("Node not found: {}", fqn)) + })?; - let mut results = Vec::new(); + let mut nodes = Vec::new(); + let mut edges_result = Vec::new(); let mut edges = self .graph .topology @@ -112,14 +136,26 @@ impl<'a> QueryEngine<'a> { let edge_data = &self.graph.topology[edge_idx]; if edge_filter.is_empty() || edge_filter.contains(&edge_data.edge_type) { let neighbor_node = &self.graph.topology[neighbor_idx]; - let summary = NodeSummary::from(neighbor_node); + let start_node = &self.graph.topology[*start_idx]; if kind_filter.is_empty() || kind_filter.contains(&neighbor_node.kind()) { - results.push(summary); + nodes.push(neighbor_node.clone()); + + let (from, to) = if dir == PetDirection::Outgoing { + (start_node.fqn().to_string(), neighbor_node.fqn().to_string()) + } else { + (neighbor_node.fqn().to_string(), start_node.fqn().to_string()) + }; + + edges_result.push(QueryResultEdge { + from, + to, + data: edge_data.clone(), + }); } } } - Ok(serde_json::to_value(results)?) + Ok(QueryResult::new(nodes, edges_result)) } } diff --git a/src/query/mod.rs b/src/query/mod.rs index 6c44b3a..52a4403 100644 --- a/src/query/mod.rs +++ b/src/query/mod.rs @@ -4,4 +4,4 @@ pub mod model; pub use dsl::GraphQuery; pub use engine::QueryEngine; -pub use model::NodeSummary; +pub use model::QueryResult; diff --git a/src/query/model.rs b/src/query/model.rs index 0485c52..502f56b 100644 --- a/src/query/model.rs +++ b/src/query/model.rs @@ -1,124 +1,31 @@ -use crate::model::graph::{BuildElement, CodeElement, GraphNode}; -use crate::model::signature::TypeRef; -use serde::Serialize; +use crate::model::graph::{GraphEdge, GraphNode}; +use serde::{Deserialize, Serialize}; -fn fmt_type(t: &TypeRef) -> String { - match t { - TypeRef::Raw(s) => s.clone(), - TypeRef::Id(s) => s.clone(), - TypeRef::Generic { base, args } => { - let args_str = args.iter().map(fmt_type).collect::>().join(", "); - format!("{}<{}>", fmt_type(base), args_str) - }, - TypeRef::Array { element, dimensions } => { - format!("{}{}", fmt_type(element), "[]".repeat(*dimensions)) - }, - _ => "?".to_string(), - } +/// A structured edge in the query result +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QueryResultEdge { + pub from: String, + pub to: String, + pub data: GraphEdge, } -/// Format method signature with smart line breaks for long signatures -fn fmt_method_signature(params: &[crate::model::lang::java::JavaParameter], return_type: &TypeRef) -> String { - let return_type_str = fmt_type(return_type); - - // Format parameters (only types, no names) - let params_str = if params.is_empty() { - String::new() - } else { - params - .iter() - .map(|p| fmt_type(&p.type_ref)) - .collect::>() - .join(", ") - }; - - // Estimate total length - let total_len = params_str.len() + return_type_str.len() + 10; // +10 for "() -> " - - // If signature is short, keep it on one line - if total_len <= 80 { - return format!("({}) -> {}", params_str, return_type_str); - } - - // For long signatures, use multi-line format with proper alignment - if params.is_empty() { - // No params, just break before return type - format!("()\n -> {}", return_type_str) - } else if params.len() > 3 || params_str.len() > 50 { - // Many params or long param list: each param on its own line - let param_lines: Vec = params - .iter() - .map(|p| format!(" {}", fmt_type(&p.type_ref))) - .collect(); - format!("(\n{}\n) -> {}", param_lines.join(",\n"), return_type_str) - } else { - // Few params but long return type: break before return type - format!("({})\n -> {}", params_str, return_type_str) - } +/// The result of a query execution, representing a subgraph +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct QueryResult { + pub nodes: Vec, + pub edges: Vec, } -/// Summary information of a node, intended to provide a concise context for the Agent -#[derive(Serialize, Debug)] -pub struct NodeSummary { - pub fqn: String, - pub name: String, - pub kind: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub signature: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub location: Option, -} - -impl From<&GraphNode> for NodeSummary { - fn from(node: &GraphNode) -> Self { - let location = node.file_path().map(|path| { - if let Some(range) = node.range() { - return format!("{}:{}:{}", path.display(), range.start_line + 1, range.start_col + 1); - } - path.display().to_string() - }); +impl QueryResult { + pub fn new(nodes: Vec, edges: Vec) -> Self { + Self { nodes, edges } + } - let signature = match node { - GraphNode::Code(code_el) => match code_el { - CodeElement::Java { element, .. } => match element { - crate::model::lang::java::JavaElement::Method(m) => { - if m.is_constructor { - let params_str = if m.parameters.is_empty() { - String::new() - } else { - m.parameters - .iter() - .map(|p| format!("{} {}", fmt_type(&p.type_ref), p.name)) - .collect::>() - .join(", ") - }; - Some(format!("{}({})", m.name, params_str)) - } else { - Some(fmt_method_signature(&m.parameters, &m.return_type)) - } - } - crate::model::lang::java::JavaElement::Field(f) => { - Some(format!("{} {}", fmt_type(&f.type_ref), f.name)) - } - _ => None, - }, - }, - GraphNode::Build(build_el) => match build_el { - BuildElement::Gradle { element, .. } => match element { - crate::model::lang::gradle::GradleElement::Dependency(d) => { - Some(format!("{}:{}:{}", d.group, d.name, d.version)) - } - _ => None, - }, - }, - }; + pub fn empty() -> Self { + Self::default() + } - Self { - fqn: node.fqn().to_string(), - name: node.name().to_string(), - kind: node.kind().to_string(), - signature, - location, - } + pub fn is_empty(&self) -> bool { + self.nodes.is_empty() } } diff --git a/src/resolver/lang/gradle.rs b/src/resolver/lang/gradle.rs index 99a7ba4..0f255d5 100644 --- a/src/resolver/lang/gradle.rs +++ b/src/resolver/lang/gradle.rs @@ -1,8 +1,9 @@ use crate::resolver::{BuildResolver, ProjectContext}; use crate::error::Result; use crate::model::graph::{EdgeType, GraphEdge, GraphNode, ResolvedUnit}; -use crate::model::lang::gradle::{GradleElement, GradlePackage}; +use crate::model::lang::gradle::{GradleElement, GradleModule}; use crate::project::scanner::{ParsedContent, ParsedFile}; +use std::collections::HashMap; pub struct GradleResolver; @@ -17,51 +18,141 @@ impl BuildResolver for GradleResolver { let mut unit = ResolvedUnit::new(); let mut context = ProjectContext::new(); + // 1. First pass: identify root and all modules + let settings_file = files + .iter() + .find(|f| matches!(f.content, ParsedContent::GradleSettings(_))); + + let mut root_name = "root".to_string(); + let mut root_path = std::path::PathBuf::new(); + let mut included_projects = Vec::new(); + + if let Some(f) = settings_file { + if let ParsedContent::GradleSettings(s) = &f.content { + if let Some(name) = &s.root_project_name { + root_name = name.clone(); + } + root_path = f.file.path.parent().unwrap().to_path_buf(); + included_projects = s.included_projects.clone(); + } + } else if let Some(first) = files.first() { + root_path = first.file.path.parent().unwrap().to_path_buf(); + root_name = root_path.file_name().and_then(|s| s.to_str()).unwrap_or("root").to_string(); + } + + let root_module_id = "module::root".to_string(); + context.path_to_module.insert(root_path.clone(), root_module_id.clone()); + + // Create root node + unit.add_node( + root_module_id.clone(), + GraphNode::gradle( + GradleElement::Module(GradleModule { + name: root_name.clone(), + id: root_module_id.clone(), + }), + None, + ), + ); + + // Pre-create all included modules to ensure nodes exist before edges + let mut module_to_path = HashMap::new(); + module_to_path.insert(":".to_string(), root_path.clone()); + + for project_path in &included_projects { + let mut current_name = String::new(); + let mut current_fs_path = root_path.clone(); + + for part in project_path.split(':') { + if part.is_empty() { continue; } + + let parent_name = if current_name.is_empty() { ":".to_string() } else { current_name.clone() }; + current_name = format!("{}:{}", current_name, part); + current_fs_path.push(part); + + let current_id = format!("module:{}", current_name); + let parent_id = if parent_name == ":" { "module::root".to_string() } else { format!("module:{}", parent_name) }; + + // Pre-create node + unit.add_node( + current_id.clone(), + GraphNode::gradle( + GradleElement::Module(GradleModule { + name: current_name.clone(), + id: current_id.clone(), + }), + None, // Will be updated if build.gradle is found + ), + ); + + unit.add_edge(parent_id, current_id.clone(), GraphEdge::new(EdgeType::Contains)); + + if current_name == format!(":{}", project_path.trim_start_matches(':')) { + module_to_path.insert(current_name.clone(), current_fs_path.clone()); + } + } + } + + // 2. Second pass: process build.gradle files to add detailed info and dependencies for file in files { if let ParsedContent::Gradle(parse_result) = &file.content { - // Get the directory containing the build.gradle file - let parent_path = file.file.path.parent().unwrap().to_path_buf(); + let current_fs_path = file.file.path.parent().unwrap(); - // Infer module name from the directory name - let module_name = parent_path - .file_name() - .and_then(|s| s.to_str()) - .map(|s| format!(":{}", s)) - .unwrap_or_else(|| ":root".to_string()); - - let module_id = format!("module:{}", module_name); + let module_name = module_to_path + .iter() + .find(|(_, path)| *path == current_fs_path) + .map(|(name, _)| name.clone()) + .unwrap_or_else(|| { + format!(":{}", current_fs_path.file_name().unwrap().to_str().unwrap()) + }); + + let module_id = if module_name == ":" { + "module::root".to_string() + } else { + format!("module:{}", module_name) + }; - // Add to context for phase 2 - context.path_to_module.insert(parent_path, module_id.clone()); + context.path_to_module.insert(current_fs_path.to_path_buf(), module_id.clone()); - // Add module node + // Update node with file path (AddNode with same ID updates it) unit.add_node( module_id.clone(), GraphNode::gradle( - GradleElement::Package(GradlePackage { - name: module_name.clone(), + GradleElement::Module(GradleModule { + name: if module_name == ":" { root_name.clone() } else { module_name.clone() }, id: module_id.clone(), }), Some(file.file.path.clone()), ), ); - // Add dependencies for dep in &parse_result.dependencies { - let dep_id = format!("dep:{}:{}:{}", dep.group, dep.name, dep.version); - let mut dep_node = dep.clone(); - dep_node.id = dep_id.clone(); - - unit.add_node( - dep_id.clone(), - GraphNode::gradle( - GradleElement::Dependency(dep_node), - Some(file.file.path.clone()), - ), - ); - - // Link: Module uses this dependency - unit.add_edge(module_id.clone(), dep_id, GraphEdge::new(EdgeType::UsesDependency)); + if dep.is_project { + let target_module_name = if dep.name.starts_with(':') { + dep.name.clone() + } else { + format!("{}:{}", module_name, dep.name) + }; + let target_id = if target_module_name == ":" { "module::root".to_string() } else { format!("module:{}", target_module_name) }; + unit.add_edge(module_id.clone(), target_id, GraphEdge::new(EdgeType::UsesDependency)); + } else { + let group = dep.group.as_deref().unwrap_or(""); + let version = dep.version.as_deref().unwrap_or(""); + let dep_id = format!("dep:{}:{}:{}", group, dep.name, version); + + let mut dep_node = dep.clone(); + dep_node.id = dep_id.clone(); + + unit.add_node( + dep_id.clone(), + GraphNode::gradle( + GradleElement::Dependency(dep_node), + Some(file.file.path.clone()), + ), + ); + + unit.add_edge(module_id.clone(), dep_id, GraphEdge::new(EdgeType::UsesDependency)); + } } } } @@ -69,3 +160,110 @@ impl BuildResolver for GradleResolver { Ok((unit, context)) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::lang::gradle::{GradleDependency, GradleSettings, GradleParseResult}; + use crate::project::source::SourceFile; + use crate::model::graph::GraphOp; + use std::path::PathBuf; + + fn create_mock_file(path: &str, content: ParsedContent) -> ParsedFile { + ParsedFile { + file: SourceFile { + path: PathBuf::from(path), + content_hash: 0, + last_modified: 0, + }, + content, + } + } + + #[test] + fn test_resolve_multi_module_hierarchy() { + let resolver = GradleResolver::new(); + + let settings = GradleSettings { + root_project_name: Some("my-project".to_string()), + included_projects: vec!["core".to_string(), "core:api".to_string()], + }; + let settings_file = create_mock_file("/repo/settings.gradle", ParsedContent::GradleSettings(settings)); + + let root_build = create_mock_file("/repo/build.gradle", ParsedContent::Gradle(GradleParseResult { dependencies: vec![] })); + let core_build = create_mock_file("/repo/core/build.gradle", ParsedContent::Gradle(GradleParseResult { dependencies: vec![] })); + let api_build = create_mock_file("/repo/core/api/build.gradle", ParsedContent::Gradle(GradleParseResult { + dependencies: vec![ + GradleDependency { + group: None, + name: ":core".to_string(), + version: None, + is_project: true, + id: String::new(), + } + ] + })); + + let files = vec![&settings_file, &root_build, &core_build, &api_build]; + let (unit, _) = resolver.resolve(&files).unwrap(); + + let node_ids: Vec<_> = unit.ops.iter().filter_map(|op| { + if let GraphOp::AddNode { id, .. } = op { Some(id.clone()) } else { None } + }).collect(); + + assert!(node_ids.contains(&"module::root".to_string())); + assert!(node_ids.contains(&"module::core".to_string())); + assert!(node_ids.contains(&"module::core:api".to_string())); + + let contains_edges: Vec<_> = unit.ops.iter().filter_map(|op| { + if let GraphOp::AddEdge { from_id, to_id, edge } = op { + if edge.edge_type == EdgeType::Contains { Some((from_id.clone(), to_id.clone())) } else { None } + } else { None } + }).collect(); + + assert!(contains_edges.contains(&("module::root".to_string(), "module::core".to_string()))); + assert!(contains_edges.contains(&("module::core".to_string(), "module::core:api".to_string()))); + + let dep_edges: Vec<_> = unit.ops.iter().filter_map(|op| { + if let GraphOp::AddEdge { from_id, to_id, edge } = op { + if edge.edge_type == EdgeType::UsesDependency { Some((from_id.clone(), to_id.clone())) } else { None } + } else { None } + }).collect(); + + assert!(dep_edges.contains(&("module::core:api".to_string(), "module::core".to_string()))); + } + + #[test] + fn test_resolve_external_dependencies() { + let resolver = GradleResolver::new(); + + let build_file = create_mock_file("/repo/build.gradle", ParsedContent::Gradle(GradleParseResult { + dependencies: vec![ + GradleDependency { + group: Some("com.google.guava".to_string()), + name: "guava".to_string(), + version: Some("31.1-jre".to_string()), + is_project: false, + id: String::new(), + } + ] + })); + + let files = vec![&build_file]; + let (unit, _) = resolver.resolve(&files).unwrap(); + + let dep_id = "dep:com.google.guava:guava:31.1-jre".to_string(); + + let has_dep_node = unit.ops.iter().any(|op| { + if let GraphOp::AddNode { id, .. } = op { id == &dep_id } else { false } + }); + assert!(has_dep_node); + + let has_edge = unit.ops.iter().any(|op| { + if let GraphOp::AddEdge { from_id, to_id, edge } = op { + from_id == "module::root" && to_id == &dep_id && edge.edge_type == EdgeType::UsesDependency + } else { false } + }); + assert!(has_edge); + } +} diff --git a/src/resolver/lang/java/mod.rs b/src/resolver/lang/java/mod.rs index c40ff59..e20cd1b 100644 --- a/src/resolver/lang/java/mod.rs +++ b/src/resolver/lang/java/mod.rs @@ -7,6 +7,7 @@ use crate::project::scanner::{ParsedContent, ParsedFile}; use crate::parser::{SymbolResolution, matches_intent}; use crate::parser::SymbolIntent; use crate::parser::java::JavaParser; +use crate::model::lang::java::{JavaElement, JavaPackage}; use crate::model::signature::TypeRef; use petgraph::stable_graph::NodeIndex; use tree_sitter::Tree; @@ -198,20 +199,27 @@ impl LangResolver for JavaResolver { .unwrap_or_else(|| "module::root".to_string()); let container_id = if let Some(pkg_name) = &parse_result.package_name { - let package_id = format!("{}::{}", module_id, pkg_name); + let package_id = if module_id.contains("::") { + format!("{}.{}", module_id, pkg_name) + } else { + format!("{}::{}", module_id, pkg_name) + }; + + // Create package node unit.add_node( package_id.clone(), - GraphNode::gradle( - crate::model::lang::gradle::GradleElement::Package( - crate::model::lang::gradle::GradlePackage { - name: pkg_name.clone(), - id: package_id.clone(), - }, - ), + GraphNode::java( + JavaElement::Package(JavaPackage { + name: pkg_name.clone(), + id: package_id.clone(), + }), None, ), ); - unit.add_edge(module_id, package_id.clone(), GraphEdge::new(EdgeType::Contains)); + + // Link package to module + unit.add_edge(module_id.clone(), package_id.clone(), GraphEdge::new(EdgeType::Contains)); + package_id } else { module_id From 2e46a53ebc6baeb78f078bd3fdb995f81bf6f891 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Mon, 26 Jan 2026 01:01:45 +0800 Subject: [PATCH 3/4] feat: add function to check for naviscope availability in PATH; update bootstrap logic to utilize found binary directly, enhancing user experience by reducing unnecessary downloads. --- editors/vscode/src/bootstrap.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/editors/vscode/src/bootstrap.ts b/editors/vscode/src/bootstrap.ts index 6aa710e..d24338d 100644 --- a/editors/vscode/src/bootstrap.ts +++ b/editors/vscode/src/bootstrap.ts @@ -15,7 +15,33 @@ const REPO_NAME = 'naviscope'; // Update this version when bundling a new version of the extension const EXPECTED_VERSION = '0.1.0'; +/** + * Check if naviscope is available in PATH + */ +async function checkPathForNaviscope(): Promise { + try { + // Use 'which' on Unix-like systems, 'where' on Windows + const command = process.platform === 'win32' ? 'where' : 'which'; + const { stdout } = await execAsync(`${command} ${BINARY_NAME}`); + const pathInPath = stdout.trim().split('\n')[0]; + if (pathInPath && fs.existsSync(pathInPath)) { + return pathInPath; + } + } catch (e) { + // Command not found in PATH + } + return null; +} + export async function bootstrap(context: vscode.ExtensionContext): Promise { + // First, check if naviscope is available in PATH + const pathBinary = await checkPathForNaviscope(); + if (pathBinary) { + // If found in PATH, use it directly without downloading or checking updates + return pathBinary; + } + + // Only download and check updates if naviscope is not in PATH const naviscopeHome = path.join(os.homedir(), '.naviscope'); const binDir = path.join(naviscopeHome, 'bin'); From bdf65438d360626a984b60ddafa662fd3d38e11d Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Mon, 26 Jan 2026 01:05:58 +0800 Subject: [PATCH 4/4] chore: update version to 0.2.0 across Cargo.lock, Cargo.toml, and VSCode extension files; enhance README with improved descriptions and features for clarity and consistency. --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 63 +++++++++++++++++++++++---------- editors/vscode/README.md | 20 ++++++----- editors/vscode/package.json | 2 +- editors/vscode/src/bootstrap.ts | 4 +-- 6 files changed, 61 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 148fbf2..9fa5217 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1402,7 +1402,7 @@ dependencies = [ [[package]] name = "naviscope" -version = "0.1.0" +version = "0.2.0" dependencies = [ "axum", "cc", diff --git a/Cargo.toml b/Cargo.toml index 76a1a5a..bb8e846 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "naviscope" -version = "0.1.0" +version = "0.2.0" edition = "2024" [dependencies] diff --git a/README.md b/README.md index 0ca3537..1c1acfd 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,19 @@ # Naviscope -Naviscope is a graph-based, structured code query engine specifically designed for Large Language Models (LLMs). It builds a comprehensive **Code Knowledge Graph** that bridges the gap between micro-level source code semantics (calls, inheritance) and macro-level project structures (modules, packages, dependencies). +Naviscope is a **unified Code Knowledge Graph engine** that bridges the gap between AI agents and developers. It builds a comprehensive graph representation of your codebase, connecting micro-level source code semantics (calls, inheritance) with macro-level project structures (modules, packages, dependencies). -Unlike traditional text search, Naviscope provides a deep, structured understanding of your codebase, enabling LLMs to navigate and reason about complex software systems with precision. +Unlike traditional text search or language servers, Naviscope provides a **single, unified knowledge graph** that powers both LLM agents (via MCP) and IDE features (via LSP), enabling precise code navigation and reasoning across complex software systems. ## 🌟 Key Features -- **Code Knowledge Graph**: Represents project entities and their complex relationships in a unified graph using `petgraph`. -- **LLM-Friendly DSL**: A shell-like query interface (`grep`, `ls`, `inspect`, `incoming`, `outgoing`) that returns structured JSON data optimized for LLM agents. +- **Unified Code Knowledge Graph**: A single graph representation using `petgraph` that powers both MCP (for LLMs) and LSP (for IDEs), ensuring consistency across all tools. +- **Zero JVM Overhead**: Built entirely in Rust, providing instant startup and low memory footprint—no more waiting for Java language servers to index. +- **LLM-Optimized Query Interface**: Structured JSON responses via MCP tools (`grep`, `ls`, `inspect`, `deps`) designed specifically for AI agent consumption. - **High-Performance Indexing**: A robust 3-phase processing pipeline (Scan & Parse → Resolve → Apply) utilizing Rust's concurrency for maximum speed. - **Real-time Synchronization**: Automatic graph updates via file system watching (`notify`), ensuring the index stays consistent with your changes. -- **Multi-Protocol Interface**: Support for both **MCP** (Model Context Protocol) for AI agents and **LSP** (Language Server Protocol) for IDEs. -- **Extensible Architecture**: Language-neutral core with a strategy-based resolver. Currently focused on **Java + Gradle**, with Maven support in progress. +- **Dual Protocol Support**: Native **MCP** (Model Context Protocol) for AI agents and **LSP** (Language Server Protocol) for IDEs, both sharing the same underlying graph. +- **Resilient & Fast**: Works effectively even with syntax errors or missing dependencies, providing immediate feedback without blocking on incomplete code. +- **Extensible Architecture**: Language-neutral core with a strategy-based resolver. Currently supports **Java + Gradle**, with Maven support in progress. ## 🏗️ Architecture @@ -54,11 +56,10 @@ npm run package Naviscope implements the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/), allowing LLM agents like Cursor and Claude to directly use its code knowledge graph. #### Available Tools -- **`grep`**: Global search for symbols by pattern and kind (Class, Method, etc.). -- **`ls`**: List package members, class fields, or project modules. +- **`grep`**: Global search for symbols by pattern and kind (Class, Method, etc.) across the entire project. +- **`ls`**: List package members, class fields, or project modules. Explore project structure hierarchically. - **`inspect`**: Retrieve full metadata and source code for a specific Fully Qualified Name (FQN). -- **`incoming`**: Trace inbound relationships like callers, implementers, or references. -- **`outgoing`**: Trace outbound relationships like callees or class dependencies. +- **`deps`**: Analyze dependencies—outgoing (what I depend on) or incoming (who depends on me) with optional edge type filtering. #### Configuring in Cursor To use Naviscope in Cursor: @@ -106,26 +107,50 @@ Naviscope acts as a high-performance LSP server for Java, offering a lightweight - **Other Editors**: Simply point your LSP client to the `naviscope lsp` command. #### Why Naviscope LSP? -- **Zero JVM Overhead**: No more "Java Language Server is indexing..." hanging your UI. -- **Resilient**: Works even if your code has syntax errors or missing dependencies. -- **Unified Knowledge**: Shares the same core graph used by MCP for LLM agents. +- **Zero JVM Overhead**: Built in Rust, providing instant startup and low memory usage—no more "Java Language Server is indexing..." blocking your workflow. +- **Resilient**: Works effectively even with syntax errors or missing dependencies, providing immediate navigation without waiting for perfect code. +- **Unified Knowledge Graph**: Shares the exact same core graph used by MCP for LLM agents, ensuring consistency between AI-assisted development and manual navigation. +- **Lightweight Alternative**: A fast, memory-efficient replacement for JDTLS that doesn't require a full Java runtime. ## 🛠️ Query API Examples The Query DSL (used by `naviscope shell` and MCP) supports several commands for structured exploration: -- `grep`: `grep "UserService"` (or JSON `{"command": "grep", "pattern": "UserService", "kind": ["class"]}`) -- `ls`: `ls "com.example.service"` (or JSON `{"command": "ls", "fqn": "com.example.service"}`) -- `cat`: `cat "com.example.service.UserService"` (or JSON `{"command": "cat", "fqn": "com.example.service.UserService"}`) -- `deps`: `deps "com.example.service.UserService"` (or JSON `{"command": "deps", "fqn": "com.example.service.UserService"}`) + +**Shell Commands:** +```bash +grep "UserService" # Search for symbols matching pattern +ls "com.example.service" # List package contents +cat "com.example.service.UserService" # Inspect full details of a symbol +deps "com.example.service.UserService" # Show dependencies (outgoing by default) +deps --rev "com.example.service.UserService" # Show reverse dependencies (incoming) +``` + +**JSON DSL (for MCP/API):** +```json +{"command": "grep", "pattern": "UserService", "kind": ["class"], "limit": 20} +{"command": "ls", "fqn": "com.example.service", "kind": ["class", "interface"]} +{"command": "cat", "fqn": "com.example.service.UserService"} +{"command": "deps", "fqn": "com.example.service.UserService", "rev": false, "edge_type": ["Calls", "InheritsFrom"]} +``` + +## 🎯 Project Positioning + +Naviscope fills a unique niche in the developer tooling ecosystem: + +**For LLM Agents**: Provides structured, graph-based code understanding that goes far beyond text search, enabling AI assistants to reason about code relationships, dependencies, and architecture. + +**For Developers**: Offers a lightweight, fast LSP server that doesn't require JVM overhead, with the added benefit of sharing the same knowledge graph that powers AI-assisted development. + +**The Unified Advantage**: Unlike traditional tools that maintain separate indexes for different purposes, Naviscope's single knowledge graph ensures that what AI agents see is exactly what developers navigate—creating a seamless, consistent experience across all development workflows. ## 📈 Roadmap (V1) - [x] Core Graph Storage (`petgraph`) - [x] Java & Gradle Parser (Tree-sitter driven) - [x] Shell-like Query DSL Engine - [x] Parallel Indexing & Real-time Updates (`notify`) -- [x] MCP Server implementation +- [x] MCP Server implementation (grep, ls, inspect, deps) - [x] LSP Support (Definition, References, Hierarchy, Hover, etc.) -- [x] VSCode Extension (Initial version) +- [x] VSCode Extension - [ ] Maven Support (In Progress) - [ ] Python/Rust Language Strategies (Planned) diff --git a/editors/vscode/README.md b/editors/vscode/README.md index 7558b66..887ea35 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -1,6 +1,6 @@ # Naviscope for VS Code -Naviscope is a graph-based, structured code query engine that powers your coding experience with deep semantic understanding. This extension brings the power of Naviscope's Language Server Protocol (LSP) integration directly into VS Code, offering a lightweight and blazing-fast alternative for Java navigation. +Naviscope is a unified Code Knowledge Graph engine that powers both AI agents and developers. This VS Code extension brings Naviscope's Language Server Protocol (LSP) integration directly into your editor, offering a lightweight, blazing-fast alternative to JDTLS for Java navigation. ## 🚀 Features @@ -20,9 +20,10 @@ Naviscope builds a comprehensive Code Knowledge Graph of your project, enabling - **Document Highlights**: Highlight all occurrences of a symbol in the current file. ### Why Use Naviscope? -- **Zero JVM Overhead**: No heavy background processes or memory-hogging language servers. -- **Resilient Indexing**: Works effectively even with syntax errors or incomplete code. -- **Unified Graph**: Built on the same engine used by LLM agents via MCP. +- **Zero JVM Overhead**: Built entirely in Rust—no Java runtime required, instant startup, minimal memory footprint. +- **Resilient Indexing**: Works effectively even with syntax errors or incomplete code, providing immediate navigation without waiting for perfect builds. +- **Unified Knowledge Graph**: Shares the exact same code knowledge graph used by LLM agents via MCP, ensuring consistency between AI-assisted development and manual navigation. +- **Lightweight Alternative**: A fast, memory-efficient replacement for JDTLS that doesn't block your workflow. ## 📦 System Requirements @@ -32,15 +33,18 @@ Naviscope builds a comprehensive Code Knowledge Graph of your project, enabling ## 📦 Installation -Just install the extension! +1. Install the extension from the VS Code marketplace or from a `.vsix` file. +2. Ensure the `naviscope` binary is available in your PATH, or configure the `naviscope.path` setting to point to the binary location. +3. Open a Java project—Naviscope will automatically start indexing your workspace. -On first launch, Naviscope will automatically detect your platform and download the necessary binary engine to `~/.naviscope/bin`. It's completely managed by the extension—no manual configuration required. +**Note**: You need to have the `naviscope` CLI installed separately. See the [main repository](https://github.com/biuld/naviscope) for installation instructions. ## 🔧 Troubleshooting If the extension fails to start: -1. Check your internet connection if this is the first run (the binary needs to be downloaded). -2. Check the "Naviscope Client" output channel in VS Code for detailed logs. +1. Ensure the `naviscope` binary is installed and available in your PATH, or configure `naviscope.path` in VS Code settings. +2. Check the "Naviscope Client" output channel in VS Code for detailed logs. +3. Verify your project is a valid Java/Gradle project structure. ## 🗑️ Uninstallation diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 5321087..87170be 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -2,7 +2,7 @@ "name": "naviscope", "displayName": "Naviscope", "description": "Graph-based structured code navigation for Java", - "version": "0.1.0", + "version": "0.2.0", "publisher": "naviscope", "repository": { "type": "git", diff --git a/editors/vscode/src/bootstrap.ts b/editors/vscode/src/bootstrap.ts index d24338d..67b8c51 100644 --- a/editors/vscode/src/bootstrap.ts +++ b/editors/vscode/src/bootstrap.ts @@ -13,7 +13,7 @@ const BINARY_NAME = 'naviscope'; const REPO_OWNER = 'biuld'; const REPO_NAME = 'naviscope'; // Update this version when bundling a new version of the extension -const EXPECTED_VERSION = '0.1.0'; +const EXPECTED_VERSION = '0.2.0'; /** * Check if naviscope is available in PATH @@ -107,7 +107,7 @@ export async function bootstrap(context: vscode.ExtensionContext): Promise { try { const { stdout } = await execAsync(`"${binaryPath}" --version`); - // Expected output: "naviscope 0.1.0" + // Expected output: "naviscope 0.2.0" return stdout.includes(EXPECTED_VERSION); } catch (e) { console.warn('Failed to check version:', e);