From b1ebc08633d05eed1ecf428e1bc1368a40e544c7 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Thu, 5 Feb 2026 00:29:57 +0800 Subject: [PATCH 01/49] feat: Refine Java FQN representation, enhance semantic analysis with declaration filtering and call hierarchy improvements, and introduce a new test engine facade. --- Cargo.lock | 1 + crates/api/src/models/symbol.rs | 10 ++ crates/core/src/facade/semantic.rs | 28 ++++- crates/core/src/features/discovery.rs | 19 ++- crates/core/src/ingest/builder.rs | 49 ++++++-- crates/lang-java/Cargo.toml | 1 + crates/lang-java/src/parser/ast/metadata.rs | 8 +- crates/lang-java/src/parser/naming.rs | 23 +++- crates/lang-java/src/resolver/mod.rs | 52 ++++---- crates/lang-java/src/resolver/scope/member.rs | 4 +- crates/lang-java/src/resolver/scope/mod.rs | 2 + .../src/resolver/scope/package_scope.rs | 45 +++++++ crates/lang-java/tests/capability_boundary.rs | 23 ++-- crates/lang-java/tests/common/mod.rs | 35 ++++++ crates/lang-java/tests/edge_verification.rs | 28 +++-- crates/lang-java/tests/java_integration.rs | 34 +----- crates/lang-java/tests/logic_goto_def.rs | 10 +- crates/lang-java/tests/logic_goto_impl.rs | 10 +- crates/lang-java/tests/logic_goto_ref.rs | 10 +- crates/lang-java/tests/logic_goto_type.rs | 10 +- crates/lang-java/tests/logic_hierarchy.rs | 10 +- crates/lang-java/tests/test_engine_facade.rs | 114 ++++++++++++++++++ 22 files changed, 388 insertions(+), 138 deletions(-) create mode 100644 crates/lang-java/src/resolver/scope/package_scope.rs create mode 100644 crates/lang-java/tests/test_engine_facade.rs diff --git a/Cargo.lock b/Cargo.lock index 3bdc5f8..71f6cb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1390,6 +1390,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.18", + "tokio", "tree-sitter", "tree-sitter-java", ] diff --git a/crates/api/src/models/symbol.rs b/crates/api/src/models/symbol.rs index 6d64ade..a922195 100644 --- a/crates/api/src/models/symbol.rs +++ b/crates/api/src/models/symbol.rs @@ -160,6 +160,16 @@ pub enum SymbolResolution { Global(String), } +impl SymbolResolution { + pub fn fqn(&self) -> Option<&str> { + match self { + SymbolResolution::Local(_, _) => None, + SymbolResolution::Precise(fqn, _) => Some(fqn), + SymbolResolution::Global(fqn) => Some(fqn), + } + } +} + // --- New Core API Types --- #[derive(Debug, Clone)] diff --git a/crates/core/src/facade/semantic.rs b/crates/core/src/facade/semantic.rs index 3eb2343..dca4896 100644 --- a/crates/core/src/facade/semantic.rs +++ b/crates/core/src/facade/semantic.rs @@ -13,7 +13,7 @@ use naviscope_api::semantic::{ CallHierarchyAnalyzer, ReferenceAnalyzer, SemanticError, SemanticResult, SymbolInfoProvider, SymbolNavigator, }; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fs; use std::path::PathBuf; use std::sync::Arc; @@ -266,6 +266,25 @@ impl ReferenceAnalyzer for EngineHandle { } } + // 4. Optional: Filter out declarations if requested + if !query.include_declaration { + let decl_locations: HashSet<_> = match_indices + .iter() + .filter_map(|&idx| { + let node = &shared_graph.topology()[idx]; + let loc = node.location.as_ref()?; + let path = shared_graph.symbols().resolve(&loc.path.0); + let range = loc.selection_range.unwrap_or(loc.range); + Some((path.to_string(), range)) + }) + .collect(); + + all_locations.retain(|loc| { + let path_str = loc.path.to_string_lossy().to_string(); + !decl_locations.contains(&(path_str, loc.range)) + }); + } + all_locations.sort_by(|a, b| { a.path .cmp(&b.path) @@ -363,7 +382,12 @@ impl CallHierarchyAnalyzer for EngineHandle { ) { let node = &graph.topology()[caller_idx]; // Only include methods or constructors as callers - if matches!(node.kind(), NodeKind::Method | NodeKind::Constructor) { + // AND avoid reflexive calls that are actually just the definition site + let is_reflexive = target_indices.contains(&caller_idx); + + if matches!(node.kind(), NodeKind::Method | NodeKind::Constructor) + && !is_reflexive + { caller_map.entry(caller_idx).or_default().push(Range { start_line: loc.range.start.line as usize, start_col: loc.range.start.character as usize, diff --git a/crates/core/src/features/discovery.rs b/crates/core/src/features/discovery.rs index 000aa86..a4887e0 100644 --- a/crates/core/src/features/discovery.rs +++ b/crates/core/src/features/discovery.rs @@ -170,8 +170,23 @@ impl<'a> DiscoveryEngine<'a> { range.start_col, self.index.as_plugin_graph(), ) { - // 3. Identity Check - if &resolved_at_loc == target_resolution { + // 3. Identity Check (Lenient) + let matched = match (&resolved_at_loc, target_resolution) { + (SymbolResolution::Local(r1, _), SymbolResolution::Local(r2, _)) => { + r1 == r2 + } + _ => { + if let (Some(f1), Some(f2)) = + (resolved_at_loc.fqn(), target_resolution.fqn()) + { + f1 == f2 + } else { + false + } + } + }; + + if matched { valid_locations.push(Location { uri: uri.clone(), range: lsp_types::Range { diff --git a/crates/core/src/ingest/builder.rs b/crates/core/src/ingest/builder.rs index ff7e853..77db6d2 100644 --- a/crates/core/src/ingest/builder.rs +++ b/crates/core/src/ingest/builder.rs @@ -239,14 +239,25 @@ impl CodeGraphBuilder { to_id, edge, } => { - let from_id = self.resolve_storage_id(&from_id, None); - let to_id = self.resolve_storage_id(&to_id, None); + let from_fqn = self.resolve_storage_id(&from_id, None); + let to_fqn = self.resolve_storage_id(&to_id, None); - if let (Some(&from), Some(&to)) = ( - self.inner.fqn_index.get(&from_id), - self.inner.fqn_index.get(&to_id), + match ( + self.inner.fqn_index.get(&from_fqn), + self.inner.fqn_index.get(&to_fqn), ) { - self.add_edge(from, to, edge); + (Some(&from), Some(&to)) => { + self.add_edge(from, to, edge); + } + _ => { + eprintln!( + "Failed to add edge: from={:?} (found={}), to={:?} (found={})", + from_fqn, + self.inner.fqn_index.contains_key(&from_fqn), + to_fqn, + self.inner.fqn_index.contains_key(&to_fqn) + ); + } } } GraphOp::RemovePath { path } => { @@ -274,9 +285,33 @@ impl CodeGraphBuilder { Ok(()) } - /// Apply multiple graph operations + /// Apply multiple graph operations in a single atomic-like batch. + /// Reorders ops to ensure correct application: + /// 1. Removals + /// 2. Node additions & updates + /// 3. Edge additions (Relational) pub fn apply_ops(&mut self, ops: Vec) -> crate::error::Result<()> { + let mut destructive = Vec::new(); + let mut additive = Vec::new(); + let mut relational = Vec::new(); + for op in ops { + match op { + GraphOp::RemovePath { .. } => destructive.push(op), + GraphOp::AddNode { .. } + | GraphOp::UpdateFile { .. } + | GraphOp::UpdateIdentifiers { .. } => additive.push(op), + GraphOp::AddEdge { .. } => relational.push(op), + } + } + + for op in destructive { + self.apply_op(op)?; + } + for op in additive { + self.apply_op(op)?; + } + for op in relational { self.apply_op(op)?; } Ok(()) diff --git a/crates/lang-java/Cargo.toml b/crates/lang-java/Cargo.toml index 6b32ea3..daecc06 100644 --- a/crates/lang-java/Cargo.toml +++ b/crates/lang-java/Cargo.toml @@ -18,3 +18,4 @@ lasso = { workspace = true } [dev-dependencies] naviscope-core = { workspace = true } +tokio = { workspace = true } diff --git a/crates/lang-java/src/parser/ast/metadata.rs b/crates/lang-java/src/parser/ast/metadata.rs index 5cd0727..6821cb1 100644 --- a/crates/lang-java/src/parser/ast/metadata.rs +++ b/crates/lang-java/src/parser/ast/metadata.rs @@ -123,7 +123,7 @@ impl JavaParser { source_id: fqn_id.clone(), target_id: naviscope_api::models::symbol::NodeId::Flat(s_name), rel_type: EdgeType::InheritsFrom, - range: None, + range: Some(range_from_ts(s.node.range())), }); } for cc in captures @@ -139,7 +139,7 @@ impl JavaParser { source_id: fqn_id.clone(), target_id: naviscope_api::models::symbol::NodeId::Flat(i), rel_type: EdgeType::Implements, - range: None, + range: Some(range_from_ts(cc.node.range())), }); } } @@ -157,7 +157,7 @@ impl JavaParser { source_id: fqn_id.clone(), target_id: naviscope_api::models::symbol::NodeId::Flat(e), rel_type: EdgeType::InheritsFrom, - range: None, + range: Some(range_from_ts(cc.node.range())), }); } } @@ -178,7 +178,7 @@ impl JavaParser { source_id: fqn_id.clone(), target_id: naviscope_api::models::symbol::NodeId::Flat(i), rel_type: EdgeType::Implements, - range: None, + range: Some(range_from_ts(cc.node.range())), }); } } diff --git a/crates/lang-java/src/parser/naming.rs b/crates/lang-java/src/parser/naming.rs index 31d65dd..68782e0 100644 --- a/crates/lang-java/src/parser/naming.rs +++ b/crates/lang-java/src/parser/naming.rs @@ -70,7 +70,15 @@ impl JavaParser { if let Some(n_node) = parent.child_by_field_name("name") { if seen_ids.insert(n_node.id()) { if let Ok(n_text) = n_node.utf8_text(source.as_bytes()) { - hierarchy.push((pk, n_text.to_string())); + let id_pk = match pk { + naviscope_api::models::graph::NodeKind::Interface + | naviscope_api::models::graph::NodeKind::Enum + | naviscope_api::models::graph::NodeKind::Annotation => { + naviscope_api::models::graph::NodeKind::Class + } + _ => pk, + }; + hierarchy.push((id_pk, n_text.to_string())); } } } @@ -82,7 +90,18 @@ impl JavaParser { parts.extend(hierarchy); // Add self at the end - parts.push((kind, self_name)); + // STABILITY NOTE: For Java, we use NodeKind::Class for all Type-like entities + // in the ID to ensure cross-file references (which often don't know the exact kind) + // can resolve correctly. The actual node.kind will still be accurate. + let id_kind = match kind { + naviscope_api::models::graph::NodeKind::Interface + | naviscope_api::models::graph::NodeKind::Enum + | naviscope_api::models::graph::NodeKind::Annotation => { + naviscope_api::models::graph::NodeKind::Class + } + _ => kind, + }; + parts.push((id_kind, self_name)); naviscope_api::models::symbol::NodeId::Structured(parts) } diff --git a/crates/lang-java/src/resolver/mod.rs b/crates/lang-java/src/resolver/mod.rs index b0da50f..ebc15e9 100644 --- a/crates/lang-java/src/resolver/mod.rs +++ b/crates/lang-java/src/resolver/mod.rs @@ -15,7 +15,7 @@ pub mod context; pub mod scope; use context::ResolutionContext; -use scope::{BuiltinScope, ImportScope, LocalScope, MemberScope, Scope}; +use scope::{BuiltinScope, ImportScope, LocalScope, MemberScope, PackageScope, Scope}; #[derive(Clone)] pub struct JavaResolver { @@ -44,6 +44,9 @@ impl JavaResolver { scopes.push(Box::new(ImportScope { parser: &self.parser, })); + scopes.push(Box::new(PackageScope { + parser: &self.parser, + })); if ctx.intent == SymbolIntent::Type { scopes.push(Box::new(BuiltinScope { @@ -359,10 +362,19 @@ 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 = pkg_name.to_string(); + let package_parts: Vec<_> = pkg_name + .split('.') + .map(|s| { + ( + naviscope_api::models::graph::NodeKind::Package, + s.to_string(), + ) + }) + .collect(); + let package_id = naviscope_api::models::symbol::NodeId::Structured(package_parts); let package_node = IndexNode { - id: package_id.clone().into(), + id: package_id.clone(), name: pkg_name.to_string(), kind: NodeKind::Package, lang: "java".to_string(), @@ -374,7 +386,7 @@ impl LangResolver for JavaResolver { unit.add_edge( module_id.clone().into(), - package_id.clone().into(), + package_id.clone(), GraphEdge::new(EdgeType::Contains), ); @@ -384,7 +396,7 @@ impl LangResolver for JavaResolver { // or just attach to module. // For now, attaching to module seems safer to avoid colliding all default packages. // But this means default package classes might be harder to find via clean FQN if module_id is weird. - module_id + module_id.into() }; let mut known_types = std::collections::HashSet::::new(); @@ -555,30 +567,19 @@ impl LangResolver for JavaResolver { if !matched { if is_last { // Heuristics for last part if not found - if rel.edge_type == EdgeType::Implements { - found_kind = naviscope_api::models::graph::NodeKind::Interface; - } else if rel.edge_type == EdgeType::DecoratedBy { - found_kind = naviscope_api::models::graph::NodeKind::Annotation; - } else if rel.edge_type == EdgeType::InheritsFrom { - if let Some(src_node) = unit.nodes.get(&rel.source_id) { - match src_node.kind { - naviscope_api::models::graph::NodeKind::Interface => { - found_kind = - naviscope_api::models::graph::NodeKind::Interface - } - _ => { - found_kind = - naviscope_api::models::graph::NodeKind::Class - } - } - } else { - found_kind = naviscope_api::models::graph::NodeKind::Class; - } - } else if rel.edge_type == EdgeType::TypedAs { + // NOTE: We now use Class for all Type IDs in Java for stability + if rel.edge_type == EdgeType::Implements + || rel.edge_type == EdgeType::InheritsFrom + || rel.edge_type == EdgeType::TypedAs + || rel.edge_type == EdgeType::DecoratedBy + { found_kind = naviscope_api::models::graph::NodeKind::Class; } else if part.chars().next().map_or(false, |c| c.is_uppercase()) { found_kind = naviscope_api::models::graph::NodeKind::Class; } + } else if part.chars().next().map_or(false, |c| c.is_uppercase()) { + // Not last, but uppercase? Handle inner classes / enclosing classes correctly + found_kind = naviscope_api::models::graph::NodeKind::Class; } else { found_kind = naviscope_api::models::graph::NodeKind::Package; } @@ -589,6 +590,7 @@ impl LangResolver for JavaResolver { let final_target_id = naviscope_api::models::symbol::NodeId::Structured(structured_parts); + unit.add_edge(rel.source_id.clone(), final_target_id, edge); } } diff --git a/crates/lang-java/src/resolver/scope/member.rs b/crates/lang-java/src/resolver/scope/member.rs index 5e392eb..67400b0 100644 --- a/crates/lang-java/src/resolver/scope/member.rs +++ b/crates/lang-java/src/resolver/scope/member.rs @@ -347,7 +347,7 @@ impl SemanticScope> for MemberScope<'_> { self.resolve_expression_type(recv, context) .and_then(|type_ref| self.get_base_fqn(&type_ref)) .and_then(|raw_type_fqn| self.resolve_fqn_from_context(&raw_type_fqn, context)) - .map(|type_fqn| format!("{}.{}", type_fqn, name)) + .map(|type_fqn| format!("{}#{}", type_fqn, name)) .and_then(|candidate| { context .index @@ -368,7 +368,7 @@ impl SemanticScope> for MemberScope<'_> { context .enclosing_classes .iter() - .map(|container_fqn| format!("{}.{}", container_fqn, name)) + .map(|container_fqn| format!("{}#{}", container_fqn, name)) .find_map(|candidate| { context .index diff --git a/crates/lang-java/src/resolver/scope/mod.rs b/crates/lang-java/src/resolver/scope/mod.rs index 39857be..8be8b92 100644 --- a/crates/lang-java/src/resolver/scope/mod.rs +++ b/crates/lang-java/src/resolver/scope/mod.rs @@ -8,8 +8,10 @@ pub mod builtin; pub mod import_scope; pub mod local; pub mod member; +pub mod package_scope; pub use builtin::BuiltinScope; pub use import_scope::ImportScope; pub use local::LocalScope; pub use member::MemberScope; +pub use package_scope::PackageScope; diff --git a/crates/lang-java/src/resolver/scope/package_scope.rs b/crates/lang-java/src/resolver/scope/package_scope.rs new file mode 100644 index 0000000..6edc827 --- /dev/null +++ b/crates/lang-java/src/resolver/scope/package_scope.rs @@ -0,0 +1,45 @@ +use super::ResolutionContext; +use crate::parser::JavaParser; +use naviscope_api::models::{SymbolIntent, SymbolResolution}; +use naviscope_plugin::SemanticScope; + +pub struct PackageScope<'a> { + pub parser: &'a JavaParser, +} + +impl<'a, 'b> SemanticScope> for PackageScope<'b> { + fn resolve( + &self, + name: &str, + context: &ResolutionContext<'a>, + ) -> Option> { + // Java Logic: If a name starts with an uppercase letter and we are in a package, + // it might be a type in the same package. + + // Only trigger for Type intent or Unknown + if !matches!(context.intent, SymbolIntent::Type | SymbolIntent::Unknown) { + return None; + } + + // Heuristic: If it starts with UpperCase, it's likely a class/interface + if !name.chars().next().map_or(false, |c| c.is_uppercase()) { + return None; + } + + if let Some(fqn) = self.parser.resolve_type_name_to_fqn_data( + name, + context.package.as_deref(), + &context.imports, + ) { + if fqn.contains('.') && fqn.ends_with(name) { + return Some(Ok(SymbolResolution::Precise(fqn, SymbolIntent::Type))); + } + } + + None + } + + fn name(&self) -> &'static str { + "PackageScope" + } +} diff --git a/crates/lang-java/tests/capability_boundary.rs b/crates/lang-java/tests/capability_boundary.rs index f4be51d..e118ecc 100644 --- a/crates/lang-java/tests/capability_boundary.rs +++ b/crates/lang-java/tests/capability_boundary.rs @@ -14,21 +14,22 @@ fn cap_structural_nesting() { )]; let (index, _) = setup_java_test_graph(files); - // Assert FQNs exist - // Note: JavaResolver prepends "module::root." to packages when no specific module is found // Assert FQNs exist // Note: JavaResolver uses clear package names now for FQN compatibility println!("Graph nodes:"); for idx in index.topology().node_indices() { let node = &index.topology()[idx]; use naviscope_plugin::NamingConvention; - println!(" - {:?}", naviscope_plugin::DotPathConvention.render_fqn(node.id, index.fqns())); + println!( + " - {:?}", + naviscope_plugin::DotPathConvention.render_fqn(node.id, index.fqns()) + ); } assert!(index.find_node("com.example").is_some()); assert!(index.find_node("com.example.MyClass").is_some()); - assert!(index.find_node("com.example.MyClass.field").is_some()); - assert!(index.find_node("com.example.MyClass.method").is_some()); + assert!(index.find_node("com.example.MyClass#field").is_some()); + assert!(index.find_node("com.example.MyClass#method").is_some()); // Assert nesting via 'Contains' edges let class_idx = index.find_node("com.example.MyClass").unwrap(); @@ -36,8 +37,8 @@ fn cap_structural_nesting() { assert!(index.topology().contains_edge(pkg_idx, class_idx)); - let field_idx = index.find_node("com.example.MyClass.field").unwrap(); - let method_idx = index.find_node("com.example.MyClass.method").unwrap(); + let field_idx = index.find_node("com.example.MyClass#field").unwrap(); + let method_idx = index.find_node("com.example.MyClass#method").unwrap(); assert!(index.topology().contains_edge(class_idx, field_idx)); assert!(index.topology().contains_edge(class_idx, method_idx)); } @@ -102,7 +103,7 @@ fn cap_cross_file_typing() { ]; let (index, _) = setup_java_test_graph(files); - let field_idx = index.find_node("com.app.Main.field").unwrap(); + let field_idx = index.find_node("com.app.Main#field").unwrap(); let type_a_idx = index.find_node("com.lib.TypeA").unwrap(); let has_typed_as = index @@ -147,7 +148,7 @@ fn cap_method_call_tracking() { ]; let (index, _) = setup_java_test_graph(files); - let a_target_idx = index.find_node("A.target").unwrap(); + let a_target_idx = index.find_node("A#target").unwrap(); // Check DiscoveryEngine "Scouting" (uses Reference Index) let discovery = DiscoveryEngine::new(&index, std::collections::HashMap::new()); @@ -219,7 +220,7 @@ fn cap_static_field_access() { ]; let (index, _) = setup_java_test_graph(files); - let config_key_idx = index.find_node("Config.KEY").unwrap(); + let config_key_idx = index.find_node("Config#KEY").unwrap(); // Checking if Main.java is discovered as a candidate for Config.KEY let discovery = DiscoveryEngine::new(&index, std::collections::HashMap::new()); @@ -243,7 +244,7 @@ fn cap_generic_type_link() { ]; let (index, _) = setup_java_test_graph(files); - let list_idx = index.find_node("Main.list").unwrap(); + let list_idx = index.find_node("Main#list").unwrap(); let type_a_idx = index.find_node("TypeA").unwrap(); let has_link = index diff --git a/crates/lang-java/tests/common/mod.rs b/crates/lang-java/tests/common/mod.rs index 69d92e0..00ff6b8 100644 --- a/crates/lang-java/tests/common/mod.rs +++ b/crates/lang-java/tests/common/mod.rs @@ -74,3 +74,38 @@ pub fn setup_java_test_graph( (builder.build(), parsed_files) } + +pub fn offset_to_point(content: &str, offset: usize) -> (usize, usize) { + let pre_content = &content[..offset]; + let line = pre_content.lines().count().max(1) - 1; + let last_newline = pre_content.rfind('\n').map(|p| p + 1).unwrap_or(0); + let col = offset - last_newline; + (line, col) +} + +pub async fn setup_java_engine( + temp_dir: &std::path::Path, + files: Vec<(&str, &str)>, +) -> naviscope_core::facade::EngineHandle { + use naviscope_core::runtime::orchestrator::NaviscopeEngine as CoreEngine; + use naviscope_java::JavaPlugin; + + let mut engine = CoreEngine::new(temp_dir.to_path_buf()); + let java_plugin = JavaPlugin::new().expect("Failed to create JavaPlugin"); + engine.register_language(Arc::new(java_plugin)); + + // Create files + for (path_str, content) in &files { + let path = temp_dir.join(path_str); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(&path, content).unwrap(); + } + + // Index files + let paths: Vec<_> = files.iter().map(|(p, _)| temp_dir.join(p)).collect(); + engine.update_files(paths).await.unwrap(); + + naviscope_core::facade::EngineHandle::from_engine(Arc::new(engine)) +} diff --git a/crates/lang-java/tests/edge_verification.rs b/crates/lang-java/tests/edge_verification.rs index 3182768..5bb2f06 100644 --- a/crates/lang-java/tests/edge_verification.rs +++ b/crates/lang-java/tests/edge_verification.rs @@ -13,7 +13,10 @@ fn assert_edge(graph: &CodeGraph, from_fqn: &str, to_fqn: &str, expected_type: E println!("Available nodes:"); for (id, _) in graph.fqn_map() { use naviscope_plugin::NamingConvention; - println!(" - {}", naviscope_plugin::DotPathConvention.render_fqn(*id, graph.fqns())); + println!( + " - {}", + naviscope_plugin::DotPathConvention.render_fqn(*id, graph.fqns()) + ); } panic!("Source node not found: {}", from_fqn); } @@ -21,7 +24,10 @@ fn assert_edge(graph: &CodeGraph, from_fqn: &str, to_fqn: &str, expected_type: E println!("Available nodes:"); for (id, _) in graph.fqn_map() { use naviscope_plugin::NamingConvention; - println!(" - {}", naviscope_plugin::DotPathConvention.render_fqn(*id, graph.fqns())); + println!( + " - {}", + naviscope_plugin::DotPathConvention.render_fqn(*id, graph.fqns()) + ); } panic!("Target node not found: {}", to_fqn); } @@ -34,7 +40,10 @@ fn assert_edge(graph: &CodeGraph, from_fqn: &str, to_fqn: &str, expected_type: E println!("Graph nodes:"); for (id, _) in graph.fqn_map() { use naviscope_plugin::NamingConvention; - println!(" - {}", naviscope_plugin::DotPathConvention.render_fqn(*id, graph.fqns())); + println!( + " - {}", + naviscope_plugin::DotPathConvention.render_fqn(*id, graph.fqns()) + ); } println!("Edges from {}:", from_fqn); let mut edges = graph @@ -63,7 +72,10 @@ fn assert_edge(graph: &CodeGraph, from_fqn: &str, to_fqn: &str, expected_type: E fn assert_reference_scouted(graph: &CodeGraph, target_fqn: &str, expected_file: &str) { let target_idx = graph.find_node(target_fqn).expect("Target node not found"); - let discovery = naviscope_core::features::discovery::DiscoveryEngine::new(graph, std::collections::HashMap::new()); + let discovery = naviscope_core::features::discovery::DiscoveryEngine::new( + graph, + std::collections::HashMap::new(), + ); let candidate_files = discovery.scout_references(&[target_idx]); assert!( candidate_files.contains(&std::path::PathBuf::from(expected_file)), @@ -91,14 +103,14 @@ fn test_edge_contains() { assert_edge( &index, "com.test.Container", - "com.test.Container.field", + "com.test.Container#field", EdgeType::Contains, ); // Class -> Method assert_edge( &index, "com.test.Container", - "com.test.Container.method", + "com.test.Container#method", EdgeType::Contains, ); } @@ -143,7 +155,7 @@ fn test_edge_calls() { let (index, _) = setup_java_test_graph(files); // Using FQN in call to ensure resolution works in batch mode - assert_reference_scouted(&index, "com.test.Service.helper", "src/Service.java"); + assert_reference_scouted(&index, "com.test.Service#helper", "src/Service.java"); } #[test] @@ -180,7 +192,7 @@ fn test_edge_typed_as() { ]; let (index, _) = setup_java_test_graph(files); - assert_edge(&index, "User.address", "Address", EdgeType::TypedAs); + assert_edge(&index, "User#address", "Address", EdgeType::TypedAs); } #[test] diff --git a/crates/lang-java/tests/java_integration.rs b/crates/lang-java/tests/java_integration.rs index 6ce2956..fcf1b4e 100644 --- a/crates/lang-java/tests/java_integration.rs +++ b/crates/lang-java/tests/java_integration.rs @@ -1,6 +1,6 @@ mod common; -use common::setup_java_test_graph; +use common::{offset_to_point, setup_java_test_graph}; use naviscope_core::ingest::resolver::SemanticResolver; use naviscope_java::resolver::JavaResolver; @@ -328,20 +328,11 @@ public class DefaultApplicationArguments { let content = &trees[0].1; let tree = &trees[0].2; - // Helper to convert byte offset to (line, col) - let offset_to_point = |offset: usize| -> (usize, usize) { - let pre_content = &content[..offset]; - let line = pre_content.lines().count().max(1) - 1; - let last_newline = pre_content.rfind('\n').map(|p| p + 1).unwrap_or(0); - let col = offset - last_newline; - (line, col) - }; - // Resolve 'this' in 'this.source.getNonOptionArgs()' let this_pos = content .find("this.source") .expect("Could not find 'this.source'"); - let (line, col) = offset_to_point(this_pos); + let (line, col) = offset_to_point(content, this_pos); let res = resolver.resolve_at(tree, content, line, col, &index); @@ -416,15 +407,7 @@ public class DefaultApplicationArguments { .expect("Find expression") + "this.source.".len(); - // Helper to get line/col from offset - let offset_to_point = |offset: usize| { - let pre = &source_content[..offset]; - let line = pre.lines().count() - 1; - let col = offset - pre.rfind('\n').map(|p| p + 1).unwrap_or(0); - (line, col) - }; - - let (line, col) = offset_to_point(method_call_pos); + let (line, col) = offset_to_point(source_content, method_call_pos); println!( "Testing Spring scenario at line {}, col {} (offset {})", line, col, method_call_pos @@ -471,16 +454,7 @@ fn test_field_method_call_resolution() { // Resolve 'doB' in 'b.doB()' let do_b_pos = a_content.find("doB()").expect("Could not find 'doB()'"); - // We need line/col for resolve_at - let offset_to_point = |offset: usize| -> (usize, usize) { - let pre_content = &a_content[..offset]; - let line = pre_content.lines().count().max(1) - 1; - let last_newline = pre_content.rfind('\n').map(|p| p + 1).unwrap_or(0); - let col = offset - last_newline; - (line, col) - }; - - let (line, col) = offset_to_point(do_b_pos); + let (line, col) = offset_to_point(a_content, do_b_pos); let res = resolver.resolve_at(a_tree, a_content, line, col, &index); diff --git a/crates/lang-java/tests/logic_goto_def.rs b/crates/lang-java/tests/logic_goto_def.rs index c8eeebd..094de87 100644 --- a/crates/lang-java/tests/logic_goto_def.rs +++ b/crates/lang-java/tests/logic_goto_def.rs @@ -1,19 +1,11 @@ mod common; -use common::setup_java_test_graph; +use common::{offset_to_point, setup_java_test_graph}; use naviscope_core::features::CodeGraphLike; use naviscope_core::ingest::parser::SymbolResolution; use naviscope_core::ingest::resolver::SemanticResolver; use naviscope_java::resolver::JavaResolver; -fn offset_to_point(content: &str, offset: usize) -> (usize, usize) { - let pre_content = &content[..offset]; - let line = pre_content.lines().count().max(1) - 1; - let last_newline = pre_content.rfind('\n').map(|p| p + 1).unwrap_or(0); - let col = offset - last_newline; - (line, col) -} - #[test] fn test_goto_definition_local() { let files = vec![( diff --git a/crates/lang-java/tests/logic_goto_impl.rs b/crates/lang-java/tests/logic_goto_impl.rs index 05f842f..5edd076 100644 --- a/crates/lang-java/tests/logic_goto_impl.rs +++ b/crates/lang-java/tests/logic_goto_impl.rs @@ -1,18 +1,10 @@ mod common; -use common::setup_java_test_graph; +use common::{offset_to_point, setup_java_test_graph}; use naviscope_core::features::CodeGraphLike; use naviscope_core::ingest::resolver::SemanticResolver; use naviscope_java::resolver::JavaResolver; -fn offset_to_point(content: &str, offset: usize) -> (usize, usize) { - let pre_content = &content[..offset]; - let line = pre_content.lines().count().max(1) - 1; - let last_newline = pre_content.rfind('\n').map(|p| p + 1).unwrap_or(0); - let col = offset - last_newline; - (line, col) -} - #[test] fn test_goto_implementation_interface() { let files = vec![ diff --git a/crates/lang-java/tests/logic_goto_ref.rs b/crates/lang-java/tests/logic_goto_ref.rs index 37c20ea..6dc258e 100644 --- a/crates/lang-java/tests/logic_goto_ref.rs +++ b/crates/lang-java/tests/logic_goto_ref.rs @@ -1,17 +1,9 @@ mod common; -use common::setup_java_test_graph; +use common::{offset_to_point, setup_java_test_graph}; use naviscope_core::ingest::resolver::SemanticResolver; use naviscope_java::resolver::JavaResolver; -fn offset_to_point(content: &str, offset: usize) -> (usize, usize) { - let pre_content = &content[..offset]; - let line: usize = pre_content.lines().count().max(1) - 1; - let last_newline = pre_content.rfind('\n').map(|p| p + 1).unwrap_or(0); - let col = offset - last_newline; - (line, col) -} - #[test] fn test_goto_references_method() { let files = vec![ diff --git a/crates/lang-java/tests/logic_goto_type.rs b/crates/lang-java/tests/logic_goto_type.rs index 895f221..5d635b6 100644 --- a/crates/lang-java/tests/logic_goto_type.rs +++ b/crates/lang-java/tests/logic_goto_type.rs @@ -1,18 +1,10 @@ mod common; -use common::setup_java_test_graph; +use common::{offset_to_point, setup_java_test_graph}; use naviscope_core::features::CodeGraphLike; use naviscope_core::ingest::resolver::SemanticResolver; use naviscope_java::resolver::JavaResolver; -fn offset_to_point(content: &str, offset: usize) -> (usize, usize) { - let pre_content = &content[..offset]; - let line = pre_content.lines().count().max(1) - 1; - let last_newline = pre_content.rfind('\n').map(|p| p + 1).unwrap_or(0); - let col = offset - last_newline; - (line, col) -} - #[test] fn test_goto_type_definition_variable() { let files = vec![ diff --git a/crates/lang-java/tests/logic_hierarchy.rs b/crates/lang-java/tests/logic_hierarchy.rs index a0a4531..09a05c0 100644 --- a/crates/lang-java/tests/logic_hierarchy.rs +++ b/crates/lang-java/tests/logic_hierarchy.rs @@ -1,20 +1,12 @@ mod common; -use common::setup_java_test_graph; +use common::{offset_to_point, setup_java_test_graph}; use naviscope_core::features::CodeGraphLike; use naviscope_core::features::discovery::DiscoveryEngine; use naviscope_core::ingest::parser::SymbolResolution; use naviscope_core::ingest::resolver::SemanticResolver; use naviscope_java::resolver::JavaResolver; -fn offset_to_point(content: &str, offset: usize) -> (usize, usize) { - let pre_content = &content[..offset]; - let line = pre_content.lines().count().max(1) - 1; - let last_newline = pre_content.rfind('\n').map(|p| p + 1).unwrap_or(0); - let col = offset - last_newline; - (line, col) -} - #[test] fn test_call_hierarchy_incoming() { let files = vec![( diff --git a/crates/lang-java/tests/test_engine_facade.rs b/crates/lang-java/tests/test_engine_facade.rs new file mode 100644 index 0000000..895cf2d --- /dev/null +++ b/crates/lang-java/tests/test_engine_facade.rs @@ -0,0 +1,114 @@ +mod common; + +use common::{offset_to_point, setup_java_engine}; +use naviscope_api::models::{PositionContext, ReferenceQuery, SymbolQuery, SymbolResolution}; +use naviscope_api::semantic::{CallHierarchyAnalyzer, ReferenceAnalyzer, SymbolNavigator}; + +#[tokio::test] +async fn test_full_engine_java_facade() { + let temp_dir = std::env::temp_dir().join("naviscope_java_facade_test"); + if temp_dir.exists() { + let _ = std::fs::remove_dir_all(&temp_dir); + } + std::fs::create_dir_all(&temp_dir).unwrap(); + + let files = vec![ + ( + "com/example/Base.java", + "package com.example; public interface Base { void run(); }", + ), + ( + "com/example/Impl.java", + "package com.example; public class Impl implements Base { public void run() {} }", + ), + ( + "com/example/App.java", + r#" +package com.example; +public class App { + void start() { + Base b = new Impl(); + b.run(); + } +} +"#, + ), + ]; + + let handle = setup_java_engine(&temp_dir, files).await; + let graph = handle.graph().await; + graph.topology(); // Ensure graph is usable + + // 1. Resolve 'run' call in App.java (line 6 roughly) + let app_path = temp_dir.join("com/example/App.java"); + let app_content = std::fs::read_to_string(&app_path).unwrap(); + let run_pos = app_content.find("b.run()").unwrap() + 2; // Point to 'run' + let (line, col) = offset_to_point(&app_content, run_pos); + + let ctx = PositionContext { + uri: format!("file://{}", app_path.display()), + line: line as u32, + char: col as u32, + content: Some(app_content.clone()), + }; + + let resolution = handle + .resolve_symbol_at(&ctx) + .await + .unwrap() + .expect("Should resolve b.run()"); + + // Should resolve to com.example.Base#run + match &resolution { + SymbolResolution::Precise(fqn, _) => assert_eq!(fqn, "com.example.Base#run"), + SymbolResolution::Global(fqn) => assert_eq!(fqn, "com.example.Base#run"), + _ => panic!( + "Expected precise or global resolution, got {:?}", + resolution + ), + } + + // 2. Find implementations of 'run' + let query = SymbolQuery { + language: naviscope_api::models::Language::JAVA, + resolution: resolution.clone(), + }; + let impls = handle.find_implementations(&query).await.unwrap(); + assert_eq!(impls.len(), 1); + assert!(impls[0].path.to_string_lossy().contains("Impl.java")); + + // 3. Find incoming calls to 'Impl#run' + // In this simple case, b.run() is a call to Base#run, so it might not show up for Impl#run unless we have advanced pointer analysis + let calls = handle + .find_incoming_calls("com.example.Impl#run") + .await + .unwrap(); + assert!( + calls.is_empty(), + "Direct lookup of Impl#run should be empty if only Base#run is called" + ); + + // 4. Find incoming calls to 'Base#run' + let target_fqn = "com.example.Base#run"; + let incoming_base = handle.find_incoming_calls(target_fqn).await.unwrap(); + + // Test find_references as a comparison + let query_refs = ReferenceQuery { + language: naviscope_api::models::Language::JAVA, + resolution: resolution.clone(), + include_declaration: false, + }; + let refs = handle.find_references(&query_refs).await.unwrap(); + assert_eq!( + refs.len(), + 1, + "find_references should have found 1 reference in App.java" + ); + + assert_eq!( + incoming_base.len(), + 1, + "find_incoming_calls should have found 1 caller in App.java" + ); + assert_eq!(incoming_base[0].from.id, "com.example.App#start"); +} From 49d68fa24368c1c7c36937427fb0fcd5f89a0adb Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Thu, 5 Feb 2026 22:13:23 +0800 Subject: [PATCH 02/49] feat: Add `NodeSource` to graph nodes and enable filtering by source in queries. --- crates/api/src/models/graph.rs | 26 ++++++++++ crates/cli/src/index.rs | 1 + crates/cli/src/shell/command.rs | 48 ++++++++++++++++++- crates/cli/src/shell/completer.rs | 1 + crates/cli/src/shell/handlers.rs | 4 ++ crates/cli/src/shell/mod.rs | 1 + crates/core/src/facade/mod.rs | 8 +++- crates/core/src/features/query.rs | 41 ++++++++++++---- crates/core/src/ingest/builder.rs | 48 ++++++++++++------- crates/core/src/ingest/parser/utils.rs | 1 + crates/core/src/model/graph.rs | 1 + crates/core/src/model/storage/converter.rs | 2 + crates/core/src/model/storage/model.rs | 2 + crates/core/tests/engine_api.rs | 1 + crates/core/tests/indexing.rs | 3 +- crates/core/tests/semantic_traits.rs | 7 ++- crates/lang-gradle/src/lib.rs | 1 + crates/lang-gradle/src/resolver.rs | 6 ++- crates/lang-java/src/lib.rs | 1 + crates/lang-java/src/parser/index.rs | 1 + crates/lang-java/src/resolver/mod.rs | 1 + crates/lang-java/src/resolver/scope/member.rs | 1 + crates/lang-java/tests/common/mod.rs | 3 ++ crates/lsp/src/symbols.rs | 12 +++-- crates/mcp/src/lib.rs | 6 +++ crates/plugin/src/converter.rs | 1 + crates/plugin/src/model.rs | 3 +- crates/plugin/src/utils.rs | 3 +- 28 files changed, 197 insertions(+), 37 deletions(-) diff --git a/crates/api/src/models/graph.rs b/crates/api/src/models/graph.rs index 792c1e7..9ffbf75 100644 --- a/crates/api/src/models/graph.rs +++ b/crates/api/src/models/graph.rs @@ -116,6 +116,23 @@ impl NodeMetadata for EmptyMetadata { } } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum NodeSource { + /// Defined in the current project (source code available) + Project, + /// External dependency (library, vendor code) + External, + /// Language builtin / Primitive type + Builtin, +} + +impl Default for NodeSource { + fn default() -> Self { + Self::Project + } +} + #[derive(Debug, Clone)] pub struct GraphNode { /// Unique Identifier (Structured FQN) @@ -126,6 +143,8 @@ pub struct GraphNode { pub kind: NodeKind, /// Language identifier (Symbol) pub lang: Symbol, + /// Source origin + pub source: NodeSource, /// Physical Location pub location: Option, /// Extension metadata @@ -139,6 +158,7 @@ impl Default for GraphNode { name: Symbol(lasso::Spur::default()), kind: NodeKind::Custom("unknown".to_string()), lang: Symbol(lasso::Spur::default()), + source: NodeSource::Project, location: None, metadata: Arc::new(EmptyMetadata), } @@ -187,6 +207,8 @@ pub struct DisplayGraphNode { pub name: String, pub kind: NodeKind, pub lang: String, + #[serde(default)] + pub source: NodeSource, pub location: Option, // Rendering fields @@ -209,6 +231,8 @@ pub enum GraphQuery { #[serde(default)] kind: Vec, #[serde(default)] + sources: Vec, + #[serde(default)] modifiers: Vec, }, @@ -217,6 +241,8 @@ pub enum GraphQuery { pattern: String, #[serde(default)] kind: Vec, + #[serde(default)] + sources: Vec, #[serde(default = "default_limit")] limit: usize, }, diff --git a/crates/cli/src/index.rs b/crates/cli/src/index.rs index c3081d1..31903d9 100644 --- a/crates/cli/src/index.rs +++ b/crates/cli/src/index.rs @@ -19,6 +19,7 @@ pub async fn run(path: PathBuf) -> Result<(), Box> { let query = naviscope_api::models::GraphQuery::Ls { fqn: None, kind: vec![], + sources: vec![], modifiers: vec![], }; if let Ok(res) = engine.query(&query).await { diff --git a/crates/cli/src/shell/command.rs b/crates/cli/src/shell/command.rs index f0f9e8c..ddc8f69 100644 --- a/crates/cli/src/shell/command.rs +++ b/crates/cli/src/shell/command.rs @@ -1,6 +1,6 @@ use super::view::{ShellNodeView, ShellNodeViewShort, get_kind_weight}; use clap::{Parser, ValueEnum}; -use naviscope_api::models::{EdgeType, GraphQuery, NodeKind, QueryResult}; +use naviscope_api::models::{EdgeType, GraphQuery, NodeKind, NodeSource, QueryResult}; use shlex; use tabled::{Table, settings::Style}; @@ -26,6 +26,24 @@ pub enum CliNodeKind { Other, } +#[derive(Clone, Debug, ValueEnum)] +#[clap(rename_all = "lowercase")] +pub enum CliNodeSource { + Project, + External, + Builtin, +} + +impl From for NodeSource { + fn from(source: CliNodeSource) -> Self { + match source { + CliNodeSource::Project => NodeSource::Project, + CliNodeSource::External => NodeSource::External, + CliNodeSource::Builtin => NodeSource::Builtin, + } + } +} + impl From for NodeKind { fn from(kind: CliNodeKind) -> Self { match kind { @@ -81,12 +99,18 @@ pub enum ShellCommand { /// Filter by node kind (e.g. class, interface, method) #[arg(long, value_delimiter = ',')] kind: Vec, + /// Filter by node source (e.g. project, external, builtin) + #[arg(long, value_delimiter = ',')] + source: Vec, /// Filter by modifiers (e.g. public, static) #[arg(long, value_delimiter = ',')] modifiers: Vec, /// Use long listing format #[arg(short, long)] long: bool, + /// Show all nodes (including external and builtins) + #[arg(short, long)] + all: bool, }, /// Change current node context (internal shell command) Cd { @@ -104,6 +128,9 @@ pub enum ShellCommand { /// Filter by node kind #[arg(long, value_delimiter = ',')] kind: Vec, + /// Filter by node source + #[arg(long, value_delimiter = ',')] + source: Vec, /// Limit number of results #[arg(long, default_value_t = DEFAULT_SEARCH_LIMIT)] limit: usize, @@ -169,23 +196,42 @@ impl ShellCommand { ShellCommand::Ls { fqn, kind, + source, modifiers, + all, .. } => { let target_fqn = fqn.clone().or_else(|| current_node.clone()); + let sources = if *all { + vec![] + } else if source.is_empty() { + vec![NodeSource::Project] + } else { + source + .iter() + .map(|s| s.clone().into()) + .collect::>() + }; + Ok(GraphQuery::Ls { fqn: target_fqn, kind: kind.iter().map(|k| k.clone().into()).collect(), + sources, modifiers: modifiers.clone(), }) } ShellCommand::Find { pattern, kind, + source, limit, } => Ok(GraphQuery::Find { pattern: pattern.clone(), kind: kind.iter().map(|k| k.clone().into()).collect(), + sources: source + .iter() + .map(|s| s.clone().into()) + .collect::>(), limit: *limit, }), ShellCommand::Cat { target } => Ok(GraphQuery::Cat { diff --git a/crates/cli/src/shell/completer.rs b/crates/cli/src/shell/completer.rs index fe520c6..de8083c 100644 --- a/crates/cli/src/shell/completer.rs +++ b/crates/cli/src/shell/completer.rs @@ -100,6 +100,7 @@ impl<'a> Completer for NaviscopeCompleter<'a> { let query = GraphQuery::Ls { fqn: parent_fqn.clone(), kind: vec![], + sources: vec![], modifiers: vec![], }; diff --git a/crates/cli/src/shell/handlers.rs b/crates/cli/src/shell/handlers.rs index edb3ec4..34fcc9b 100644 --- a/crates/cli/src/shell/handlers.rs +++ b/crates/cli/src/shell/handlers.rs @@ -96,8 +96,10 @@ impl CommandHandler for GenericQueryHandler { ShellCommand::Ls { fqn: Some(target), kind, + source, modifiers, long, + all, } => { resolved_target_fqn = match context.resolve_node(target) { ResolveResult::Found(f) => Some(f), @@ -106,8 +108,10 @@ impl CommandHandler for GenericQueryHandler { ShellCommand::Ls { fqn: resolved_target_fqn.clone(), kind: kind.clone(), + source: source.clone(), modifiers: modifiers.clone(), long: *long, + all: *all, } } ShellCommand::Deps { diff --git a/crates/cli/src/shell/mod.rs b/crates/cli/src/shell/mod.rs index bfe2789..03edcb1 100644 --- a/crates/cli/src/shell/mod.rs +++ b/crates/cli/src/shell/mod.rs @@ -104,6 +104,7 @@ impl ReplServer { let query = naviscope_api::models::GraphQuery::Ls { fqn: None, kind: vec![naviscope_api::models::NodeKind::Project], + sources: vec![], modifiers: vec![], }; diff --git a/crates/core/src/facade/mod.rs b/crates/core/src/facade/mod.rs index cab9b96..e83ab19 100644 --- a/crates/core/src/facade/mod.rs +++ b/crates/core/src/facade/mod.rs @@ -88,9 +88,11 @@ impl EngineHandle { ) -> Option> { self.engine.naming_conventions().get(language).cloned() } - + /// Get all naming conventions (cheap Arc clone) - pub(crate) fn naming_conventions(&self) -> Arc>> { + pub(crate) fn naming_conventions( + &self, + ) -> Arc>> { self.engine.naming_conventions() } @@ -171,6 +173,7 @@ mod tests { let query = GraphQuery::Find { pattern: "test".to_string(), kind: vec![], + sources: vec![], limit: 10, }; @@ -193,6 +196,7 @@ mod tests { let query = GraphQuery::Find { pattern: "test".to_string(), kind: vec![], + sources: vec![], limit: 10, }; diff --git a/crates/core/src/features/query.rs b/crates/core/src/features/query.rs index c05c4b0..2b42a81 100644 --- a/crates/core/src/features/query.rs +++ b/crates/core/src/features/query.rs @@ -12,7 +12,8 @@ use super::CodeGraphLike; pub struct QueryEngine { graph: G, lookup: L, - naming_conventions: std::collections::HashMap>, + naming_conventions: + std::collections::HashMap>, } impl QueryEngine @@ -23,7 +24,10 @@ where pub fn new( graph: G, lookup: L, - naming_conventions: std::collections::HashMap>, + naming_conventions: std::collections::HashMap< + String, + Arc, + >, ) -> Self { Self { graph, @@ -51,6 +55,7 @@ where GraphQuery::Find { pattern, kind, + sources, limit, } => { let regex = RegexBuilder::new(pattern) @@ -65,7 +70,9 @@ where let convention = self.naming_conventions.get(lang_str).map(|c| c.as_ref()); let fqn_str = self.graph.render_fqn(node, convention); if regex.is_match(&fqn_str) || regex.is_match(node.name(symbols)) { - if kind.is_empty() || kind.contains(&node.kind) { + let kind_match = kind.is_empty() || kind.contains(&node.kind); + let source_match = sources.is_empty() || sources.contains(&node.source); + if kind_match && source_match { nodes.push(self.render_node(node)); } } @@ -79,6 +86,7 @@ where GraphQuery::Ls { fqn, kind, + sources, modifiers: _, } => { if let Some(target_fqn) = fqn { @@ -87,6 +95,7 @@ where &[EdgeType::Contains], PetDirection::Outgoing, kind, + sources, ) } else { let mut nodes = Vec::new(); @@ -101,7 +110,11 @@ where .any(|e| e.weight().edge_type == EdgeType::Contains); if !has_parent { - nodes.push(self.render_node(node)); + let source_match = + sources.is_empty() || sources.contains(&node.source); + if source_match { + nodes.push(self.render_node(node)); + } } } } @@ -116,7 +129,10 @@ where .any(|e| e.weight().edge_type == EdgeType::Contains); if !has_parent { - if kind.is_empty() || kind.contains(&node.kind) { + let kind_match = kind.is_empty() || kind.contains(&node.kind); + let source_match = + sources.is_empty() || sources.contains(&node.source); + if kind_match && source_match { nodes.push(self.render_node(node)); } } @@ -147,7 +163,7 @@ where } else { PetDirection::Outgoing }; - self.traverse_neighbors(fqn.as_str(), edge_types, direction, &[]) + self.traverse_neighbors(fqn.as_str(), edge_types, direction, &[], &[]) } } } @@ -158,6 +174,7 @@ where edge_filter: &[EdgeType], dir: PetDirection, kind_filter: &[NodeKind], + source_filter: &[naviscope_api::models::graph::NodeSource], ) -> Result { let start_idx = self .graph @@ -175,14 +192,20 @@ where let neighbor_node = &topology[neighbor_idx]; let start_node = &topology[start_idx]; - if kind_filter.is_empty() || kind_filter.contains(&neighbor_node.kind) { + if (kind_filter.is_empty() || kind_filter.contains(&neighbor_node.kind)) + && (source_filter.is_empty() || source_filter.contains(&neighbor_node.source)) + { nodes.push(self.render_node(neighbor_node)); let symbols = self.graph.symbols(); let start_lang = symbols.resolve(&start_node.lang.0); let neighbor_lang = symbols.resolve(&neighbor_node.lang.0); - let start_convention = self.naming_conventions.get(start_lang).map(|c| c.as_ref()); - let neighbor_convention = self.naming_conventions.get(neighbor_lang).map(|c| c.as_ref()); + let start_convention = + self.naming_conventions.get(start_lang).map(|c| c.as_ref()); + let neighbor_convention = self + .naming_conventions + .get(neighbor_lang) + .map(|c| c.as_ref()); let (from, to) = if dir == PetDirection::Outgoing { ( diff --git a/crates/core/src/ingest/builder.rs b/crates/core/src/ingest/builder.rs index 77db6d2..f059ad5 100644 --- a/crates/core/src/ingest/builder.rs +++ b/crates/core/src/ingest/builder.rs @@ -75,7 +75,7 @@ impl CodeGraphBuilder { // Or simpler: Java is special. // BETTER: `naming_conventions` is a map. If we have keys, we try them. - for (_, nc) in &self.naming_conventions { + for (_lang, nc) in &self.naming_conventions { match id { naviscope_api::models::symbol::NodeId::Flat(s) => { // Try to upgrade @@ -85,7 +85,9 @@ impl CodeGraphBuilder { // This assumes we don't mix conflicting conventions in one builder session recklessly. let parts = nc.parse_fqn(s, kind_hint.clone()); let structured_id = naviscope_api::models::symbol::NodeId::Structured(parts); - return self.inner.fqns.intern_node_id(&structured_id); + let fqn_id = self.inner.fqns.intern_node_id(&structured_id); + + return fqn_id; } _ => {} } @@ -132,6 +134,7 @@ impl CodeGraphBuilder { name: name_sym, kind: node_data.kind.clone(), lang: lang_sym, + source: node_data.source, location: location.clone(), metadata: node_data.metadata.intern(&mut ctx), }; @@ -242,22 +245,33 @@ impl CodeGraphBuilder { let from_fqn = self.resolve_storage_id(&from_id, None); let to_fqn = self.resolve_storage_id(&to_id, None); - match ( - self.inner.fqn_index.get(&from_fqn), - self.inner.fqn_index.get(&to_fqn), - ) { - (Some(&from), Some(&to)) => { + let from_idx = self.inner.fqn_index.get(&from_fqn).cloned(); + let mut to_idx = self.inner.fqn_index.get(&to_fqn).cloned(); + + // If target node doesn't exist, create an external placeholder + if to_idx.is_none() && from_idx.is_some() { + let from_node = self.inner.topology.node_weight(from_idx.unwrap()).unwrap(); + let lang_str = self.inner.symbols.resolve(&from_node.lang.0).to_string(); + + // Heuristic for external node: use class/unknown kind + let name = to_id.to_string(); + let placeholder = crate::ingest::parser::IndexNode { + id: to_id.clone(), + name, + kind: naviscope_api::models::graph::NodeKind::Class, // Default to class for external types + lang: lang_str, + source: naviscope_api::models::graph::NodeSource::External, + location: None, + metadata: std::sync::Arc::new(crate::model::EmptyMetadata), + }; + to_idx = Some(self.add_node(placeholder)); + } + + match (from_idx, to_idx) { + (Some(from), Some(to)) => { self.add_edge(from, to, edge); } - _ => { - eprintln!( - "Failed to add edge: from={:?} (found={}), to={:?} (found={})", - from_fqn, - self.inner.fqn_index.contains_key(&from_fqn), - to_fqn, - self.inner.fqn_index.contains_key(&to_fqn) - ); - } + _ => {} } } GraphOp::RemovePath { path } => { @@ -343,6 +357,7 @@ mod tests { name: "test_project".to_string(), kind: NodeKind::Project, lang: "buildfile".to_string(), + source: naviscope_api::models::graph::NodeSource::Project, location: None, metadata: std::sync::Arc::new(crate::model::EmptyMetadata), }; @@ -366,6 +381,7 @@ mod tests { name: "new_project".to_string(), kind: NodeKind::Project, lang: "buildfile".to_string(), + source: naviscope_api::models::graph::NodeSource::Project, location: None, metadata: std::sync::Arc::new(crate::model::EmptyMetadata), }; diff --git a/crates/core/src/ingest/parser/utils.rs b/crates/core/src/ingest/parser/utils.rs index 35305ea..3d0d34a 100644 --- a/crates/core/src/ingest/parser/utils.rs +++ b/crates/core/src/ingest/parser/utils.rs @@ -48,6 +48,7 @@ pub fn build_symbol_hierarchy(raw_symbols: Vec) -> Vec, pub metadata: Box<[u8]>, } diff --git a/crates/core/tests/engine_api.rs b/crates/core/tests/engine_api.rs index ebb7582..d23efe0 100644 --- a/crates/core/tests/engine_api.rs +++ b/crates/core/tests/engine_api.rs @@ -40,6 +40,7 @@ async fn test_engine_handle_query() { let query = GraphQuery::Find { pattern: "test".to_string(), kind: vec![], + sources: vec![], limit: 5, }; diff --git a/crates/core/tests/indexing.rs b/crates/core/tests/indexing.rs index 1307379..774a95c 100644 --- a/crates/core/tests/indexing.rs +++ b/crates/core/tests/indexing.rs @@ -1,4 +1,4 @@ -use naviscope_api::models::graph::NodeKind; +use naviscope_api::models::graph::{NodeKind, NodeSource}; use naviscope_api::models::{BuildTool, EmptyMetadata, Range}; use naviscope_core::ingest::parser::IndexNode; use naviscope_core::ingest::resolver::BuildResolver; @@ -70,6 +70,7 @@ impl BuildResolver for MockBuildResolver { name: "test".to_string(), kind: NodeKind::Project, lang: "gradle".to_string(), + source: NodeSource::Project, location: Some(naviscope_api::models::DisplaySymbolLocation { path: f.path().to_string_lossy().to_string(), range: Range::default(), diff --git a/crates/core/tests/semantic_traits.rs b/crates/core/tests/semantic_traits.rs index 8e13bca..e5fd0a9 100644 --- a/crates/core/tests/semantic_traits.rs +++ b/crates/core/tests/semantic_traits.rs @@ -1,5 +1,5 @@ use naviscope_api::models::{ - DisplayGraphNode, DisplaySymbolLocation, Language, NodeKind, Range, ReferenceQuery, + DisplayGraphNode, DisplaySymbolLocation, Language, NodeKind, NodeSource, Range, ReferenceQuery, SymbolQuery, SymbolResolution, }; use naviscope_api::semantic::{ @@ -61,6 +61,7 @@ impl NodeAdapter for MockPlugin { name: rodeo.resolve_atom(node.name).to_string(), kind: node.kind.clone(), lang: rodeo.resolve_atom(node.lang).to_string(), + source: node.source.clone(), location: node.location.as_ref().map(|l| l.to_display(rodeo)), detail: None, signature: None, @@ -332,6 +333,7 @@ async fn test_symbol_navigator_queries() { name: "Symbol".to_string(), kind: NodeKind::Class, lang: "mock".to_string(), + source: NodeSource::Project, location: Some(DisplaySymbolLocation { path: temp_dir.join("test.mock").to_string_lossy().to_string(), range: Range { @@ -423,6 +425,7 @@ async fn test_call_hierarchy_analyzer() { name: "Callee".to_string(), kind: NodeKind::Method, lang: "mock".to_string(), + source: NodeSource::Project, location: Some(DisplaySymbolLocation { path: test_file_path.clone(), range: Range { @@ -443,6 +446,7 @@ async fn test_call_hierarchy_analyzer() { name: "Caller".to_string(), kind: NodeKind::Method, lang: "mock".to_string(), + source: NodeSource::Project, location: Some(DisplaySymbolLocation { path: test_file_path.clone(), range: Range { @@ -496,6 +500,7 @@ async fn test_get_symbol_info() { name: "Symbol".to_string(), kind: NodeKind::Class, lang: "mock".to_string(), + source: NodeSource::Project, location: Some(DisplaySymbolLocation { path: temp_dir.join("test.mock").to_string_lossy().to_string(), range: Range { diff --git a/crates/lang-gradle/src/lib.rs b/crates/lang-gradle/src/lib.rs index 13d3efa..12db9eb 100644 --- a/crates/lang-gradle/src/lib.rs +++ b/crates/lang-gradle/src/lib.rs @@ -28,6 +28,7 @@ impl NodeAdapter for GradlePlugin { name: fqns.resolve_atom(node.name).to_string(), kind: node.kind.clone(), lang: "gradle".to_string(), + source: node.source.clone(), location: node.location.as_ref().map(|l| l.to_display(fqns)), detail: None, signature: None, diff --git a/crates/lang-gradle/src/resolver.rs b/crates/lang-gradle/src/resolver.rs index 840b944..ab97189 100644 --- a/crates/lang-gradle/src/resolver.rs +++ b/crates/lang-gradle/src/resolver.rs @@ -1,5 +1,5 @@ use naviscope_api::models::graph::{ - DisplaySymbolLocation, EdgeType, EmptyMetadata, GraphEdge, NodeKind, + DisplaySymbolLocation, EdgeType, EmptyMetadata, GraphEdge, NodeKind, NodeSource, }; use naviscope_api::models::symbol::{NodeId, Range}; use naviscope_plugin::{ @@ -127,6 +127,7 @@ impl BuildResolver for GradleResolver { name: project_name.clone(), kind: NodeKind::Project, lang: "gradle".to_string(), + source: NodeSource::Project, location: Some(DisplaySymbolLocation { path: root_path.to_string_lossy().to_string(), range: Range { @@ -183,6 +184,7 @@ impl BuildResolver for GradleResolver { name: display_name.to_string(), kind: NodeKind::Module, lang: "gradle".to_string(), + source: NodeSource::Project, location: data .build_file .as_ref() @@ -232,6 +234,7 @@ impl BuildResolver for GradleResolver { name: display_name.to_string(), kind: NodeKind::Module, lang: "gradle".to_string(), + source: NodeSource::Project, location: data .build_file .as_ref() @@ -313,6 +316,7 @@ impl BuildResolver for GradleResolver { name: dep.name.clone(), kind: NodeKind::Dependency, lang: "gradle".to_string(), + source: NodeSource::External, location: Some(DisplaySymbolLocation { path: data .build_file diff --git a/crates/lang-java/src/lib.rs b/crates/lang-java/src/lib.rs index 1f42176..9c77f38 100644 --- a/crates/lang-java/src/lib.rs +++ b/crates/lang-java/src/lib.rs @@ -28,6 +28,7 @@ impl NodeAdapter for JavaPlugin { name: fqns.resolve_atom(node.name).to_string(), kind: node.kind.clone(), lang: "java".to_string(), + source: node.source.clone(), location: node.location.as_ref().map(|l| l.to_display(fqns)), detail: None, signature: None, diff --git a/crates/lang-java/src/parser/index.rs b/crates/lang-java/src/parser/index.rs index 4a94851..80097ca 100644 --- a/crates/lang-java/src/parser/index.rs +++ b/crates/lang-java/src/parser/index.rs @@ -59,6 +59,7 @@ impl JavaParser { name: e.name.clone(), kind, lang: "java".to_string(), + source: naviscope_api::models::graph::NodeSource::Project, location, metadata: Arc::new(e.element), } diff --git a/crates/lang-java/src/resolver/mod.rs b/crates/lang-java/src/resolver/mod.rs index ebc15e9..8acfc29 100644 --- a/crates/lang-java/src/resolver/mod.rs +++ b/crates/lang-java/src/resolver/mod.rs @@ -378,6 +378,7 @@ impl LangResolver for JavaResolver { name: pkg_name.to_string(), kind: NodeKind::Package, lang: "java".to_string(), + source: naviscope_api::models::graph::NodeSource::Project, location: None, metadata: Arc::new(crate::model::JavaIndexMetadata::Package), }; diff --git a/crates/lang-java/src/resolver/scope/member.rs b/crates/lang-java/src/resolver/scope/member.rs index 67400b0..21c9453 100644 --- a/crates/lang-java/src/resolver/scope/member.rs +++ b/crates/lang-java/src/resolver/scope/member.rs @@ -464,6 +464,7 @@ mod tests { name: Symbol(lasso::Spur::default()), kind: naviscope_api::models::graph::NodeKind::Field, lang: Symbol(lasso::Spur::default()), + source: naviscope_api::models::graph::NodeSource::Project, location: None, metadata: std::sync::Arc::new(JavaNodeMetadata::Field { type_ref: naviscope_api::models::TypeRef::Raw("int".to_string()), diff --git a/crates/lang-java/tests/common/mod.rs b/crates/lang-java/tests/common/mod.rs index 00ff6b8..0ce95c1 100644 --- a/crates/lang-java/tests/common/mod.rs +++ b/crates/lang-java/tests/common/mod.rs @@ -9,6 +9,7 @@ use std::path::PathBuf; use std::sync::Arc; use tree_sitter::Parser; +#[allow(dead_code)] pub fn setup_java_test_graph( files: Vec<(&str, &str)>, ) -> ( @@ -75,6 +76,7 @@ pub fn setup_java_test_graph( (builder.build(), parsed_files) } +#[allow(dead_code)] pub fn offset_to_point(content: &str, offset: usize) -> (usize, usize) { let pre_content = &content[..offset]; let line = pre_content.lines().count().max(1) - 1; @@ -83,6 +85,7 @@ pub fn offset_to_point(content: &str, offset: usize) -> (usize, usize) { (line, col) } +#[allow(dead_code)] pub async fn setup_java_engine( temp_dir: &std::path::Path, files: Vec<(&str, &str)>, diff --git a/crates/lsp/src/symbols.rs b/crates/lsp/src/symbols.rs index 93ffc3a..43bf402 100644 --- a/crates/lsp/src/symbols.rs +++ b/crates/lsp/src/symbols.rs @@ -34,10 +34,13 @@ fn convert_api_symbol(sym: DisplayGraphNode) -> DocumentSymbol { start: Position::new(loc.range.start_line as u32, loc.range.start_col as u32), end: Position::new(loc.range.end_line as u32, loc.range.end_col as u32), }; - let selection_range = loc.selection_range.map(|sr| Range { - start: Position::new(sr.start_line as u32, sr.start_col as u32), - end: Position::new(sr.end_line as u32, sr.end_col as u32), - }).unwrap_or(range); + let selection_range = loc + .selection_range + .map(|sr| Range { + start: Position::new(sr.start_line as u32, sr.start_col as u32), + end: Position::new(sr.end_line as u32, sr.end_col as u32), + }) + .unwrap_or(range); #[allow(deprecated)] DocumentSymbol { @@ -91,6 +94,7 @@ pub async fn workspace_symbol( let query = GraphQuery::Find { pattern: params.query, kind: vec![], + sources: vec![], limit: 100, }; diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index 3c32121..1e2e03a 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -48,6 +48,8 @@ pub struct FindArgs { pub pattern: String, /// Optional: Filter by element type. pub kind: Option>, + /// Optional: Filter by node source (project, external, builtin). + pub sources: Option>, /// Maximum number of results to return (default: 20) pub limit: Option, } @@ -58,6 +60,8 @@ pub struct LsArgs { pub fqn: Option, /// Optional: Filter results by element type. pub kind: Option>, + /// Optional: Filter by node source (project, external, builtin). + pub sources: Option>, /// Optional: Filter results by modifiers (e.g. ["public", "static"]) pub modifiers: Option>, } @@ -171,6 +175,7 @@ Naviscope is a graph-based code understanding engine. Unlike text search, it und self.execute_query(GraphQuery::Find { pattern: args.pattern, kind: args.kind.unwrap_or_default(), + sources: args.sources.unwrap_or_default(), limit: args.limit.unwrap_or(20), }) .await @@ -184,6 +189,7 @@ Naviscope is a graph-based code understanding engine. Unlike text search, it und self.execute_query(GraphQuery::Ls { fqn: args.fqn, kind: args.kind.unwrap_or_default(), + sources: args.sources.unwrap_or_default(), modifiers: args.modifiers.unwrap_or_default(), }) .await diff --git a/crates/plugin/src/converter.rs b/crates/plugin/src/converter.rs index da0aae5..924cde6 100644 --- a/crates/plugin/src/converter.rs +++ b/crates/plugin/src/converter.rs @@ -29,6 +29,7 @@ impl ModelConverter for DisplayGraphNode { name: interner.intern_atom(&self.name), kind: self.kind.clone(), lang: interner.intern_atom(&self.lang), + source: self.source.clone(), location: self.location.as_ref().map(|l| l.to_internal(interner)), metadata: Arc::new(naviscope_api::models::graph::EmptyMetadata), } diff --git a/crates/plugin/src/model.rs b/crates/plugin/src/model.rs index a4f3959..bbd8369 100644 --- a/crates/plugin/src/model.rs +++ b/crates/plugin/src/model.rs @@ -1,6 +1,6 @@ use crate::interner::SymbolInterner; use naviscope_api::models::graph::{ - DisplaySymbolLocation, EdgeType, EmptyMetadata, NodeKind, NodeMetadata, + DisplaySymbolLocation, EdgeType, EmptyMetadata, NodeKind, NodeMetadata, NodeSource, }; use naviscope_api::models::symbol::{NodeId, Range}; use std::path::{Path, PathBuf}; @@ -36,6 +36,7 @@ pub struct IndexNode { pub name: String, pub kind: NodeKind, pub lang: String, + pub source: NodeSource, pub location: Option, pub metadata: Arc, } diff --git a/crates/plugin/src/utils.rs b/crates/plugin/src/utils.rs index 858d2bc..29f21c8 100644 --- a/crates/plugin/src/utils.rs +++ b/crates/plugin/src/utils.rs @@ -1,4 +1,4 @@ -use naviscope_api::models::graph::{DisplayGraphNode, DisplaySymbolLocation, NodeKind}; +use naviscope_api::models::graph::{DisplayGraphNode, DisplaySymbolLocation, NodeKind, NodeSource}; use naviscope_api::models::symbol::Range; use tree_sitter::{Language, Node, Query}; @@ -53,6 +53,7 @@ pub fn build_symbol_hierarchy(raw_symbols: Vec) -> Vec Date: Sat, 7 Feb 2026 04:58:40 +0800 Subject: [PATCH 03/49] feat: Introduce a comprehensive stub caching system, including metadata serialization, asset routing, and external dependency resolution. --- Cargo.lock | 290 +++++++++++++- Cargo.toml | 2 + crates/api/src/cache.rs | 45 +++ crates/api/src/lib.rs | 4 + crates/api/src/models/graph.rs | 15 +- crates/cli/src/cache.rs | 144 +++++++ crates/cli/src/lib.rs | 15 +- crates/core/Cargo.toml | 2 + crates/core/src/cache/mod.rs | 5 + crates/core/src/cache/stub_cache.rs | 373 ++++++++++++++++++ crates/core/src/facade/mod.rs | 18 +- crates/core/src/features/mod.rs | 5 + crates/core/src/ingest/builder.rs | 32 +- crates/core/src/ingest/resolver/engine.rs | 214 ++++++++++ crates/core/src/ingest/resolver/mod.rs | 3 + crates/core/src/ingest/resolver/stub.rs | 24 ++ crates/core/src/lib.rs | 1 + crates/core/src/model/graph.rs | 44 ++- crates/core/src/model/storage/converter.rs | 17 + crates/core/src/model/storage/model.rs | 4 +- crates/core/src/runtime/orchestrator.rs | 168 +++++++- crates/core/tests/async_stubbing.rs | 149 +++++++ crates/core/tests/indexing.rs | 3 +- crates/core/tests/semantic_traits.rs | 4 + crates/core/tests/stub_cache.rs | 223 +++++++++++ crates/lang-gradle/Cargo.toml | 4 + crates/lang-gradle/src/resolver.rs | 324 +++++++++++++++ crates/lang-java/Cargo.toml | 3 + crates/lang-java/src/lib.rs | 14 + crates/lang-java/src/model.rs | 20 + crates/lang-java/src/parser/index.rs | 3 +- .../src/resolver/external/converter.rs | 128 ++++++ crates/lang-java/src/resolver/external/mod.rs | 217 ++++++++++ crates/lang-java/src/resolver/mod.rs | 2 + crates/lang-java/src/resolver/scope/member.rs | 3 +- crates/plugin/Cargo.toml | 1 + crates/plugin/src/converter.rs | 1 + crates/plugin/src/graph.rs | 4 + crates/plugin/src/model.rs | 72 ++++ crates/plugin/src/plugin.rs | 10 + crates/plugin/src/resolver.rs | 42 +- 41 files changed, 2611 insertions(+), 41 deletions(-) create mode 100644 crates/api/src/cache.rs create mode 100644 crates/cli/src/cache.rs create mode 100644 crates/core/src/cache/mod.rs create mode 100644 crates/core/src/cache/stub_cache.rs create mode 100644 crates/core/src/ingest/resolver/stub.rs create mode 100644 crates/core/tests/async_stubbing.rs create mode 100644 crates/core/tests/stub_cache.rs create mode 100644 crates/lang-java/src/resolver/external/converter.rs create mode 100644 crates/lang-java/src/resolver/external/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 71f6cb3..8c20b08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,23 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.8.12" @@ -250,6 +267,25 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + +[[package]] +name = "cafebabe" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9dc1dc4d3a0a7a314b5dcaad4077be139cfa3fcaa8d237218dedc6b71835872" +dependencies = [ + "bitflags 1.3.2", + "cesu8", +] + [[package]] name = "cc" version = "1.2.55" @@ -262,6 +298,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cfg-if" version = "1.0.4" @@ -282,6 +324,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + [[package]] name = "clap" version = "4.5.57" @@ -328,6 +380,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + [[package]] name = "convert_case" version = "0.10.0" @@ -352,6 +410,30 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -491,6 +573,12 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "deflate64" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204" + [[package]] name = "deranged" version = "0.5.5" @@ -530,6 +618,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -630,6 +719,16 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "fnv" version = "1.0.7" @@ -777,9 +876,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", ] [[package]] @@ -827,6 +928,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "http" version = "1.4.0" @@ -1088,6 +1198,15 @@ dependencies = [ "libc", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1166,6 +1285,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "libbz2-rs-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" + [[package]] name = "libc" version = "0.2.180" @@ -1238,6 +1363,16 @@ dependencies = [ "url", ] +[[package]] +name = "lzma-rust2" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1670343e58806300d87950e3401e820b519b9384281bbabfb15e3636689ffd69" +dependencies = [ + "crc", + "sha2", +] + [[package]] name = "matchers" version = "0.2.0" @@ -1274,6 +1409,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.1.1" @@ -1333,6 +1478,7 @@ dependencies = [ "log", "lsp-types", "naviscope-api", + "naviscope-java", "naviscope-plugin", "notify", "once_cell", @@ -1342,6 +1488,7 @@ dependencies = [ "rmp-serde", "schemars", "serde", + "serde_bytes", "serde_json", "tempfile", "thiserror 2.0.18", @@ -1362,6 +1509,7 @@ dependencies = [ name = "naviscope-gradle" version = "0.5.5" dependencies = [ + "dirs", "lasso", "naviscope-api", "naviscope-core", @@ -1371,6 +1519,7 @@ dependencies = [ "rmp-serde", "serde", "serde_json", + "tempfile", "thiserror 2.0.18", "tree-sitter", "tree-sitter-groovy", @@ -1380,6 +1529,7 @@ dependencies = [ name = "naviscope-java" version = "0.5.5" dependencies = [ + "cafebabe", "lasso", "lsp-types", "naviscope-api", @@ -1389,10 +1539,12 @@ dependencies = [ "rmp-serde", "serde", "serde_json", + "tempfile", "thiserror 2.0.18", "tokio", "tree-sitter", "tree-sitter-java", + "zip", ] [[package]] @@ -1440,6 +1592,7 @@ dependencies = [ "lsp-types", "naviscope-api", "serde", + "serde_bytes", "serde_json", "thiserror 2.0.18", "tree-sitter", @@ -1494,9 +1647,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" [[package]] name = "num-traits" @@ -1565,6 +1718,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1637,6 +1800,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppmd-rust" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2006,6 +2175,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -2096,6 +2275,17 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -2142,6 +2332,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "slab" version = "0.4.12" @@ -2210,6 +2406,12 @@ dependencies = [ "syn", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.114" @@ -2335,12 +2537,13 @@ dependencies = [ [[package]] name = "time" -version = "0.3.46" +version = "0.3.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5" +checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" dependencies = [ "deranged", "itoa", + "js-sys", "num-conv", "powerfmt", "serde_core", @@ -2350,15 +2553,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" [[package]] name = "time-macros" -version = "0.2.26" +version = "0.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4" +checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" dependencies = [ "num-conv", "time-core", @@ -2663,6 +2866,12 @@ dependencies = [ "utf-8", ] +[[package]] +name = "typed-path" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3015e6ce46d5ad8751e4a772543a30c7511468070e98e64e20165f8f81155b64" + [[package]] name = "typenum" version = "1.19.0" @@ -3143,6 +3352,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerotrie" version = "0.2.3" @@ -3176,12 +3405,57 @@ dependencies = [ "syn", ] +[[package]] +name = "zip" +version = "7.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "268bf6f9ceb991e07155234071501490bb41fd1e39c6a588106dad10ae2a5804" +dependencies = [ + "aes", + "bzip2", + "constant_time_eq", + "crc32fast", + "deflate64", + "flate2", + "getrandom 0.3.4", + "hmac", + "indexmap", + "lzma-rust2", + "memchr", + "pbkdf2", + "ppmd-rust", + "sha1", + "time", + "typed-path", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zlib-rs" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7948af682ccbc3342b6e9420e8c51c1fe5d7bf7756002b4a3c6cabfe96a7e3c" + [[package]] name = "zmij" version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zstd" version = "0.13.3" diff --git a/Cargo.toml b/Cargo.toml index 84526ff..8bbeb9c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,3 +66,5 @@ tree-sitter-java = "0.23.5" tree-sitter-groovy = "0.1.2" mimalloc = "0.1" tempfile = "3.10" +cafebabe = "0.9.0" +zip = "7.3.0" diff --git a/crates/api/src/cache.rs b/crates/api/src/cache.rs new file mode 100644 index 0000000..ab1f4a7 --- /dev/null +++ b/crates/api/src/cache.rs @@ -0,0 +1,45 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; + +/// Summary of a cached asset +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CachedAssetSummary { + pub hash: String, + pub path: String, + pub size_bytes: u64, + pub stub_count: usize, + pub version: u32, + pub created_at: u64, +} + +/// Detailed inspection result for a cached asset +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CacheInspectResult { + pub summary: CachedAssetSummary, + pub metadata_distribution: HashMap, + pub sample_entries: Vec, +} + +/// Statistics for the global stub cache +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CacheStats { + pub total_assets: usize, + pub total_entries: usize, + pub cache_dir: PathBuf, +} + +/// Service interface for managing the global stub cache +pub trait StubCacheManager: Send + Sync { + /// Get cache statistics + fn stats(&self) -> CacheStats; + + /// Scan all cached assets returning their summaries + fn scan_assets(&self) -> Vec; + + /// Inspect a specific cached asset by hash (full or prefix) + fn inspect_asset(&self, hash_prefix: &str) -> Option; + + /// Clear all cached data + fn clear(&self) -> Result<(), String>; +} diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index eb36c62..dab2b33 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -1,3 +1,4 @@ +pub mod cache; pub mod graph; pub mod lifecycle; pub mod models; @@ -5,6 +6,7 @@ pub mod navigation; pub mod semantic; // Re-export commonly used types +pub use cache::{CacheInspectResult, CacheStats, CachedAssetSummary, StubCacheManager}; pub use graph::GraphService; pub use lifecycle::EngineLifecycle; pub use models::*; @@ -22,4 +24,6 @@ pub trait NaviscopeEngine: + SymbolInfoProvider + EngineLifecycle { + /// Get the stub cache manager. + fn get_stub_cache_manager(&self) -> std::sync::Arc; } diff --git a/crates/api/src/models/graph.rs b/crates/api/src/models/graph.rs index 9ffbf75..d490b02 100644 --- a/crates/api/src/models/graph.rs +++ b/crates/api/src/models/graph.rs @@ -117,7 +117,6 @@ impl NodeMetadata for EmptyMetadata { } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, JsonSchema)] -#[serde(rename_all = "lowercase")] pub enum NodeSource { /// Defined in the current project (source code available) Project, @@ -133,6 +132,17 @@ impl Default for NodeSource { } } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum ResolutionStatus { + /// Just a placeholder (name and ID known, metadata may be empty) + Unresolved, + /// Structure known from bytecode or partial scan (stubs available) + Stubbed, + /// Full details known from source code or complete parsing + Resolved, +} + #[derive(Debug, Clone)] pub struct GraphNode { /// Unique Identifier (Structured FQN) @@ -145,6 +155,8 @@ pub struct GraphNode { pub lang: Symbol, /// Source origin pub source: NodeSource, + /// Current resolution depth/state + pub status: ResolutionStatus, /// Physical Location pub location: Option, /// Extension metadata @@ -159,6 +171,7 @@ impl Default for GraphNode { kind: NodeKind::Custom("unknown".to_string()), lang: Symbol(lasso::Spur::default()), source: NodeSource::Project, + status: ResolutionStatus::Resolved, location: None, metadata: Arc::new(EmptyMetadata), } diff --git a/crates/cli/src/cache.rs b/crates/cli/src/cache.rs new file mode 100644 index 0000000..91534db --- /dev/null +++ b/crates/cli/src/cache.rs @@ -0,0 +1,144 @@ +use clap::Subcommand; +use tabled::{Table, Tabled}; + +#[derive(Subcommand)] +pub enum CacheCommands { + /// Show cache statistics + Stats, + /// List cached assets + List { + /// Sort by size or date + #[arg(long, value_parser = ["size", "date"])] + sort: Option, + /// Filter by path pattern + #[arg(long)] + filter: Option, + }, + /// Inspect a specific cached asset + Inspect { + /// Asset hash (full or prefix) + hash: String, + }, + /// Clear the cache + Clear, +} + +#[derive(Tabled)] +struct AssetRow { + #[tabled(rename = "Hash")] + hash: String, + #[tabled(rename = "Path")] + path: String, + #[tabled(rename = "Size")] + size: String, + #[tabled(rename = "Stubs")] + stubs: usize, + #[tabled(rename = "Ver")] + version: u32, + #[tabled(rename = "Age")] + age: String, +} + +pub async fn run(cmd: CacheCommands) -> Result<(), Box> { + // We use current directory to initialize engine, though cache is global + let cwd = std::env::current_dir()?; + let engine = naviscope_runtime::build_default_engine(cwd); + let cache = engine.get_stub_cache_manager(); + + match cmd { + CacheCommands::Stats => { + let stats = cache.stats(); + println!("Cache Directory: {}", stats.cache_dir.display()); + println!("Total Assets: {}", stats.total_assets); + println!("Total Entries: {}", stats.total_entries); + } + CacheCommands::List { sort, filter } => { + let mut assets = cache.scan_assets(); + + // Filter + if let Some(pattern) = filter { + assets.retain(|a| a.path.contains(&pattern)); + } + + // Sort + if let Some(key) = sort { + match key.as_str() { + "size" => assets.sort_by(|a, b| b.size_bytes.cmp(&a.size_bytes)), + "date" => assets.sort_by(|a, b| b.created_at.cmp(&a.created_at)), + _ => {} + } + } + + let rows: Vec = assets + .into_iter() + .map(|a| { + let bytes = a.size_bytes; + let size_str = if bytes < 1024 { + format!("{} B", bytes) + } else if bytes < 1024 * 1024 { + format!("{:.1} KB", bytes as f64 / 1024.0) + } else { + format!("{:.1} MB", bytes as f64 / 1024.0 / 1024.0) + }; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let age = now.saturating_sub(a.created_at); + let age_str = if age < 60 { + format!("{}s ago", age) + } else if age < 3600 { + format!("{}m ago", age / 60) + } else if age < 86400 { + format!("{}h ago", age / 3600) + } else { + format!("{}d ago", age / 86400) + }; + + AssetRow { + hash: a.hash, + path: a.path, + size: size_str, + stubs: a.stub_count, + version: a.version, + age: age_str, + } + }) + .collect(); + + if rows.is_empty() { + println!("No cached assets found."); + } else { + println!("{}", Table::new(rows)); + } + } + CacheCommands::Inspect { hash } => { + if let Some(result) = cache.inspect_asset(&hash) { + println!("Asset Summary:"); + println!(" Path: {}", result.summary.path); + println!(" Hash: {}", result.summary.hash); + println!(" Version: {}", result.summary.version); + println!(" Stubs: {}", result.summary.stub_count); + + println!("\nMetadata Distribution:"); + for (type_tag, count) in result.metadata_distribution { + println!(" {}: {}", type_tag, count); + } + + println!("\nSample Entries:"); + for (i, entry) in result.sample_entries.iter().enumerate() { + println!(" {}. {}", i + 1, entry); + } + } else { + println!("Asset not found with hash prefix: {}", hash); + } + } + CacheCommands::Clear => { + cache.clear()?; + println!("Cache cleared successfully."); + } + } + + Ok(()) +} diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index d212de2..26eba58 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -1,3 +1,4 @@ +mod cache; mod clear; mod index; mod shell; @@ -70,6 +71,11 @@ pub enum Commands { }, /// Start the Language Server Protocol (LSP) server Lsp, + /// Manage global stub cache + Cache { + #[command(subcommand)] + command: cache::CacheCommands, + }, } pub fn run() -> Result<(), Box> { @@ -88,9 +94,13 @@ pub fn run() -> Result<(), Box> { match cli.command { Commands::Index { path } => rt.block_on(index::run(path.canonicalize()?)), - Commands::Shell { path } => rt.block_on(shell::run(path.map(|p| p.canonicalize()).transpose()?)), + Commands::Shell { path } => { + rt.block_on(shell::run(path.map(|p| p.canonicalize()).transpose()?)) + } Commands::Watch { path } => rt.block_on(watch::run(path.canonicalize()?)), - Commands::Clear { path } => rt.block_on(clear::run(path.map(|p| p.canonicalize()).transpose()?)), + Commands::Clear { path } => { + rt.block_on(clear::run(path.map(|p| p.canonicalize()).transpose()?)) + } Commands::Mcp { path } => { let project_path = match path { Some(p) => p.canonicalize()?, @@ -107,5 +117,6 @@ pub fn run() -> Result<(), Box> { })?; Ok(()) } + Commands::Cache { command } => rt.block_on(cache::run(command)), } } diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 93a0b37..5d4c8e5 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -32,7 +32,9 @@ naviscope-api = { workspace = true } naviscope-plugin = { workspace = true } async-trait = { workspace = true } url = { workspace = true } +serde_bytes = "0.11.19" [dev-dependencies] tree-sitter-java = { workspace = true } tempfile = { workspace = true } +naviscope-java = { workspace = true } diff --git a/crates/core/src/cache/mod.rs b/crates/core/src/cache/mod.rs new file mode 100644 index 0000000..20771d4 --- /dev/null +++ b/crates/core/src/cache/mod.rs @@ -0,0 +1,5 @@ +//! Caching subsystem + +pub mod stub_cache; + +pub use stub_cache::{AssetKey, GlobalStubCache}; diff --git a/crates/core/src/cache/stub_cache.rs b/crates/core/src/cache/stub_cache.rs new file mode 100644 index 0000000..5d42df0 --- /dev/null +++ b/crates/core/src/cache/stub_cache.rs @@ -0,0 +1,373 @@ +//! Global stub cache for external dependencies +//! +//! Stores parsed stub data from external assets (JARs, jmods, etc.) in a global cache +//! to avoid re-parsing the same dependencies across different projects. + +use naviscope_plugin::IndexNode; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock}; +use std::time::SystemTime; +use xxhash_rust::xxh3::xxh3_64; + +// Note: We use the metadata registry from naviscope_plugin + +/// Key identifying an external asset +#[derive(Debug, Clone, Hash, PartialEq, Eq)] +pub struct AssetKey { + pub path: PathBuf, + pub size: u64, + pub mtime: u64, // Unix timestamp for serialization simplicity +} + +impl AssetKey { + /// Create an AssetKey from a file path + pub fn from_path(path: &Path) -> std::io::Result { + let metadata = fs::metadata(path)?; + let mtime = metadata + .modified()? + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + Ok(Self { + path: path.to_path_buf(), + size: metadata.len(), + mtime, + }) + } + + /// Compute a hash for this asset key + pub fn hash(&self) -> u64 { + let key_str = format!("{}:{}:{}", self.path.display(), self.size, self.mtime); + xxh3_64(key_str.as_bytes()) + } +} + +/// Cached stub entry (serializable, language-agnostic) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CachedStub { + pub fqn: String, + pub id: String, + pub name: String, + pub kind: naviscope_api::models::graph::NodeKind, + pub lang: naviscope_api::models::Language, + pub source: naviscope_api::models::graph::NodeSource, + pub status: naviscope_api::models::graph::ResolutionStatus, + /// Encapsulated metadata + pub metadata: naviscope_plugin::CachedMetadata, +} + +impl CachedStub { + /// Convert from IndexNode (language-agnostic serialization) + pub fn from_index_node(node: &IndexNode) -> Self { + let id = match &node.id { + naviscope_api::models::symbol::NodeId::Flat(s) => s.clone(), + naviscope_api::models::symbol::NodeId::Structured(s) => format!("{:?}", s), + }; + + // Use trait method for serialization + let metadata = node.metadata.to_cached_metadata(); + + Self { + fqn: id.clone(), + id, + name: node.name.clone(), + kind: node.kind.clone(), + lang: naviscope_api::models::Language::from(node.lang.clone()), + source: node.source.clone(), + status: node.status.clone(), + metadata, + } + } + + /// Convert back to IndexNode + pub fn to_index_node(&self) -> IndexNode { + // Deserialize metadata + let metadata = naviscope_plugin::deserialize_metadata(&self.metadata); + + IndexNode { + id: naviscope_api::models::symbol::NodeId::Flat(self.id.clone()), + name: self.name.clone(), + kind: self.kind.clone(), + lang: self.lang.to_string(), + source: self.source.clone(), + status: self.status.clone(), + location: None, + metadata, + } + } +} + +/// Cache file for a single asset +#[derive(Debug, Serialize, Deserialize)] +pub struct StubCacheFile { + pub version: u32, + pub asset_hash: u64, + pub asset_path: String, + pub created_at: u64, + pub entries: HashMap, +} + +impl StubCacheFile { + pub fn new(asset: &AssetKey) -> Self { + let now = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + Self { + version: 1, + asset_hash: asset.hash(), + asset_path: asset.path.display().to_string(), + created_at: now, + entries: HashMap::new(), + } + } +} + +/// Global stub cache manager +pub struct GlobalStubCache { + base_dir: PathBuf, + loaded: Arc>>>>, +} + +use naviscope_api::cache::{CacheInspectResult, CacheStats, CachedAssetSummary, StubCacheManager}; + +impl StubCacheManager for GlobalStubCache { + fn stats(&self) -> CacheStats { + self.stats() + } + + fn scan_assets(&self) -> Vec { + self.scan_assets() + } + + fn inspect_asset(&self, hash_prefix: &str) -> Option { + self.inspect_asset(hash_prefix) + } + + fn clear(&self) -> Result<(), String> { + self.clear().map_err(|e| e.to_string()) + } +} + +impl GlobalStubCache { + /// Create a new global stub cache + pub fn new(base_dir: PathBuf) -> Self { + fs::create_dir_all(&base_dir).unwrap_or_default(); + Self { + base_dir, + loaded: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Get the default global cache location + pub fn default_location() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); + PathBuf::from(home).join(".naviscope").join("stub_cache") + } + + /// Create a global cache at the default location + pub fn at_default_location() -> Self { + Self::new(Self::default_location()) + } + + /// Get the cache file path for an asset + fn cache_path(&self, asset_hash: u64) -> PathBuf { + self.base_dir.join(format!("{:016x}.stubs", asset_hash)) + } + + /// Load or create cache for an asset + fn get_or_create_cache(&self, asset: &AssetKey) -> Arc> { + let hash = asset.hash(); + + // Check if already loaded + { + let loaded = self.loaded.read().unwrap(); + if let Some(cache) = loaded.get(&hash) { + return cache.clone(); + } + } + + // Try to load from disk + let cache_path = self.cache_path(hash); + let cache = if cache_path.exists() { + match fs::read(&cache_path) { + Ok(bytes) => match rmp_serde::from_slice::(&bytes) { + Ok(file) if file.asset_hash == hash => file, + _ => StubCacheFile::new(asset), + }, + Err(_) => StubCacheFile::new(asset), + } + } else { + StubCacheFile::new(asset) + }; + + let cache = Arc::new(RwLock::new(cache)); + + // Store in memory + { + let mut loaded = self.loaded.write().unwrap(); + loaded.insert(hash, cache.clone()); + } + + cache + } + + /// Look up a cached stub + pub fn lookup(&self, asset: &AssetKey, fqn: &str) -> Option { + let cache = self.get_or_create_cache(asset); + let cache = cache.read().unwrap(); + + cache.entries.get(fqn).map(|e| e.to_index_node()) + } + + /// Store a stub in the cache + pub fn store(&self, asset: &AssetKey, stub: &IndexNode) { + let fqn = match &stub.id { + naviscope_api::models::symbol::NodeId::Flat(s) => s.clone(), + naviscope_api::models::symbol::NodeId::Structured(s) => format!("{:?}", s), + }; + + let cache = self.get_or_create_cache(asset); + { + let mut cache = cache.write().unwrap(); + cache.entries.insert(fqn, CachedStub::from_index_node(stub)); + } + + // Persist to disk + self.save_cache(asset); + } + + /// Save cache to disk + fn save_cache(&self, asset: &AssetKey) { + let hash = asset.hash(); + let loaded = self.loaded.read().unwrap(); + + if let Some(cache) = loaded.get(&hash) { + let cache = cache.read().unwrap(); + let cache_path = self.cache_path(hash); + + if let Ok(bytes) = rmp_serde::to_vec(&*cache) { + let _ = fs::write(cache_path, bytes); + } + } + } + + /// Clear all cached data + pub fn clear(&self) -> std::io::Result<()> { + // Clear in-memory cache + { + let mut loaded = self.loaded.write().unwrap(); + loaded.clear(); + } + + // Remove all cache files + if self.base_dir.exists() { + for entry in fs::read_dir(&self.base_dir)? { + let entry = entry?; + if entry + .path() + .extension() + .map(|e| e == "stubs") + .unwrap_or(false) + { + let _ = fs::remove_file(entry.path()); + } + } + } + + Ok(()) + } + + /// Scan all cached assets returning their summaries + pub fn scan_assets(&self) -> Vec { + let mut summaries = Vec::new(); + + if !self.base_dir.exists() { + return summaries; + } + + if let Ok(entries) = fs::read_dir(&self.base_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().map_or(false, |ext| ext == "stubs") { + if let Ok(metadata) = fs::metadata(&path) { + // Try to read file header + // Note: We read the whole file for now. + // Optimization: Define a Header-only struct for rmp-serde if needed. + if let Ok(bytes) = fs::read(&path) { + if let Ok(file) = rmp_serde::from_slice::(&bytes) { + summaries.push(CachedAssetSummary { + hash: format!("{:016x}", file.asset_hash), + path: file.asset_path, + size_bytes: metadata.len(), + stub_count: file.entries.len(), + version: file.version, + created_at: file.created_at, + }); + } + } + } + } + } + } + + summaries + } + + /// Inspect a specific cached asset by hash (full or prefix) + pub fn inspect_asset(&self, hash_prefix: &str) -> Option { + let summaries = self.scan_assets(); + let target = summaries.iter().find(|s| s.hash.starts_with(hash_prefix))?; + + // Convert hex string back to u64 for file path lookup + let hash = u64::from_str_radix(&target.hash, 16).ok()?; + let cache_path = self.cache_path(hash); + + if let Ok(bytes) = fs::read(&cache_path) { + if let Ok(file) = rmp_serde::from_slice::(&bytes) { + let mut distro = HashMap::new(); + let mut samples = Vec::new(); + + for (i, entry) in file.entries.values().enumerate() { + *distro.entry(entry.metadata.type_tag.clone()).or_insert(0) += 1; + if i < 10 { + samples.push(entry.fqn.clone()); + } + } + + return Some(CacheInspectResult { + summary: CachedAssetSummary { + hash: target.hash.clone(), + path: file.asset_path, + size_bytes: target.size_bytes, + stub_count: file.entries.len(), + version: file.version, + created_at: file.created_at, + }, + metadata_distribution: distro, + sample_entries: samples, + }); + } + } + + None + } + + /// Get cache statistics + pub fn stats(&self) -> CacheStats { + let summaries = self.scan_assets(); + let total_assets = summaries.len(); + let total_entries = summaries.iter().map(|s| s.stub_count).sum(); + + CacheStats { + total_assets, + total_entries, + cache_dir: self.base_dir.clone(), + } + } +} diff --git a/crates/core/src/facade/mod.rs b/crates/core/src/facade/mod.rs index e83ab19..e07a028 100644 --- a/crates/core/src/facade/mod.rs +++ b/crates/core/src/facade/mod.rs @@ -104,7 +104,11 @@ impl EngineHandle { } } -impl NaviscopeEngine for EngineHandle {} +impl NaviscopeEngine for EngineHandle { + fn get_stub_cache_manager(&self) -> Arc { + self.engine.get_stub_cache() + } +} #[cfg(test)] mod tests { @@ -125,13 +129,13 @@ mod tests { fn test_blocking_graph_access() { // Create runtime in a separate thread without any existing runtime context std::thread::spawn(|| { - let engine = Arc::new(InternalEngine::new(PathBuf::from("."))); - let handle = EngineHandle::from_engine(engine); - // Test that blocking API works via async runtime let rt = tokio::runtime::Runtime::new().unwrap(); let _guard = rt.enter(); + let engine = Arc::new(InternalEngine::new(PathBuf::from("."))); + let handle = EngineHandle::from_engine(engine); + let _graph = rt.block_on(handle.graph()); }) .join() @@ -187,12 +191,12 @@ mod tests { use naviscope_api::models::GraphQuery; std::thread::spawn(|| { - let engine = Arc::new(InternalEngine::new(PathBuf::from("."))); - let handle = EngineHandle::from_engine(engine); - let rt = tokio::runtime::Runtime::new().unwrap(); let _guard = rt.enter(); + let engine = Arc::new(InternalEngine::new(PathBuf::from("."))); + let handle = EngineHandle::from_engine(engine); + let query = GraphQuery::Find { pattern: "test".to_string(), kind: vec![], diff --git a/crates/core/src/features/mod.rs b/crates/core/src/features/mod.rs index bffb7fc..e05dea9 100644 --- a/crates/core/src/features/mod.rs +++ b/crates/core/src/features/mod.rs @@ -15,6 +15,7 @@ pub trait CodeGraphLike: Send + Sync { fn fqn_map(&self) -> &std::collections::HashMap; fn path_to_nodes(&self, path: &Path) -> Option<&[petgraph::stable_graph::NodeIndex]>; fn reference_index(&self) -> &std::collections::HashMap>; + fn asset_routes(&self) -> &std::collections::HashMap; fn find_container_node_at( &self, path: &std::path::Path, @@ -83,6 +84,10 @@ impl CodeGraphLike for &T { (*self).reference_index() } + fn asset_routes(&self) -> &std::collections::HashMap { + (*self).asset_routes() + } + fn find_container_node_at( &self, path: &std::path::Path, diff --git a/crates/core/src/ingest/builder.rs b/crates/core/src/ingest/builder.rs index f059ad5..099c7c5 100644 --- a/crates/core/src/ingest/builder.rs +++ b/crates/core/src/ingest/builder.rs @@ -37,6 +37,7 @@ impl CodeGraphBuilder { name_index: HashMap::new(), file_index: HashMap::new(), reference_index: HashMap::new(), + asset_routes: HashMap::new(), }, naming_conventions: HashMap::new(), } @@ -115,7 +116,18 @@ impl CodeGraphBuilder { }; if let Some(&idx) = self.inner.fqn_index.get(&fqn_id) { - // Node already exists + // Node already exists - check if we should update metadata + if let Some(existing_node) = self.inner.topology.node_weight_mut(idx) { + // If the new metadata is NOT empty, or we want to force an update, do it here. + // For stubbing, we move from EmptyMetadata to rich language metadata. + let mut ctx = crate::model::storage::model::GenericStorageContext { + rodeo: self.inner.symbols.clone(), + }; + existing_node.metadata = node_data.metadata.intern(&mut ctx); + + // Also update source if it was External and now it's Project (or just keep it updated) + existing_node.source = node_data.source; + } idx } else { let name_sym = self.inner.fqns.intern_atom(&node_data.name); @@ -135,6 +147,7 @@ impl CodeGraphBuilder { kind: node_data.kind.clone(), lang: lang_sym, source: node_data.source, + status: node_data.status, location: location.clone(), metadata: node_data.metadata.intern(&mut ctx), }; @@ -261,6 +274,7 @@ impl CodeGraphBuilder { kind: naviscope_api::models::graph::NodeKind::Class, // Default to class for external types lang: lang_str, source: naviscope_api::models::graph::NodeSource::External, + status: naviscope_api::models::graph::ResolutionStatus::Unresolved, location: None, metadata: std::sync::Arc::new(crate::model::EmptyMetadata), }; @@ -295,6 +309,17 @@ impl CodeGraphBuilder { let path = metadata.path.clone(); self.update_file(&path, metadata); } + GraphOp::UpdateAssetRoutes { routes } => { + for (prefix, path) in routes { + let prefix_sym = Symbol(self.inner.symbols.get_or_intern(&prefix)); + let path_sym = Symbol( + self.inner + .symbols + .get_or_intern(path.to_string_lossy().as_ref()), + ); + self.inner.asset_routes.insert(prefix_sym, path_sym); + } + } } Ok(()) } @@ -314,7 +339,8 @@ impl CodeGraphBuilder { GraphOp::RemovePath { .. } => destructive.push(op), GraphOp::AddNode { .. } | GraphOp::UpdateFile { .. } - | GraphOp::UpdateIdentifiers { .. } => additive.push(op), + | GraphOp::UpdateIdentifiers { .. } + | GraphOp::UpdateAssetRoutes { .. } => additive.push(op), GraphOp::AddEdge { .. } => relational.push(op), } } @@ -358,6 +384,7 @@ mod tests { kind: NodeKind::Project, lang: "buildfile".to_string(), source: naviscope_api::models::graph::NodeSource::Project, + status: naviscope_api::models::graph::ResolutionStatus::Resolved, location: None, metadata: std::sync::Arc::new(crate::model::EmptyMetadata), }; @@ -382,6 +409,7 @@ mod tests { kind: NodeKind::Project, lang: "buildfile".to_string(), source: naviscope_api::models::graph::NodeSource::Project, + status: naviscope_api::models::graph::ResolutionStatus::Resolved, location: None, metadata: std::sync::Arc::new(crate::model::EmptyMetadata), }; diff --git a/crates/core/src/ingest/resolver/engine.rs b/crates/core/src/ingest/resolver/engine.rs index 7d2d02a..177b4f7 100644 --- a/crates/core/src/ingest/resolver/engine.rs +++ b/crates/core/src/ingest/resolver/engine.rs @@ -1,4 +1,5 @@ use crate::error::Result; +use crate::ingest::resolver::StubbingManager; use crate::ingest::resolver::{ProjectContext, SemanticResolver}; use crate::ingest::scanner::ParsedFile; use crate::model::source::Language; @@ -12,6 +13,7 @@ use crate::plugin::{BuildToolPlugin, LanguagePlugin}; pub struct IndexResolver { build_plugins: Vec>, lang_plugins: Vec>, + stubbing: Option, } impl IndexResolver { @@ -19,6 +21,7 @@ impl IndexResolver { Self { build_plugins: Vec::new(), lang_plugins: Vec::new(), + stubbing: None, } } @@ -29,9 +32,15 @@ impl IndexResolver { Self { build_plugins, lang_plugins, + stubbing: None, } } + pub fn with_stubbing(mut self, stubbing: StubbingManager) -> Self { + self.stubbing = Some(stubbing); + self + } + pub fn register_language(&mut self, plugin: Arc) { self.lang_plugins.push(plugin); } @@ -101,6 +110,10 @@ impl IndexResolver { let build_ops = self.resolve_build_batch(&build_files, &mut project_context)?; all_ops.extend(build_ops); + // Phase 1.5: Asset Routing (Classpath) + let asset_ops = self.resolve_assets_batch(&mut project_context)?; + all_ops.extend(asset_ops); + // Phase 2: Source Files let source_ops = self.resolve_source_batch(&source_files, &project_context)?; all_ops.extend(source_ops); @@ -128,6 +141,113 @@ impl IndexResolver { (all_ops, build_files, source_files) } + pub fn resolve_assets_batch(&self, context: &mut ProjectContext) -> Result> { + // 1. Collect and deduplicate all assets + let mut all_assets = context.builtin_assets.clone(); + all_assets.extend(context.external_assets.clone()); + all_assets.sort(); + all_assets.dedup(); + + if all_assets.is_empty() { + return Ok(vec![]); + } + + // 2. Index each asset using appropriate language plugins + for asset in all_assets { + let ext = asset + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + + for plugin in &self.lang_plugins { + // Heuristic: Java plugin handles .jar and .jmod + let is_java_asset = + (ext == "jar" || ext == "jmod") && plugin.name().as_str() == "java"; + let is_supported_ext = plugin.supported_extensions().contains(&ext.as_str()); + + if is_java_asset || is_supported_ext { + if let Some(external) = plugin.external_resolver() { + if let Ok(prefixes) = external.index_asset(&asset) { + for prefix in prefixes { + context.asset_routes.insert(prefix, asset.clone()); + } + } + } + } + } + } + + if context.asset_routes.is_empty() { + Ok(vec![]) + } else { + Ok(vec![GraphOp::UpdateAssetRoutes { + routes: context.asset_routes.clone(), + }]) + } + } + + pub fn schedule_stubs(&self, ops: &[GraphOp], context: Arc) { + use naviscope_api::models::graph::NodeSource; + use std::collections::HashSet; + + let Some(stubbing) = &self.stubbing else { + return; + }; + + let mut seen_fqns = HashSet::new(); + + // 1. Identify all unique external FQNs referenced in the operations + for op in ops { + match op { + GraphOp::AddEdge { to_id, .. } => { + let fqn = to_id.to_string(); + seen_fqns.insert(fqn); + } + GraphOp::AddNode { + data: Some(node_data), + } => { + if node_data.source == NodeSource::External { + seen_fqns.insert(node_data.id.to_string()); + } + } + _ => {} + } + } + + if seen_fqns.is_empty() || context.asset_routes.is_empty() { + return; + } + + // 2. Schedule each FQN for background resolution + for fqn in seen_fqns { + // We only schedule if we have a route for it + if self.find_asset_for_fqn(&fqn, &context).is_some() { + stubbing.request(fqn, context.clone()); + } + } + } + + fn find_asset_for_fqn<'a>( + &self, + fqn: &str, + context: &'a ProjectContext, + ) -> Option<&'a std::path::PathBuf> { + // Longest prefix match + let mut current = fqn.to_string(); + while !current.is_empty() { + if let Some(path) = context.asset_routes.get(¤t) { + return Some(path); + } + if let Some(idx) = current.rfind('.') { + current.truncate(idx); + } else { + break; + } + } + None + } + pub fn resolve_build_batch( &self, build_files: &[ParsedFile], @@ -153,6 +273,8 @@ impl IndexResolver { .map_err(crate::error::NaviscopeError::from)?; all_ops.extend(unit.ops); context.path_to_module.extend(ctx.path_to_module); + context.external_assets.extend(ctx.external_assets); + context.builtin_assets.extend(ctx.builtin_assets); } } Ok(all_ops) @@ -211,3 +333,95 @@ impl crate::ingest::pipeline::PipelineStage for IndexResolver { Ok(all_ops) } } + +#[cfg(test)] +mod tests { + use super::*; + use naviscope_api::models::Language; + use naviscope_plugin::{ExternalResolver, GlobalParseResult, LangResolver, LspParser}; + use std::path::{Path, PathBuf}; + + struct MockExternalResolver; + impl ExternalResolver for MockExternalResolver { + fn index_asset( + &self, + asset: &Path, + ) -> std::result::Result, Box> { + if asset.to_str().unwrap().contains("example.jar") { + Ok(vec!["com.example".to_string()]) + } else { + Ok(vec![]) + } + } + fn generate_stub( + &self, + _fqn: &str, + _asset: &Path, + ) -> std::result::Result< + naviscope_plugin::model::IndexNode, + Box, + > { + unimplemented!() + } + fn resolve_source( + &self, + _fqn: &str, + _source_asset: &Path, + ) -> std::result::Result> + { + unimplemented!() + } + } + + struct MockLanguagePlugin; + impl crate::plugin::PluginInstance for MockLanguagePlugin {} + impl crate::plugin::LanguagePlugin for MockLanguagePlugin { + fn name(&self) -> Language { + Language::JAVA + } + fn supported_extensions(&self) -> &[&str] { + &["java"] + } + fn parse_file( + &self, + _source: &str, + _path: &Path, + ) -> std::result::Result> + { + unimplemented!() + } + fn resolver(&self) -> Arc { + unimplemented!() + } + fn lang_resolver(&self) -> Arc { + unimplemented!() + } + fn lsp_parser(&self) -> Arc { + unimplemented!() + } + fn external_resolver(&self) -> Option> { + Some(Arc::new(MockExternalResolver)) + } + } + + #[test] + fn test_resolve_assets_batch() { + let mut resolver = IndexResolver::new(); + resolver.register_language(Arc::new(MockLanguagePlugin)); + + let mut context = ProjectContext::new(); + let asset_path = PathBuf::from("/libs/example.jar"); + context.external_assets.push(asset_path.clone()); + + let ops = resolver.resolve_assets_batch(&mut context).unwrap(); + + assert_eq!(ops.len(), 1); + if let GraphOp::UpdateAssetRoutes { routes } = &ops[0] { + assert_eq!(routes.get("com.example"), Some(&asset_path)); + } else { + panic!("Expected UpdateAssetRoutes operation"); + } + + assert_eq!(context.asset_routes.get("com.example"), Some(&asset_path)); + } +} diff --git a/crates/core/src/ingest/resolver/mod.rs b/crates/core/src/ingest/resolver/mod.rs index 462c64d..cbad8f4 100644 --- a/crates/core/src/ingest/resolver/mod.rs +++ b/crates/core/src/ingest/resolver/mod.rs @@ -1,4 +1,7 @@ pub mod engine; pub mod scope; +pub mod stub; +pub use engine::IndexResolver; pub use naviscope_plugin::{BuildResolver, LangResolver, ProjectContext, SemanticResolver}; +pub use stub::{StubRequest, StubbingManager}; diff --git a/crates/core/src/ingest/resolver/stub.rs b/crates/core/src/ingest/resolver/stub.rs new file mode 100644 index 0000000..456c8cc --- /dev/null +++ b/crates/core/src/ingest/resolver/stub.rs @@ -0,0 +1,24 @@ +use crate::ingest::resolver::ProjectContext; +use std::sync::Arc; +use tokio::sync::mpsc::UnboundedSender; + +/// A request to asynchronously generate a stub for an external FQN +#[derive(Debug, Clone)] +pub struct StubRequest { + pub fqn: String, + pub context: Arc, +} + +pub struct StubbingManager { + tx: UnboundedSender, +} + +impl StubbingManager { + pub fn new(tx: UnboundedSender) -> Self { + Self { tx } + } + + pub fn request(&self, fqn: String, context: Arc) { + let _ = self.tx.send(StubRequest { fqn, context }); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 71b674a..622370b 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod cache; pub mod error; pub mod logging; pub mod util; diff --git a/crates/core/src/model/graph.rs b/crates/core/src/model/graph.rs index 9bfb1e8..8fbff0e 100644 --- a/crates/core/src/model/graph.rs +++ b/crates/core/src/model/graph.rs @@ -59,6 +59,10 @@ pub struct CodeGraphInner { /// Reference Index: Token (e.g. Method Name) -> Files that contain this token. /// Used for fast "scouting" during reference discovery. pub reference_index: HashMap>, + + /// Asset Route Table: Prefix (Package/Symbol) -> Asset Path + /// Used for routing FQNs to their defining JARs/files. + pub asset_routes: HashMap, } /// Metadata and nodes associated with a single source file @@ -83,6 +87,7 @@ impl CodeGraph { name_index: HashMap::new(), file_index: HashMap::new(), reference_index: HashMap::new(), + asset_routes: HashMap::new(), }), } } @@ -314,6 +319,10 @@ impl CodeGraphLike for CodeGraph { &self.inner.reference_index } + fn asset_routes(&self) -> &std::collections::HashMap { + &self.inner.asset_routes + } + fn find_container_node_at( &self, path: &std::path::Path, @@ -406,7 +415,9 @@ impl naviscope_plugin::CodeGraph for CodeGraph { } #[cfg(test)] mod tests { - use super::{CURRENT_VERSION, CodeGraph}; + use super::*; + use naviscope_api::models::symbol::Symbol; + use std::path::Path; #[test] fn test_arc_clone_is_cheap() { @@ -447,6 +458,7 @@ mod tests { kind: NodeKind::Class, lang: "java".to_string(), source: naviscope_api::models::graph::NodeSource::Project, + status: naviscope_api::models::graph::ResolutionStatus::Resolved, location: None, metadata: std::sync::Arc::new(crate::model::EmptyMetadata), }; @@ -465,4 +477,34 @@ mod tests { assert_eq!(recovered_node.name(symbols), "node"); assert_eq!(recovered_node.language(symbols).as_str(), "java"); } + + #[test] + fn test_graph_asset_routes_serialization() { + use crate::ingest::builder::CodeGraphBuilder; + let mut builder = CodeGraphBuilder::new(); + + // Add a route + let prefix = "com.example"; + let path = Path::new("/path/to/example.jar"); + + builder + .apply_op(naviscope_plugin::GraphOp::UpdateAssetRoutes { + routes: [(prefix.to_string(), path.to_path_buf())] + .into_iter() + .collect(), + }) + .unwrap(); + + let graph = builder.build(); + let serialized = graph.serialize(|_| None).unwrap(); + let deserialized = CodeGraph::deserialize(&serialized, |_| None).unwrap(); + + let prefix_sym = Symbol(deserialized.symbols().get(prefix).unwrap()); + let path_sym = Symbol(deserialized.symbols().get(path.to_str().unwrap()).unwrap()); + + assert_eq!( + deserialized.asset_routes().get(&prefix_sym), + Some(&path_sym) + ); + } } diff --git a/crates/core/src/model/storage/converter.rs b/crates/core/src/model/storage/converter.rs index 34f2366..b29e99b 100644 --- a/crates/core/src/model/storage/converter.rs +++ b/crates/core/src/model/storage/converter.rs @@ -114,6 +114,7 @@ pub fn to_storage( kind: node.kind.clone(), lang_sid: node.lang.0.into_usize() as u32, source: node.source.clone(), + status: node.status, location: node.location.as_ref().map(|loc| StorageLocation { path_id: loc.path.0.into_usize() as u32, range: loc.range, @@ -198,6 +199,11 @@ pub fn to_storage( name_index, file_index, reference_index, + asset_routes: inner + .asset_routes + .iter() + .map(|(prefix, path)| (prefix.0.into_usize() as u32, path.0.into_usize() as u32)) + .collect(), } } @@ -222,6 +228,7 @@ pub fn from_storage( kind: snode.kind.clone(), lang: Symbol(Spur::try_from_usize(snode.lang_sid as usize).unwrap()), source: snode.source.clone(), + status: snode.status, location: snode.location.as_ref().map(|loc| InternedLocation { path: Symbol(Spur::try_from_usize(loc.path_id as usize).unwrap()), range: loc.range, @@ -302,5 +309,15 @@ pub fn from_storage( name_index, file_index, reference_index, + asset_routes: storage + .asset_routes + .into_iter() + .map(|(prefix_sid, path_sid)| { + ( + Symbol(Spur::try_from_usize(prefix_sid as usize).unwrap()), + Symbol(Spur::try_from_usize(path_sid as usize).unwrap()), + ) + }) + .collect(), } } diff --git a/crates/core/src/model/storage/model.rs b/crates/core/src/model/storage/model.rs index a235d91..3741c50 100644 --- a/crates/core/src/model/storage/model.rs +++ b/crates/core/src/model/storage/model.rs @@ -1,7 +1,7 @@ use crate::model::FqnStorage; use crate::model::{GraphEdge, NodeKind, Range}; use lasso::{Key, ThreadedRodeo}; -use naviscope_api::models::graph::NodeSource; +use naviscope_api::models::graph::{NodeSource, ResolutionStatus}; use naviscope_api::models::symbol::Symbol; use naviscope_plugin::FqnInterner; use serde::{Deserialize, Serialize}; @@ -110,6 +110,7 @@ pub struct StorageGraph { pub name_index: Vec<(u32, Vec)>, // (Symbol, Vec) pub file_index: Vec<(u32, StorageFileEntry)>, // (Symbol, Entry) pub reference_index: Vec<(u32, Vec)>, // (Symbol, Vec) + pub asset_routes: Vec<(u32, u32)>, // (Prefix, Path) } #[derive(Serialize, Deserialize)] @@ -119,6 +120,7 @@ pub struct StorageNode { pub kind: NodeKind, pub lang_sid: u32, pub source: NodeSource, + pub status: ResolutionStatus, pub location: Option, pub metadata: Box<[u8]>, } diff --git a/crates/core/src/runtime/orchestrator.rs b/crates/core/src/runtime/orchestrator.rs index 87c1f3b..4e13116 100644 --- a/crates/core/src/runtime/orchestrator.rs +++ b/crates/core/src/runtime/orchestrator.rs @@ -2,16 +2,16 @@ use crate::error::{NaviscopeError, Result}; use crate::ingest::builder::CodeGraphBuilder; -use crate::ingest::resolver::engine::IndexResolver; +use crate::ingest::resolver::{IndexResolver, StubRequest, StubbingManager}; use crate::ingest::scanner::Scanner; use crate::model::CodeGraph; use crate::model::GraphOp; +use naviscope_plugin::NamingConvention; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::sync::RwLock; use xxhash_rust::xxh3::xxh3_64; -use std::collections::HashMap; -use naviscope_plugin::NamingConvention; use crate::plugin::{BuildToolPlugin, LanguagePlugin}; @@ -40,6 +40,12 @@ pub struct NaviscopeEngine { /// Cancellation token for background tasks (like watcher) cancel_token: tokio_util::sync::CancellationToken, + + /// Background stubbing channel + stub_tx: tokio::sync::mpsc::UnboundedSender, + + /// Global stub cache + stub_cache: Arc, } impl Drop for NaviscopeEngine { @@ -56,15 +62,26 @@ impl NaviscopeEngine { .unwrap_or_else(|_| project_root.clone()); let index_path = Self::compute_index_path(&canonical_root); - Self { + let (stub_tx, stub_rx) = tokio::sync::mpsc::unbounded_channel(); + let cancel_token = tokio_util::sync::CancellationToken::new(); + // Initialize global cache once + let stub_cache = Arc::new(crate::cache::GlobalStubCache::at_default_location()); + + let engine = Self { current: Arc::new(RwLock::new(Arc::new(CodeGraph::empty()))), project_root: canonical_root, index_path, build_plugins: Arc::new(Vec::new()), lang_plugins: Arc::new(Vec::new()), naming_conventions: Arc::new(std::collections::HashMap::new()), - cancel_token: tokio_util::sync::CancellationToken::new(), - } + cancel_token: cancel_token.clone(), + stub_tx, + stub_cache: stub_cache.clone(), + }; + + engine.spawn_stub_worker(stub_rx, cancel_token, stub_cache); + + engine } pub fn register_language(&mut self, plugin: Arc) { @@ -101,10 +118,13 @@ impl NaviscopeEngine { /// Get the index resolver configured with current plugins pub fn get_resolver(&self) -> IndexResolver { IndexResolver::with_plugins((*self.build_plugins).clone(), (*self.lang_plugins).clone()) + .with_stubbing(StubbingManager::new(self.stub_tx.clone())) } /// Get naming conventions registry (cheap Arc clone) - pub(crate) fn naming_conventions(&self) -> Arc>> { + pub(crate) fn naming_conventions( + &self, + ) -> Arc>> { self.naming_conventions.clone() } @@ -167,9 +187,10 @@ impl NaviscopeEngine { let build_plugins = self.build_plugins.clone(); let lang_plugins = self.lang_plugins.clone(); + let stub_tx = self.stub_tx.clone(); // Build in blocking pool (CPU-intensive) let new_graph = tokio::task::spawn_blocking(move || { - Self::build_index(&project_root, build_plugins, lang_plugins) + Self::build_index(&project_root, build_plugins, lang_plugins, stub_tx) }) .await .map_err(|e| NaviscopeError::Internal(e.to_string()))??; @@ -202,6 +223,7 @@ impl NaviscopeEngine { } let current_lock = self.current.clone(); + let stub_tx = self.stub_tx.clone(); // Processing in blocking pool tokio::task::spawn_blocking(move || -> Result<()> { @@ -230,10 +252,11 @@ impl NaviscopeEngine { scan_results.into_iter().partition(|f| f.is_build()); let resolver = - IndexResolver::with_plugins((*build_plugins).clone(), (*lang_plugins).clone()); + IndexResolver::with_plugins((*build_plugins).clone(), (*lang_plugins).clone()) + .with_stubbing(StubbingManager::new(stub_tx.clone())); // 2. Phase 1: Heavy Build Resolution (Global Context) - let mut project_context = crate::ingest::resolver::ProjectContext::new(); + let mut project_context_inner = crate::ingest::resolver::ProjectContext::new(); let mut initial_ops = manual_ops; // IMPORTANT: RemovePath MUST come before AddNode for the same paths. @@ -248,9 +271,12 @@ impl NaviscopeEngine { } // For build files, we still process them up front because they define the structure - let build_ops = resolver.resolve_build_batch(&build_files, &mut project_context)?; + let build_ops = + resolver.resolve_build_batch(&build_files, &mut project_context_inner)?; initial_ops.extend(build_ops); + let project_context = Arc::new(project_context_inner); + // 3. Phase 2: Pipeline Batch Processing for source files let pipeline = crate::ingest::pipeline::IngestPipeline::new(500); // 500 files per batch let source_paths: Vec = source_files @@ -270,8 +296,9 @@ impl NaviscopeEngine { builder.apply_ops(initial_ops)?; // Note: We are in a blocking thread, resolver and context are Thread-safe. - pipeline.execute(&project_context, source_paths, &resolver, |batch_ops| { - builder.apply_ops(batch_ops)?; + pipeline.execute(&*project_context, source_paths, &resolver, |batch_ops| { + builder.apply_ops(batch_ops.clone())?; + resolver.schedule_stubs(&batch_ops, project_context.clone()); Ok(()) })?; @@ -366,6 +393,105 @@ impl NaviscopeEngine { Ok(()) } + /// Start the background stubbing worker + fn spawn_stub_worker( + &self, + mut rx: tokio::sync::mpsc::UnboundedReceiver, + cancel_token: tokio_util::sync::CancellationToken, + stub_cache: Arc, + ) { + let current = self.current.clone(); + let lang_plugins = self.lang_plugins.clone(); + let naming_conventions = self.naming_conventions.clone(); + + tokio::spawn(async move { + tracing::info!("Stubbing worker started"); + let mut seen_fqns = std::collections::HashSet::new(); + + loop { + tokio::select! { + _ = cancel_token.cancelled() => break, + Some(req) = rx.recv() => { + // Skip if already seen in this session to avoid redundant work + if !seen_fqns.insert(req.fqn.clone()) { + continue; + } + + // Check if node already exists and is resolved + { + let lock = current.read().await; + let graph = &**lock; + if let Some(idx) = graph.find_node(&req.fqn) { + if let Some(node) = graph.get_node(idx) { + if node.status == naviscope_api::models::graph::ResolutionStatus::Resolved { + continue; + } + } + } + } + + // Resolve + let mut ops = Vec::new(); + + if let Some(asset_path) = req.context.asset_routes.get(&req.fqn) { + // Try to create asset key for cache lookup + let asset_key = crate::cache::AssetKey::from_path(asset_path).ok(); + + // Check cache first + if let Some(ref key) = asset_key { + if let Some(cached_stub) = stub_cache.lookup(key, &req.fqn) { + tracing::trace!("Cache hit for {}", req.fqn); + ops.push(GraphOp::AddNode { data: Some(cached_stub) }); + } + } + + // If not in cache, generate stub + if ops.is_empty() { + let ext = asset_path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + + for plugin in lang_plugins.iter() { + if plugin.can_handle_external_asset(&ext) { + if let Some(external) = plugin.external_resolver() { + if let Ok(stub) = external.generate_stub(&req.fqn, asset_path) { + // Store in cache for future use + if let Some(ref key) = asset_key { + stub_cache.store(key, &stub); + tracing::trace!("Cached stub for {}", req.fqn); + } + ops.push(GraphOp::AddNode { data: Some(stub) }); + } + } + } + } + } + } + + if !ops.is_empty() { + let mut lock = current.write().await; + let mut builder = (**lock).to_builder(); + + // Load naming conventions + let conventions = (*naming_conventions).clone(); + for (lang, nc) in conventions { + builder.naming_conventions.insert(naviscope_api::models::Language::from(lang), nc); + } + + if let Ok(()) = builder.apply_ops(ops) { + *lock = Arc::new(builder.build()); + tracing::debug!("Applied stub for {}", req.fqn); + } + } + } + } + } + tracing::info!("Stubbing worker stopped"); + }); + } + /// Clear the index for the current project pub async fn clear_project_index(&self) -> Result<()> { let path = self.index_path.clone(); @@ -495,15 +621,22 @@ impl NaviscopeEngine { project_root: &Path, build_plugins: Arc>>, lang_plugins: Arc>>, + stub_tx: tokio::sync::mpsc::UnboundedSender, ) -> Result { // Scan and parse let parse_results = Scanner::scan_and_parse(project_root, &std::collections::HashMap::new()); // Resolve + let project_context_inner = crate::ingest::resolver::ProjectContext::new(); let resolver = - IndexResolver::with_plugins((*build_plugins).clone(), (*lang_plugins).clone()); + IndexResolver::with_plugins((*build_plugins).clone(), (*lang_plugins).clone()) + .with_stubbing(StubbingManager::new(stub_tx)); + + // Note: build_index currently use a separate resolve path which doesn't use the pipeline for everything. + // We'll manually schedule stubs for the full result set. let ops = resolver.resolve(parse_results)?; + let project_context = Arc::new(project_context_inner); // Build graph let mut builder = CodeGraphBuilder::new(); @@ -515,10 +648,15 @@ impl NaviscopeEngine { } } - builder.apply_ops(ops)?; + builder.apply_ops(ops.clone())?; + resolver.schedule_stubs(&ops, project_context); Ok(builder.build()) } + + pub fn get_stub_cache(&self) -> Arc { + self.stub_cache.clone() + } } #[cfg(test)] diff --git a/crates/core/tests/async_stubbing.rs b/crates/core/tests/async_stubbing.rs new file mode 100644 index 0000000..0f85fff --- /dev/null +++ b/crates/core/tests/async_stubbing.rs @@ -0,0 +1,149 @@ +//! Tests for async stubbing workflow + +use naviscope_api::models::graph::ResolutionStatus; +use naviscope_core::ingest::resolver::{ProjectContext, StubbingManager}; +use naviscope_core::model::GraphOp; +use naviscope_core::runtime::orchestrator::NaviscopeEngine; +use naviscope_java::JavaPlugin; +use naviscope_plugin::LanguagePlugin; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::mpsc; + +/// Test that the stubbing manager correctly sends requests +#[tokio::test] +async fn test_stubbing_manager_sends_requests() { + let (tx, mut rx) = mpsc::unbounded_channel(); + let manager = StubbingManager::new(tx); + + let context = Arc::new(ProjectContext::new()); + manager.request("com.example.Foo".to_string(), context.clone()); + manager.request("com.example.Bar".to_string(), context.clone()); + + // Verify requests are received + let req1 = rx.recv().await.expect("Should receive first request"); + assert_eq!(req1.fqn, "com.example.Foo"); + + let req2 = rx.recv().await.expect("Should receive second request"); + assert_eq!(req2.fqn, "com.example.Bar"); +} + +/// Test that JavaPlugin correctly reports external asset handling +#[test] +fn test_java_plugin_handles_external_assets() { + let plugin = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + // Java plugin should handle these + assert!(plugin.can_handle_external_asset("jar")); + assert!(plugin.can_handle_external_asset("jmod")); + assert!(plugin.can_handle_external_asset("class")); + + // Java plugin should NOT handle these + assert!(!plugin.can_handle_external_asset("rs")); + assert!(!plugin.can_handle_external_asset("py")); + assert!(!plugin.can_handle_external_asset("go")); +} + +/// Test the full async stubbing flow with a real JAR file +#[tokio::test] +async fn test_async_stubbing_with_jar() { + use std::time::Duration; + + // Create a temporary directory for the test project + let temp_dir = std::env::temp_dir().join("naviscope_stub_test"); + if temp_dir.exists() { + let _ = std::fs::remove_dir_all(&temp_dir); + } + std::fs::create_dir_all(&temp_dir).unwrap(); + + // Create engine with Java plugin + let mut engine = NaviscopeEngine::new(temp_dir.clone()); + let java_plugin = Arc::new(JavaPlugin::new().expect("Failed to create JavaPlugin")); + engine.register_language(java_plugin); + + // Find a real JAR file (use JDK's rt.jar or similar) + let java_home = std::env::var("JAVA_HOME").ok(); + let jmod_path = java_home + .as_ref() + .map(|h| PathBuf::from(h).join("jmods").join("java.base.jmod")); + + if let Some(jmod) = jmod_path.filter(|p| p.exists()) { + // Create a project context with the asset route + let mut context = ProjectContext::new(); + context + .asset_routes + .insert("java.lang.String".to_string(), jmod.clone()); + + let context = Arc::new(context); + + // Get the stub_tx from engine and send a request + // Note: In real usage, this happens automatically through IndexResolver + let resolver = engine.get_resolver(); + + // Create a mock GraphOp that would trigger stubbing + let ops = vec![GraphOp::AddNode { + data: Some(naviscope_plugin::IndexNode { + id: naviscope_api::models::symbol::NodeId::Flat("java.lang.String".to_string()), + name: "String".to_string(), + kind: naviscope_api::models::graph::NodeKind::Class, + lang: "java".to_string(), + source: naviscope_api::models::graph::NodeSource::External, + status: ResolutionStatus::Unresolved, + location: None, + metadata: Arc::new(naviscope_api::models::graph::EmptyMetadata), + }), + }]; + + // Schedule stubs - this sends to the background worker + resolver.schedule_stubs(&ops, context); + + // Give the worker time to process + tokio::time::sleep(Duration::from_millis(500)).await; + + // Check that the node was updated (in a real test, we'd verify the graph) + // For now, we just verify no panics occurred + let graph = engine.snapshot().await; + // The graph might not have nodes yet if the worker hasn't finished, + // but the test ensures the pipeline doesn't crash + println!( + "Graph after stubbing: {} nodes, {} edges", + graph.node_count(), + graph.edge_count() + ); + } else { + println!("Skipping JAR test: JAVA_HOME not set or jmods not found"); + } + + // Cleanup + let _ = std::fs::remove_dir_all(&temp_dir); +} + +/// Test that duplicate FQNs are deduplicated in the worker +#[tokio::test] +async fn test_stubbing_deduplication() { + let (tx, mut rx) = mpsc::unbounded_channel(); + + // Simulate what the worker's seen_fqns set does + let mut seen = std::collections::HashSet::new(); + + let context = Arc::new(ProjectContext::new()); + let manager = StubbingManager::new(tx); + + // Send the same FQN twice + manager.request("com.example.Foo".to_string(), context.clone()); + manager.request("com.example.Foo".to_string(), context.clone()); + manager.request("com.example.Bar".to_string(), context.clone()); + + // Process like the worker would + let mut processed = Vec::new(); + while let Ok(req) = rx.try_recv() { + if seen.insert(req.fqn.clone()) { + processed.push(req.fqn); + } + } + + // Only unique FQNs should be processed + assert_eq!(processed.len(), 2); + assert!(processed.contains(&"com.example.Foo".to_string())); + assert!(processed.contains(&"com.example.Bar".to_string())); +} diff --git a/crates/core/tests/indexing.rs b/crates/core/tests/indexing.rs index 774a95c..c6bb4cb 100644 --- a/crates/core/tests/indexing.rs +++ b/crates/core/tests/indexing.rs @@ -1,4 +1,4 @@ -use naviscope_api::models::graph::{NodeKind, NodeSource}; +use naviscope_api::models::graph::{NodeKind, NodeSource, ResolutionStatus}; use naviscope_api::models::{BuildTool, EmptyMetadata, Range}; use naviscope_core::ingest::parser::IndexNode; use naviscope_core::ingest::resolver::BuildResolver; @@ -71,6 +71,7 @@ impl BuildResolver for MockBuildResolver { kind: NodeKind::Project, lang: "gradle".to_string(), source: NodeSource::Project, + status: ResolutionStatus::Resolved, location: Some(naviscope_api::models::DisplaySymbolLocation { path: f.path().to_string_lossy().to_string(), range: Range::default(), diff --git a/crates/core/tests/semantic_traits.rs b/crates/core/tests/semantic_traits.rs index e5fd0a9..82d41e3 100644 --- a/crates/core/tests/semantic_traits.rs +++ b/crates/core/tests/semantic_traits.rs @@ -334,6 +334,7 @@ async fn test_symbol_navigator_queries() { kind: NodeKind::Class, lang: "mock".to_string(), source: NodeSource::Project, + status: naviscope_api::models::graph::ResolutionStatus::Resolved, location: Some(DisplaySymbolLocation { path: temp_dir.join("test.mock").to_string_lossy().to_string(), range: Range { @@ -426,6 +427,7 @@ async fn test_call_hierarchy_analyzer() { kind: NodeKind::Method, lang: "mock".to_string(), source: NodeSource::Project, + status: naviscope_api::models::graph::ResolutionStatus::Resolved, location: Some(DisplaySymbolLocation { path: test_file_path.clone(), range: Range { @@ -447,6 +449,7 @@ async fn test_call_hierarchy_analyzer() { kind: NodeKind::Method, lang: "mock".to_string(), source: NodeSource::Project, + status: naviscope_api::models::graph::ResolutionStatus::Resolved, location: Some(DisplaySymbolLocation { path: test_file_path.clone(), range: Range { @@ -501,6 +504,7 @@ async fn test_get_symbol_info() { kind: NodeKind::Class, lang: "mock".to_string(), source: NodeSource::Project, + status: naviscope_api::models::graph::ResolutionStatus::Resolved, location: Some(DisplaySymbolLocation { path: temp_dir.join("test.mock").to_string_lossy().to_string(), range: Range { diff --git a/crates/core/tests/stub_cache.rs b/crates/core/tests/stub_cache.rs new file mode 100644 index 0000000..2d12f49 --- /dev/null +++ b/crates/core/tests/stub_cache.rs @@ -0,0 +1,223 @@ +//! Tests for global stub cache + +use naviscope_api::models::graph::{EmptyMetadata, NodeKind, NodeSource, ResolutionStatus}; +use naviscope_api::models::symbol::NodeId; +use naviscope_core::cache::{AssetKey, GlobalStubCache}; +use naviscope_plugin::IndexNode; +use std::sync::Arc; +use tempfile::TempDir; + +fn create_test_stub(fqn: &str, name: &str) -> IndexNode { + IndexNode { + id: NodeId::Flat(fqn.to_string()), + name: name.to_string(), + kind: NodeKind::Class, + lang: "java".to_string(), + source: NodeSource::External, + status: ResolutionStatus::Stubbed, + location: None, + metadata: Arc::new(EmptyMetadata), + } +} + +#[test] +fn test_cache_store_and_lookup() { + let temp = TempDir::new().unwrap(); + let cache = GlobalStubCache::new(temp.path().to_path_buf()); + + // Create a fake asset file + let asset_file = temp.path().join("test.jar"); + std::fs::write(&asset_file, b"fake jar content").unwrap(); + + let asset_key = AssetKey::from_path(&asset_file).unwrap(); + + // Store a stub + let stub = create_test_stub("com.example.Foo", "Foo"); + cache.store(&asset_key, &stub); + + // Lookup should return the stub + let cached = cache.lookup(&asset_key, "com.example.Foo"); + assert!(cached.is_some()); + let cached = cached.unwrap(); + assert_eq!(cached.name, "Foo"); + assert_eq!(cached.lang, "java"); +} + +#[test] +fn test_cache_miss() { + let temp = TempDir::new().unwrap(); + let cache = GlobalStubCache::new(temp.path().to_path_buf()); + + // Create a fake asset file + let asset_file = temp.path().join("test.jar"); + std::fs::write(&asset_file, b"fake jar content").unwrap(); + + let asset_key = AssetKey::from_path(&asset_file).unwrap(); + + // Lookup without storing should return None + let cached = cache.lookup(&asset_key, "com.example.NotCached"); + assert!(cached.is_none()); +} + +#[test] +fn test_cache_persistence() { + let temp = TempDir::new().unwrap(); + let cache_dir = temp.path().join("stub_cache"); + + // Create a fake asset file + let asset_file = temp.path().join("test.jar"); + std::fs::write(&asset_file, b"fake jar content").unwrap(); + + let asset_key = AssetKey::from_path(&asset_file).unwrap(); + + // Store with first cache instance + { + let cache = GlobalStubCache::new(cache_dir.clone()); + let stub = create_test_stub("com.example.Persisted", "Persisted"); + cache.store(&asset_key, &stub); + } + + // Create new cache instance and lookup + { + let cache = GlobalStubCache::new(cache_dir.clone()); + let cached = cache.lookup(&asset_key, "com.example.Persisted"); + assert!(cached.is_some()); + assert_eq!(cached.unwrap().name, "Persisted"); + } +} + +#[test] +fn test_cache_invalidation_on_file_change() { + let temp = TempDir::new().unwrap(); + let cache = GlobalStubCache::new(temp.path().join("cache")); + + // Create first version of asset + let asset_file = temp.path().join("test.jar"); + std::fs::write(&asset_file, b"version 1").unwrap(); + let key1 = AssetKey::from_path(&asset_file).unwrap(); + + // Store a stub + let stub = create_test_stub("com.example.V1", "V1"); + cache.store(&key1, &stub); + + // "Modify" the file (change content and mtime) + std::thread::sleep(std::time::Duration::from_millis(10)); + std::fs::write(&asset_file, b"version 2 with different size").unwrap(); + let key2 = AssetKey::from_path(&asset_file).unwrap(); + + // Keys should be different (different size/mtime) + assert_ne!(key1.hash(), key2.hash()); + + // Old key should still find the stub + assert!(cache.lookup(&key1, "com.example.V1").is_some()); + + // New key should NOT find the stub (different asset version) + assert!(cache.lookup(&key2, "com.example.V1").is_none()); +} + +#[test] +fn test_cache_stats() { + let temp = TempDir::new().unwrap(); + let cache = GlobalStubCache::new(temp.path().to_path_buf()); + + // Create multiple assets + for i in 0..3 { + let asset_file = temp.path().join(format!("asset{}.jar", i)); + std::fs::write(&asset_file, format!("content{}", i)).unwrap(); + let key = AssetKey::from_path(&asset_file).unwrap(); + + for j in 0..5 { + let stub = create_test_stub( + &format!("com.example.Class{}_{}", i, j), + &format!("Class{}_{}", i, j), + ); + cache.store(&key, &stub); + } + } + + let stats = cache.stats(); + assert_eq!(stats.total_assets, 3); + assert_eq!(stats.total_entries, 15); +} + +#[test] +fn test_cache_clear() { + let temp = TempDir::new().unwrap(); + let cache = GlobalStubCache::new(temp.path().to_path_buf()); + + // Create and populate cache + let asset_file = temp.path().join("test.jar"); + std::fs::write(&asset_file, b"content").unwrap(); + let key = AssetKey::from_path(&asset_file).unwrap(); + + cache.store(&key, &create_test_stub("com.example.Test", "Test")); + assert!(cache.lookup(&key, "com.example.Test").is_some()); + + // Clear cache + cache.clear().unwrap(); + + // After clear, should not find anything + let cache2 = GlobalStubCache::new(temp.path().to_path_buf()); + assert!(cache2.lookup(&key, "com.example.Test").is_none()); +} + +#[test] +fn test_cache_with_java_metadata() { + use naviscope_java::model::JavaIndexMetadata; + use naviscope_plugin::register_metadata_deserializer; + + let temp = TempDir::new().unwrap(); + let cache_dir = temp.path().join("cache"); + + // Register the deserializer (normally done when plugin is initialized) + register_metadata_deserializer("java", JavaIndexMetadata::deserialize_for_cache); + + // Create a fake asset file + let asset_file = temp.path().join("test.jar"); + std::fs::write(&asset_file, b"content").unwrap(); + let key = AssetKey::from_path(&asset_file).unwrap(); + + // 1. Create a stub with Java-specific metadata + let java_meta = JavaIndexMetadata::Class { + modifiers: vec!["public".to_string(), "final".to_string()], + }; + + let stub = IndexNode { + id: NodeId::Flat("com.example.Foo".to_string()), + name: "Foo".to_string(), + kind: NodeKind::Class, + lang: "java".to_string(), + source: NodeSource::External, + status: ResolutionStatus::Stubbed, + location: None, + metadata: Arc::new(java_meta), + }; + + // 2. Store in cache + { + let cache = GlobalStubCache::new(cache_dir.clone()); + cache.store(&key, &stub); + } + + // 3. Load from a new cache instance (simulating a restart) + { + let cache = GlobalStubCache::new(cache_dir); + let cached = cache + .lookup(&key, "com.example.Foo") + .expect("Should find cached stub"); + + // 4. Verify metadata is reconstituted as JavaIndexMetadata + let meta = cached + .metadata + .as_any() + .downcast_ref::() + .expect("Metadata should be downcastable to JavaIndexMetadata"); + + if let JavaIndexMetadata::Class { modifiers } = meta { + assert!(modifiers.contains(&"public".to_string())); + assert!(modifiers.contains(&"final".to_string())); + } else { + panic!("Metadata should be of Class type"); + } + } +} diff --git a/crates/lang-gradle/Cargo.toml b/crates/lang-gradle/Cargo.toml index 1a8ab14..0fdaf9b 100644 --- a/crates/lang-gradle/Cargo.toml +++ b/crates/lang-gradle/Cargo.toml @@ -16,3 +16,7 @@ petgraph = { workspace = true } once_cell = { workspace = true } rmp-serde = { workspace = true } lasso = { workspace = true } +dirs = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/lang-gradle/src/resolver.rs b/crates/lang-gradle/src/resolver.rs index ab97189..a1e369c 100644 --- a/crates/lang-gradle/src/resolver.rs +++ b/crates/lang-gradle/src/resolver.rs @@ -20,6 +20,249 @@ impl GradleResolver { fn normalize_path(&self, path: &Path) -> PathBuf { path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) } + + /// Discovers built-in assets (SDK libraries like lib/modules, rt.jar or jmods). + fn discover_builtin_assets(&self) -> Vec { + let mut assets = Vec::new(); + + // 1. Check JAVA_HOME + if let Ok(java_home) = std::env::var("JAVA_HOME") { + self.collect_sdk_assets(Path::new(&java_home), &mut assets); + } + + // 2. macOS specific: Use java_home tool + #[cfg(target_os = "macos")] + if assets.is_empty() { + if let Ok(output) = std::process::Command::new("/usr/libexec/java_home").output() { + if output.status.success() { + let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); + self.collect_sdk_assets(Path::new(&path_str), &mut assets); + } + } + } + + // 3. Search common installation paths + if assets.is_empty() { + let mut search_roots = Vec::new(); + + #[cfg(target_os = "macos")] + { + search_roots.push(PathBuf::from("/Library/Java/JavaVirtualMachines/")); + search_roots.push(PathBuf::from("/opt/homebrew/opt/openjdk/")); + search_roots.push(PathBuf::from("/usr/local/opt/openjdk/")); + } + #[cfg(target_os = "linux")] + { + search_roots.push(PathBuf::from("/usr/lib/jvm/")); + } + #[cfg(target_os = "windows")] + { + search_roots.push(PathBuf::from("C:\\Program Files\\Java\\")); + } + + // SDKMAN + if let Some(mut sdkman) = dirs::home_dir() { + sdkman.push(".sdkman/candidates/java/"); + search_roots.push(sdkman); + } + + for root in search_roots { + if !root.exists() { + continue; + } + + // If root itself is a JDK (e.g. Homebrew symlink) + self.collect_sdk_assets(&root, &mut assets); + if !assets.is_empty() { + break; + } + + // If root is a parent directory containing multiple SDKs + if let Ok(entries) = std::fs::read_dir(root) { + for entry in entries.flatten() { + let mut sdk_path = entry.path(); + if cfg!(target_os = "macos") && sdk_path.join("Contents/Home").exists() { + sdk_path.push("Contents/Home"); + } + self.collect_sdk_assets(&sdk_path, &mut assets); + if !assets.is_empty() { + break; + } + } + } + + if !assets.is_empty() { + break; + } + } + } + + assets + } + + fn collect_sdk_assets(&self, sdk_path: &Path, assets: &mut Vec) { + if !sdk_path.exists() { + return; + } + + // Priority 1: Java 9+ Runtime Image (The most correct way for modern Java) + let modules = sdk_path.join("lib/modules"); + if modules.exists() { + assets.push(modules); + return; + } + + // Priority 2: Java 8 Legacy Runtime + let rt_jar = sdk_path.join("jre/lib/rt.jar"); + if rt_jar.exists() { + assets.push(rt_jar); + return; + } + let lib_rt_jar = sdk_path.join("lib/rt.jar"); + if lib_rt_jar.exists() { + assets.push(lib_rt_jar); + return; + } + + // Priority 3: jmods (Fallback for some JDK builds without lib/modules) + let jmods = sdk_path.join("jmods"); + if jmods.exists() { + if let Ok(entries) = std::fs::read_dir(jmods) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("jmod") { + assets.push(path); + } + } + } + } + } + + /// Discovers external assets (third-party JARs). + /// Discovers external assets (third-party JARs). + fn discover_external_assets(&self, files: &[&ParsedFile]) -> Vec { + let mut assets = Vec::new(); + + // 1. Fast Path: Scan .idea/libraries if available (IntelliJ projects) + self.discover_from_idea(files, &mut assets); + + // 2. Accurate Path: Scan Gradle cache based on parsed dependencies + self.discover_from_gradle_cache(files, &mut assets); + + assets.sort(); + assets.dedup(); + assets + } + + fn discover_from_idea(&self, files: &[&ParsedFile], assets: &mut Vec) { + let Some(first_file) = files.first() else { + return; + }; + + let mut current = first_file.file.path.clone(); + while let Some(parent) = current.parent() { + let idea_libs = parent.join(".idea/libraries"); + if idea_libs.exists() { + if let Ok(entries) = std::fs::read_dir(idea_libs) { + for entry in entries.flatten() { + self.parse_idea_lib_xml(&entry.path(), assets); + } + } + break; + } + current = parent.to_path_buf(); + } + } + + fn parse_idea_lib_xml(&self, path: &Path, assets: &mut Vec) { + if path.extension().and_then(|e| e.to_str()) != Some("xml") { + return; + } + + let Ok(content) = std::fs::read_to_string(path) else { + return; + }; + + for line in content.lines() { + let Some(jar_path) = self.extract_jar_path_from_xml_line(line) else { + continue; + }; + + if jar_path.exists() && jar_path.extension().and_then(|e| e.to_str()) == Some("jar") { + assets.push(jar_path); + } + } + } + + fn extract_jar_path_from_xml_line(&self, line: &str) -> Option { + if !line.contains("url=\"jar://") || !line.contains("!/\"") { + return None; + } + + let start = line.find("jar://")? + 6; + let end = line.find("!/\"")?; + let path_str = &line[start..end]; + + if path_str.contains("$USER_HOME$") { + dirs::home_dir().map(|home| { + PathBuf::from(path_str.replace("$USER_HOME$", home.to_str().unwrap_or(""))) + }) + } else { + Some(PathBuf::from(path_str)) + } + } + + fn discover_from_gradle_cache(&self, files: &[&ParsedFile], assets: &mut Vec) { + let Some(mut cache_path) = dirs::home_dir() else { + return; + }; + cache_path.push(".gradle/caches/modules-2/files-2.1"); + + if !cache_path.exists() { + return; + } + + for file in files { + let ParsedContent::Metadata(value) = &file.content else { + continue; + }; + + let Ok(res) = serde_json::from_value::(value.clone()) + else { + continue; + }; + + for dep in res.dependencies { + if dep.is_project { + continue; + } + if let (Some(group), Some(version)) = (dep.group, dep.version) { + let dep_dir = cache_path.join(&group).join(&dep.name).join(&version); + if dep_dir.exists() { + self.collect_jars_from_dir(&dep_dir, assets); + } + } + } + } + } + + fn collect_jars_from_dir(&self, dir: &Path, assets: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + self.collect_jars_from_dir(&path, assets); + } else if path.extension().and_then(|e| e.to_str()) == Some("jar") { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if !name.ends_with("-sources.jar") && !name.ends_with("-javadoc.jar") { + assets.push(path); + } + } + } + } } impl BuildResolver for GradleResolver { @@ -31,6 +274,10 @@ impl BuildResolver for GradleResolver { let mut unit = ResolvedUnit::new(); let mut context = ProjectContext::new(); + // --- Step 0: Discover environment (Builtin & External) --- + context.builtin_assets = self.discover_builtin_assets(); + context.external_assets = self.discover_external_assets(files); + // --- Step 1: Discover all potential module paths --- let mut module_map: HashMap = HashMap::new(); @@ -128,6 +375,7 @@ impl BuildResolver for GradleResolver { kind: NodeKind::Project, lang: "gradle".to_string(), source: NodeSource::Project, + status: naviscope_api::models::graph::ResolutionStatus::Resolved, location: Some(DisplaySymbolLocation { path: root_path.to_string_lossy().to_string(), range: Range { @@ -185,6 +433,7 @@ impl BuildResolver for GradleResolver { kind: NodeKind::Module, lang: "gradle".to_string(), source: NodeSource::Project, + status: naviscope_api::models::graph::ResolutionStatus::Resolved, location: data .build_file .as_ref() @@ -235,6 +484,7 @@ impl BuildResolver for GradleResolver { kind: NodeKind::Module, lang: "gradle".to_string(), source: NodeSource::Project, + status: naviscope_api::models::graph::ResolutionStatus::Resolved, location: data .build_file .as_ref() @@ -317,6 +567,7 @@ impl BuildResolver for GradleResolver { kind: NodeKind::Dependency, lang: "gradle".to_string(), source: NodeSource::External, + status: naviscope_api::models::graph::ResolutionStatus::Resolved, location: Some(DisplaySymbolLocation { path: data .build_file @@ -445,4 +696,77 @@ mod tests { == "project:spring-boot-build::module:spring-boot-project" && t == "project:spring-boot-build::module:spring-boot-project/spring-boot")); } + + #[test] + fn test_discover_external_assets_from_idea() { + let resolver = GradleResolver::new(); + let temp = tempfile::tempdir().unwrap(); + let idea_libs = temp.path().join(".idea/libraries"); + std::fs::create_dir_all(&idea_libs).unwrap(); + + let jar_path = temp.path().join("mock.jar"); + std::fs::File::create(&jar_path).unwrap(); + + let lib_xml = idea_libs.join("mock_lib.xml"); + let xml_content = format!( + r#" + + + + + + + +"#, + jar_path.display() + ); + std::fs::write(&lib_xml, xml_content).unwrap(); + + let mock_file = create_mock_file( + temp.path().join("build.gradle").to_str().unwrap(), + ParsedContent::Unparsed("".to_string()), + ); + let assets: Vec<_> = resolver + .discover_external_assets(&[&mock_file]) + .into_iter() + .map(|p| p.canonicalize().unwrap()) + .collect(); + + assert_eq!(assets.len(), 1); + assert_eq!(assets[0], jar_path.canonicalize().unwrap()); + } + + #[test] + fn test_discover_builtin_assets_java_11() { + let resolver = GradleResolver::new(); + let temp = tempfile::tempdir().unwrap(); + let sdk_path = temp.path().to_path_buf(); + + let modules_path = sdk_path.join("lib/modules"); + std::fs::create_dir_all(modules_path.parent().unwrap()).unwrap(); + std::fs::File::create(&modules_path).unwrap(); + + let mut assets = Vec::new(); + resolver.collect_sdk_assets(&sdk_path, &mut assets); + + assert_eq!(assets.len(), 1); + assert_eq!(assets[0], modules_path); + } + + #[test] + fn test_discover_builtin_assets_java_8() { + let resolver = GradleResolver::new(); + let temp = tempfile::tempdir().unwrap(); + let sdk_path = temp.path().to_path_buf(); + + let rt_jar_path = sdk_path.join("jre/lib/rt.jar"); + std::fs::create_dir_all(rt_jar_path.parent().unwrap()).unwrap(); + std::fs::File::create(&rt_jar_path).unwrap(); + + let mut assets = Vec::new(); + resolver.collect_sdk_assets(&sdk_path, &mut assets); + + assert_eq!(assets.len(), 1); + assert_eq!(assets[0], rt_jar_path); + } } diff --git a/crates/lang-java/Cargo.toml b/crates/lang-java/Cargo.toml index daecc06..1a9514b 100644 --- a/crates/lang-java/Cargo.toml +++ b/crates/lang-java/Cargo.toml @@ -15,7 +15,10 @@ lsp-types = { workspace = true } tree-sitter-java = { workspace = true } rmp-serde = { workspace = true } lasso = { workspace = true } +cafebabe = { workspace = true } +zip = { workspace = true } [dev-dependencies] naviscope-core = { workspace = true } tokio = { workspace = true } +tempfile = { workspace = true } diff --git a/crates/lang-java/src/lib.rs b/crates/lang-java/src/lib.rs index 9c77f38..cbe13fd 100644 --- a/crates/lang-java/src/lib.rs +++ b/crates/lang-java/src/lib.rs @@ -233,6 +233,12 @@ impl NodeAdapter for JavaPlugin { impl JavaPlugin { pub fn new() -> std::result::Result> { + // Register metadata deserializer for Java + naviscope_plugin::register_metadata_deserializer( + "java", + crate::model::JavaIndexMetadata::deserialize_for_cache, + ); + let parser = Arc::new(parser::JavaParser::new()?); let resolver = Arc::new(resolver::JavaResolver { parser: (*parser).clone(), @@ -279,6 +285,14 @@ impl LanguagePlugin for JavaPlugin { fn lsp_parser(&self) -> Arc { Arc::new(self.clone()) } + + fn external_resolver(&self) -> Option> { + Some(Arc::new(crate::resolver::external::JavaExternalResolver)) + } + + fn can_handle_external_asset(&self, ext: &str) -> bool { + ext == "jar" || ext == "jmod" || ext == "class" + } } impl LspParser for JavaPlugin { diff --git a/crates/lang-java/src/model.rs b/crates/lang-java/src/model.rs index 8df94f7..185306e 100644 --- a/crates/lang-java/src/model.rs +++ b/crates/lang-java/src/model.rs @@ -41,6 +41,10 @@ impl IndexMetadata for JavaIndexMetadata { fn intern(&self, interner: &mut dyn SymbolInterner) -> Arc { Arc::new(self.to_storage(interner)) } + + fn to_cached_metadata(&self) -> naviscope_plugin::CachedMetadata { + self.to_cached_metadata() + } } /// Interned metadata stored in the graph @@ -73,6 +77,22 @@ pub enum JavaNodeMetadata { } impl JavaIndexMetadata { + pub fn deserialize_for_cache(_version: u32, bytes: &[u8]) -> Arc { + // In the future, we can switch on version here to handle migrations + match rmp_serde::from_slice::(bytes) { + Ok(meta) => Arc::new(meta), + Err(_) => Arc::new(naviscope_api::models::graph::EmptyMetadata), + } + } + + pub fn to_cached_metadata(&self) -> naviscope_plugin::CachedMetadata { + naviscope_plugin::CachedMetadata { + type_tag: "java".to_string(), + version: 1, // Current version + data: rmp_serde::to_vec(self).unwrap_or_default(), + } + } + pub fn to_storage(&self, ctx: &mut dyn SymbolInterner) -> JavaNodeMetadata { match self { JavaIndexMetadata::Class { modifiers } => JavaNodeMetadata::Class { diff --git a/crates/lang-java/src/parser/index.rs b/crates/lang-java/src/parser/index.rs index 80097ca..26f21c4 100644 --- a/crates/lang-java/src/parser/index.rs +++ b/crates/lang-java/src/parser/index.rs @@ -1,5 +1,5 @@ use super::JavaParser; -use naviscope_api::models::graph::{DisplaySymbolLocation, NodeKind}; +use naviscope_api::models::graph::{DisplaySymbolLocation, NodeKind, ResolutionStatus}; use naviscope_plugin::utils::range_from_ts; use naviscope_plugin::{GlobalParseResult, IndexNode, IndexRelation, ParseOutput}; use std::sync::Arc; @@ -60,6 +60,7 @@ impl JavaParser { kind, lang: "java".to_string(), source: naviscope_api::models::graph::NodeSource::Project, + status: ResolutionStatus::Resolved, location, metadata: Arc::new(e.element), } diff --git a/crates/lang-java/src/resolver/external/converter.rs b/crates/lang-java/src/resolver/external/converter.rs new file mode 100644 index 0000000..e5093bf --- /dev/null +++ b/crates/lang-java/src/resolver/external/converter.rs @@ -0,0 +1,128 @@ +use cafebabe::descriptors::{FieldDescriptor, FieldType, MethodDescriptor, ReturnDescriptor}; +use naviscope_api::models::TypeRef; + +pub struct JavaTypeConverter; + +impl JavaTypeConverter { + pub fn convert_field(desc: &FieldDescriptor) -> TypeRef { + let mut tr = Self::convert_type(&desc.field_type); + if desc.dimensions > 0 { + tr = TypeRef::Array { + element: Box::new(tr), + dimensions: desc.dimensions as usize, + }; + } + tr + } + + pub fn convert_method(desc: &MethodDescriptor) -> (TypeRef, Vec) { + let return_type = match &desc.return_type { + ReturnDescriptor::Void => TypeRef::Raw("void".to_string()), + ReturnDescriptor::Return(field_desc) => Self::convert_field(field_desc), + }; + + let parameters = desc + .parameters + .iter() + .enumerate() + .map(|(i, field_desc)| crate::model::JavaParameter { + name: format!("arg{}", i), + type_ref: Self::convert_field(field_desc), + }) + .collect(); + + (return_type, parameters) + } + + pub fn convert_type(ty: &FieldType) -> TypeRef { + match ty { + FieldType::Byte => TypeRef::Raw("byte".to_string()), + FieldType::Char => TypeRef::Raw("char".to_string()), + FieldType::Double => TypeRef::Raw("double".to_string()), + FieldType::Float => TypeRef::Raw("float".to_string()), + FieldType::Integer => TypeRef::Raw("int".to_string()), + FieldType::Long => TypeRef::Raw("long".to_string()), + FieldType::Short => TypeRef::Raw("short".to_string()), + FieldType::Boolean => TypeRef::Raw("boolean".to_string()), + FieldType::Object(name) => TypeRef::Id(name.replace('/', ".")), + } + } +} + +pub struct JavaModifierConverter; + +impl JavaModifierConverter { + pub fn parse_class(flags: cafebabe::ClassAccessFlags) -> Vec { + let mut mods = Vec::new(); + if flags.contains(cafebabe::ClassAccessFlags::PUBLIC) { + mods.push("public".into()); + } + if flags.contains(cafebabe::ClassAccessFlags::FINAL) { + mods.push("final".into()); + } + if flags.contains(cafebabe::ClassAccessFlags::ABSTRACT) + && !flags.contains(cafebabe::ClassAccessFlags::INTERFACE) + { + mods.push("abstract".into()); + } + mods + } + + pub fn parse_field(flags: cafebabe::FieldAccessFlags) -> Vec { + let mut mods = Vec::new(); + if flags.contains(cafebabe::FieldAccessFlags::PUBLIC) { + mods.push("public".into()); + } + if flags.contains(cafebabe::FieldAccessFlags::PRIVATE) { + mods.push("private".into()); + } + if flags.contains(cafebabe::FieldAccessFlags::PROTECTED) { + mods.push("protected".into()); + } + if flags.contains(cafebabe::FieldAccessFlags::STATIC) { + mods.push("static".into()); + } + if flags.contains(cafebabe::FieldAccessFlags::FINAL) { + mods.push("final".into()); + } + if flags.contains(cafebabe::FieldAccessFlags::VOLATILE) { + mods.push("volatile".into()); + } + if flags.contains(cafebabe::FieldAccessFlags::TRANSIENT) { + mods.push("transient".into()); + } + mods + } + + pub fn parse_method(flags: cafebabe::MethodAccessFlags) -> Vec { + let mut mods = Vec::new(); + if flags.contains(cafebabe::MethodAccessFlags::PUBLIC) { + mods.push("public".into()); + } + if flags.contains(cafebabe::MethodAccessFlags::PRIVATE) { + mods.push("private".into()); + } + if flags.contains(cafebabe::MethodAccessFlags::PROTECTED) { + mods.push("protected".into()); + } + if flags.contains(cafebabe::MethodAccessFlags::STATIC) { + mods.push("static".into()); + } + if flags.contains(cafebabe::MethodAccessFlags::FINAL) { + mods.push("final".into()); + } + if flags.contains(cafebabe::MethodAccessFlags::SYNCHRONIZED) { + mods.push("synchronized".into()); + } + if flags.contains(cafebabe::MethodAccessFlags::NATIVE) { + mods.push("native".into()); + } + if flags.contains(cafebabe::MethodAccessFlags::ABSTRACT) { + mods.push("abstract".into()); + } + if flags.contains(cafebabe::MethodAccessFlags::STRICT) { + mods.push("strictfp".into()); + } + mods + } +} diff --git a/crates/lang-java/src/resolver/external/mod.rs b/crates/lang-java/src/resolver/external/mod.rs new file mode 100644 index 0000000..ca1f73b --- /dev/null +++ b/crates/lang-java/src/resolver/external/mod.rs @@ -0,0 +1,217 @@ +use naviscope_plugin::{ExternalResolver, GlobalParseResult, IndexNode}; +use std::collections::HashSet; +use std::fs::File; +use std::io::Read; +use std::path::Path; +use std::sync::Arc; +use zip::ZipArchive; + +mod converter; +use converter::{JavaModifierConverter, JavaTypeConverter}; + +pub struct JavaExternalResolver; + +impl ExternalResolver for JavaExternalResolver { + fn index_asset( + &self, + asset: &Path, + ) -> std::result::Result, Box> { + let file = File::open(asset)?; + let mut archive = ZipArchive::new(file)?; + let mut packages = HashSet::new(); + + for i in 0..archive.len() { + let file = archive.by_index(i)?; + let name = file.name(); + + if name.ends_with(".class") && !name.contains('$') { + if let Some(slash_idx) = name.rfind('/') { + let package = name[..slash_idx].replace('/', "."); + if !package.starts_with("META-INF") { + packages.insert(package); + } + } + } + } + + let mut result: Vec = packages.into_iter().collect(); + result.sort(); + Ok(result) + } + + fn generate_stub( + &self, + fqn: &str, + asset: &Path, + ) -> std::result::Result> { + let file = File::open(asset)?; + let mut archive = ZipArchive::new(file)?; + + let mut current_fqn = fqn.to_string(); + let mut member_parts = Vec::new(); + + let mut entry = loop { + let class_path = current_fqn.replace('.', "/") + ".class"; + if let Ok(e) = archive.by_name(&class_path) { + break e; + } + + let inner_path = current_fqn.replace('.', "/"); + if let Some(idx) = inner_path.rfind('/') { + let mut try_inner = inner_path.clone(); + try_inner.replace_range(idx..idx + 1, "$"); + if let Ok(e) = archive.by_name(&(try_inner + ".class")) { + break e; + } + } + + if let Some(idx) = current_fqn.rfind('.') { + member_parts.push(current_fqn[idx + 1..].to_string()); + current_fqn = current_fqn[..idx].to_string(); + } else { + return Err( + format!("Could not find class for {} in {}", fqn, asset.display()).into(), + ); + } + }; + + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes)?; + let class = + cafebabe::parse_class(&bytes).map_err(|e| format!("Failed to parse class: {:?}", e))?; + + if member_parts.is_empty() { + let name = fqn.split('.').last().unwrap_or(fqn).to_string(); + let kind = if class + .access_flags + .contains(cafebabe::ClassAccessFlags::INTERFACE) + { + naviscope_api::models::graph::NodeKind::Interface + } else if class + .access_flags + .contains(cafebabe::ClassAccessFlags::ANNOTATION) + { + naviscope_api::models::graph::NodeKind::Annotation + } else if class + .access_flags + .contains(cafebabe::ClassAccessFlags::ENUM) + { + naviscope_api::models::graph::NodeKind::Enum + } else { + naviscope_api::models::graph::NodeKind::Class + }; + + let modifiers = JavaModifierConverter::parse_class(class.access_flags); + let metadata = crate::model::JavaIndexMetadata::Class { modifiers }; + + return Ok(IndexNode { + id: naviscope_api::models::symbol::NodeId::Flat(fqn.to_string()), + name, + kind, + lang: "java".to_string(), + source: naviscope_api::models::graph::NodeSource::External, + status: naviscope_api::models::graph::ResolutionStatus::Stubbed, + location: None, + metadata: Arc::new(metadata), + }); + } + + member_parts.reverse(); + let member_name = member_parts.join("."); + + for field in &class.fields { + if field.name == member_name { + let type_ref = JavaTypeConverter::convert_field(&field.descriptor); + let modifiers = JavaModifierConverter::parse_field(field.access_flags); + let metadata = crate::model::JavaIndexMetadata::Field { + modifiers, + type_ref, + }; + return Ok(IndexNode { + id: naviscope_api::models::symbol::NodeId::Flat(fqn.to_string()), + name: member_name.clone(), + kind: naviscope_api::models::graph::NodeKind::Field, + lang: "java".to_string(), + source: naviscope_api::models::graph::NodeSource::External, + status: naviscope_api::models::graph::ResolutionStatus::Stubbed, + location: None, + metadata: Arc::new(metadata), + }); + } + } + + for method in &class.methods { + if method.name == member_name { + let (return_type, parameters) = + JavaTypeConverter::convert_method(&method.descriptor); + let modifiers = JavaModifierConverter::parse_method(method.access_flags); + let metadata = crate::model::JavaIndexMetadata::Method { + modifiers, + return_type, + parameters, + is_constructor: member_name == "", + }; + return Ok(IndexNode { + id: naviscope_api::models::symbol::NodeId::Flat(fqn.to_string()), + name: if member_name == "" { + fqn.split('.').nth_back(1).unwrap_or(fqn).to_string() + } else { + member_name.clone() + }, + kind: if member_name == "" { + naviscope_api::models::graph::NodeKind::Constructor + } else { + naviscope_api::models::graph::NodeKind::Method + }, + lang: "java".to_string(), + source: naviscope_api::models::graph::NodeSource::External, + status: naviscope_api::models::graph::ResolutionStatus::Stubbed, + location: None, + metadata: Arc::new(metadata), + }); + } + } + + Err(format!("Member {} not found in class {}", member_name, current_fqn).into()) + } + + fn resolve_source( + &self, + _fqn: &str, + _source_asset: &Path, + ) -> std::result::Result> { + Err("Source resolution not yet implemented".into()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::tempdir; + + fn create_test_jar(path: &Path) { + let file = File::create(path).unwrap(); + let mut zip = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default(); + + zip.start_file("com/example/Test.class", options).unwrap(); + // CAFEBABE header + zip.write_all(&[0xCA, 0xFE, 0xBA, 0xBE, 0x00, 0x00, 0x00, 0x34]) + .unwrap(); + + zip.finish().unwrap(); + } + + #[test] + fn test_index_asset() { + let dir = tempdir().unwrap(); + let jar_path = dir.path().join("test.jar"); + create_test_jar(&jar_path); + + let resolver = JavaExternalResolver; + let packages = resolver.index_asset(&jar_path).unwrap(); + + assert_eq!(packages, vec!["com.example".to_string()]); + } +} diff --git a/crates/lang-java/src/resolver/mod.rs b/crates/lang-java/src/resolver/mod.rs index 8acfc29..a1b113d 100644 --- a/crates/lang-java/src/resolver/mod.rs +++ b/crates/lang-java/src/resolver/mod.rs @@ -12,6 +12,7 @@ use std::sync::Arc; use tree_sitter::Tree; pub mod context; +pub mod external; pub mod scope; use context::ResolutionContext; @@ -379,6 +380,7 @@ impl LangResolver for JavaResolver { kind: NodeKind::Package, lang: "java".to_string(), source: naviscope_api::models::graph::NodeSource::Project, + status: naviscope_api::models::graph::ResolutionStatus::Resolved, location: None, metadata: Arc::new(crate::model::JavaIndexMetadata::Package), }; diff --git a/crates/lang-java/src/resolver/scope/member.rs b/crates/lang-java/src/resolver/scope/member.rs index 21c9453..f431d71 100644 --- a/crates/lang-java/src/resolver/scope/member.rs +++ b/crates/lang-java/src/resolver/scope/member.rs @@ -392,7 +392,7 @@ impl SemanticScope> for MemberScope<'_> { #[cfg(test)] mod tests { use super::*; - use naviscope_api::models::graph::{EdgeType, GraphNode}; + use naviscope_api::models::graph::{EdgeType, GraphNode, ResolutionStatus}; use naviscope_api::models::symbol::{FqnId, FqnNode, FqnReader, Symbol}; use naviscope_plugin::{CodeGraph, Direction}; use std::path::Path; @@ -465,6 +465,7 @@ mod tests { kind: naviscope_api::models::graph::NodeKind::Field, lang: Symbol(lasso::Spur::default()), source: naviscope_api::models::graph::NodeSource::Project, + status: ResolutionStatus::Resolved, location: None, metadata: std::sync::Arc::new(JavaNodeMetadata::Field { type_ref: naviscope_api::models::TypeRef::Raw("int".to_string()), diff --git a/crates/plugin/Cargo.toml b/crates/plugin/Cargo.toml index 9d18493..00723e3 100644 --- a/crates/plugin/Cargo.toml +++ b/crates/plugin/Cargo.toml @@ -11,3 +11,4 @@ async-trait = { workspace = true } thiserror = { workspace = true } tree-sitter = { workspace = true } lsp-types = { workspace = true } +serde_bytes = "0.11.19" diff --git a/crates/plugin/src/converter.rs b/crates/plugin/src/converter.rs index 924cde6..b70e35e 100644 --- a/crates/plugin/src/converter.rs +++ b/crates/plugin/src/converter.rs @@ -30,6 +30,7 @@ impl ModelConverter for DisplayGraphNode { kind: self.kind.clone(), lang: interner.intern_atom(&self.lang), source: self.source.clone(), + status: naviscope_api::models::graph::ResolutionStatus::Resolved, // Default for display nodes location: self.location.as_ref().map(|l| l.to_internal(interner)), metadata: Arc::new(naviscope_api::models::graph::EmptyMetadata), } diff --git a/crates/plugin/src/graph.rs b/crates/plugin/src/graph.rs index bef7bf1..428bfa6 100644 --- a/crates/plugin/src/graph.rs +++ b/crates/plugin/src/graph.rs @@ -25,6 +25,10 @@ pub enum GraphOp { }, /// Update file metadata (hash, mtime) UpdateFile { metadata: SourceFile }, + /// Update the asset routing table: Prefix -> Asset Path + UpdateAssetRoutes { + routes: HashMap, + }, } /// Result of resolving a single file or unit diff --git a/crates/plugin/src/model.rs b/crates/plugin/src/model.rs index bbd8369..7115105 100644 --- a/crates/plugin/src/model.rs +++ b/crates/plugin/src/model.rs @@ -1,12 +1,77 @@ use crate::interner::SymbolInterner; use naviscope_api::models::graph::{ DisplaySymbolLocation, EdgeType, EmptyMetadata, NodeKind, NodeMetadata, NodeSource, + ResolutionStatus, }; use naviscope_api::models::symbol::{NodeId, Range}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::sync::OnceLock; use tree_sitter::Tree; +/// A function type that can deserialize bytes into language-specific IndexMetadata. +pub type MetadataDeserializer = fn(version: u32, bytes: &[u8]) -> Arc; + +/// Global registry for metadata deserializers. +static DESERIALIZER_REGISTRY: OnceLock>> = + OnceLock::new(); + +/// Register a deserializer for a specific metadata type tag. +pub fn register_metadata_deserializer(type_tag: &str, deserializer: MetadataDeserializer) { + let registry = DESERIALIZER_REGISTRY.get_or_init(|| std::sync::RwLock::new(HashMap::new())); + let mut registry = registry + .write() + .expect("Failed to lock deserializer registry"); + registry.insert(type_tag.to_string(), deserializer); +} + +/// Serialized metadata for cache storage. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CachedMetadata { + /// Type tag identifying the metadata format (e.g., "java") + pub type_tag: String, + + /// Schema version for the metadata format + pub version: u32, + + /// The serialized metadata bytes + #[serde(with = "serde_bytes")] + pub data: Vec, +} + +impl CachedMetadata { + /// Create a new empty metadata + pub fn empty() -> Self { + Self { + type_tag: "empty".to_string(), + version: 0, + data: Vec::new(), + } + } +} + +/// Deserialize metadata from CachedMetadata using the registered deserializers. +pub fn deserialize_metadata(cached: &CachedMetadata) -> Arc { + if cached.data.is_empty() || cached.type_tag == "empty" { + return Arc::new(EmptyMetadata); + } + + if let Some(registry) = DESERIALIZER_REGISTRY.get() { + let registry = registry + .read() + .expect("Failed to read deserializer registry"); + if let Some(deserializer) = registry.get(&cached.type_tag) { + return deserializer(cached.version, &cached.data); + } + } + + // Fallback: If "java" is not registered but type is "java", return empty + // but try to warn if appropriate (though we want to avoid log spam) + Arc::new(EmptyMetadata) +} + /// Compilation-time/Index-time metadata. /// This version usually contains strings and is used during the parsing phase. /// It must be able to convert itself into a runtime NodeMetadata. @@ -16,6 +81,12 @@ pub trait IndexMetadata: Send + Sync + std::fmt::Debug { /// Transform this metadata into its interned/optimized version for graph storage. fn intern(&self, interner: &mut dyn SymbolInterner) -> Arc; + + /// Convert this metadata into a cacheable form. + /// Default implementation returns empty metadata. + fn to_cached_metadata(&self) -> CachedMetadata { + CachedMetadata::empty() + } } impl IndexMetadata for EmptyMetadata { @@ -37,6 +108,7 @@ pub struct IndexNode { pub kind: NodeKind, pub lang: String, pub source: NodeSource, + pub status: ResolutionStatus, pub location: Option, pub metadata: Arc, } diff --git a/crates/plugin/src/plugin.rs b/crates/plugin/src/plugin.rs index a285982..064addd 100644 --- a/crates/plugin/src/plugin.rs +++ b/crates/plugin/src/plugin.rs @@ -84,6 +84,16 @@ pub trait LanguagePlugin: PluginInstance + Send + Sync { /// Get the LSP parser for this language fn lsp_parser(&self) -> Arc; + + /// Get the external resolver for classpath resolution (Phase 2+) + fn external_resolver(&self) -> Option> { + None + } + + /// Check if this plugin can handle an external asset (by extension) for stubbing. + fn can_handle_external_asset(&self, _ext: &str) -> bool { + false + } } /// Unified interface for build tool support. diff --git a/crates/plugin/src/resolver.rs b/crates/plugin/src/resolver.rs index 54e9973..7fb5c80 100644 --- a/crates/plugin/src/resolver.rs +++ b/crates/plugin/src/resolver.rs @@ -60,16 +60,28 @@ pub trait LspParser: Send + Sync { } /// Project context generated by BuildResolver during the first phase -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct ProjectContext { /// Mapping from path prefixes to module IDs (e.g., "/project/app" -> "module::app") pub path_to_module: HashMap, + + /// Third-party library asset paths (JARs, node_modules, etc.) + pub external_assets: Vec, + + /// SDK/Runtime asset paths (Builtin libs) + pub builtin_assets: Vec, + + /// Mapping from symbol prefixes (e.g. "java.lang") to asset paths + pub asset_routes: HashMap, } impl ProjectContext { pub fn new() -> Self { Self { path_to_module: HashMap::new(), + external_assets: Vec::new(), + builtin_assets: Vec::new(), + asset_routes: HashMap::new(), } } @@ -122,3 +134,31 @@ pub trait SemanticScope: Send + Sync { /// Returns the name of the scope for debugging purposes. fn name(&self) -> &'static str; } + +pub trait ExternalResolver: Send + Sync { + /// 1. Asset Indexing: Pre-scan asset content, returns top-level symbols/packages + /// Used by the engine to build the route table. + fn index_asset( + &self, + asset: &Path, + ) -> std::result::Result, Box>; + + /// 2. Code Stubbing: Extract symbol definitions (without logic) from asset. + /// Used for background population of External nodes' metadata. + fn generate_stub( + &self, + fqn: &str, + asset: &Path, + ) -> std::result::Result>; + + /// 3. Source Resolution: Extract full parse results from source asset. + /// Triggered when the user requests Go to Definition. + fn resolve_source( + &self, + fqn: &str, + source_asset: &Path, + ) -> std::result::Result< + crate::model::GlobalParseResult, + Box, + >; +} From e8bf326bd20ddcad69b78d4f68a9f473fc08ed26 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Sat, 7 Feb 2026 16:29:36 +0800 Subject: [PATCH 04/49] feat: Refactor engine initialization with a builder pattern and enhance asset routing to support multiple paths per symbol. --- Cargo.lock | 22 +++ Cargo.toml | 1 + crates/cli/src/cache.rs | 6 +- crates/cli/src/lib.rs | 2 + crates/core/src/facade/mod.rs | 12 +- crates/core/src/features/mod.rs | 4 +- crates/core/src/ingest/builder.rs | 35 ++-- crates/core/src/ingest/resolver/engine.rs | 78 ++++++-- crates/core/src/ingest/resolver/stub.rs | 19 +- crates/core/src/logging.rs | 4 +- crates/core/src/model/graph.rs | 12 +- crates/core/src/model/storage/converter.rs | 14 +- crates/core/src/model/storage/model.rs | 2 +- crates/core/src/runtime/orchestrator.rs | 186 ++++++++++++------ crates/core/tests/async_stubbing.rs | 19 +- crates/core/tests/engine_api.rs | 2 +- crates/core/tests/indexing.rs | 5 +- crates/core/tests/semantic_traits.rs | 7 +- crates/lang-java/Cargo.toml | 1 + crates/lang-java/src/jdk.rs | 69 +++++++ crates/lang-java/src/lib.rs | 1 + crates/lang-java/src/resolver/external/mod.rs | 175 +++++++++++++--- crates/lang-java/src/resolver/mod.rs | 22 +++ crates/lang-java/tests/common/mod.rs | 5 +- crates/lang-java/tests/repro_modifiers.rs | 122 ++++++++++++ crates/plugin/src/graph.rs | 2 +- crates/plugin/src/resolver.rs | 2 +- crates/runtime/src/lib.rs | 22 ++- 28 files changed, 659 insertions(+), 192 deletions(-) create mode 100644 crates/lang-java/src/jdk.rs create mode 100644 crates/lang-java/tests/repro_modifiers.rs diff --git a/Cargo.lock b/Cargo.lock index 8c20b08..8840170 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1394,6 +1394,15 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "memmap2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +dependencies = [ + "libc", +] + [[package]] name = "mimalloc" version = "0.1.48" @@ -1536,6 +1545,7 @@ dependencies = [ "naviscope-core", "naviscope-plugin", "petgraph", + "ristretto_jimage", "rmp-serde", "serde", "serde_json", @@ -2030,6 +2040,18 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +[[package]] +name = "ristretto_jimage" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5635cd85cac414cf04d9f66b7138203aef545e06c84b9a5848e962de9f70787f" +dependencies = [ + "byteorder", + "memchr", + "memmap2", + "thiserror 2.0.18", +] + [[package]] name = "rmcp" version = "0.13.0" diff --git a/Cargo.toml b/Cargo.toml index 8bbeb9c..e532622 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,3 +68,4 @@ mimalloc = "0.1" tempfile = "3.10" cafebabe = "0.9.0" zip = "7.3.0" +ristretto_jimage = "0.28.0" diff --git a/crates/cli/src/cache.rs b/crates/cli/src/cache.rs index 91534db..bef0e9c 100644 --- a/crates/cli/src/cache.rs +++ b/crates/cli/src/cache.rs @@ -40,10 +40,8 @@ struct AssetRow { } pub async fn run(cmd: CacheCommands) -> Result<(), Box> { - // We use current directory to initialize engine, though cache is global - let cwd = std::env::current_dir()?; - let engine = naviscope_runtime::build_default_engine(cwd); - let cache = engine.get_stub_cache_manager(); + // Cache is global, we don't need a full engine to access it + let cache = naviscope_runtime::get_cache_manager(); match cmd { CacheCommands::Stats => { diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 26eba58..5590c1a 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -86,6 +86,8 @@ pub fn run() -> Result<(), Box> { Commands::Lsp => ("lsp", false), Commands::Mcp { .. } => ("mcp", false), Commands::Shell { .. } => ("cli", false), + Commands::Cache { .. } => ("cli", false), + Commands::Clear { .. } => ("cli", false), _ => ("cli", true), }; let _guard = naviscope_runtime::init_logging(component, to_stderr); diff --git a/crates/core/src/facade/mod.rs b/crates/core/src/facade/mod.rs index e07a028..cf28266 100644 --- a/crates/core/src/facade/mod.rs +++ b/crates/core/src/facade/mod.rs @@ -25,7 +25,7 @@ impl EngineHandle { /// Create a new engine handle pub fn new(project_root: PathBuf) -> Self { Self { - engine: Arc::new(InternalEngine::new(project_root)), + engine: Arc::new(InternalEngine::builder(project_root).build()), } } @@ -118,7 +118,7 @@ mod tests { #[tokio::test] async fn test_async_graph_access() { - let engine = Arc::new(InternalEngine::new(PathBuf::from("."))); + let engine = Arc::new(InternalEngine::builder(PathBuf::from(".")).build()); let handle = EngineHandle::from_engine(engine); let graph = handle.graph().await; @@ -133,7 +133,7 @@ mod tests { let rt = tokio::runtime::Runtime::new().unwrap(); let _guard = rt.enter(); - let engine = Arc::new(InternalEngine::new(PathBuf::from("."))); + let engine = Arc::new(InternalEngine::builder(PathBuf::from(".")).build()); let handle = EngineHandle::from_engine(engine); let _graph = rt.block_on(handle.graph()); @@ -146,7 +146,7 @@ mod tests { async fn test_concurrent_queries() { use tokio::task::JoinSet; - let engine = Arc::new(InternalEngine::new(PathBuf::from("."))); + let engine = Arc::new(InternalEngine::builder(PathBuf::from(".")).build()); let handle = Arc::new(EngineHandle::from_engine(engine)); let mut set = JoinSet::new(); @@ -170,7 +170,7 @@ mod tests { async fn test_query_functionality() { use naviscope_api::models::GraphQuery; - let engine = Arc::new(InternalEngine::new(PathBuf::from("."))); + let engine = Arc::new(InternalEngine::builder(PathBuf::from(".")).build()); let handle = EngineHandle::from_engine(engine); // Test async query @@ -194,7 +194,7 @@ mod tests { let rt = tokio::runtime::Runtime::new().unwrap(); let _guard = rt.enter(); - let engine = Arc::new(InternalEngine::new(PathBuf::from("."))); + let engine = Arc::new(InternalEngine::builder(PathBuf::from(".")).build()); let handle = EngineHandle::from_engine(engine); let query = GraphQuery::Find { diff --git a/crates/core/src/features/mod.rs b/crates/core/src/features/mod.rs index e05dea9..1b12a73 100644 --- a/crates/core/src/features/mod.rs +++ b/crates/core/src/features/mod.rs @@ -15,7 +15,7 @@ pub trait CodeGraphLike: Send + Sync { fn fqn_map(&self) -> &std::collections::HashMap; fn path_to_nodes(&self, path: &Path) -> Option<&[petgraph::stable_graph::NodeIndex]>; fn reference_index(&self) -> &std::collections::HashMap>; - fn asset_routes(&self) -> &std::collections::HashMap; + fn asset_routes(&self) -> &std::collections::HashMap>; fn find_container_node_at( &self, path: &std::path::Path, @@ -84,7 +84,7 @@ impl CodeGraphLike for &T { (*self).reference_index() } - fn asset_routes(&self) -> &std::collections::HashMap { + fn asset_routes(&self) -> &std::collections::HashMap> { (*self).asset_routes() } diff --git a/crates/core/src/ingest/builder.rs b/crates/core/src/ingest/builder.rs index 099c7c5..051793a 100644 --- a/crates/core/src/ingest/builder.rs +++ b/crates/core/src/ingest/builder.rs @@ -208,11 +208,7 @@ impl CodeGraphBuilder { /// Remove all nodes associated with a file path pub fn remove_path(&mut self, path: &Path) { - let interned_path = Symbol( - self.inner - .symbols - .get_or_intern(path.to_string_lossy().as_ref()), - ); + let interned_path = Symbol(self.inner.symbols.get_or_intern(&path.to_string_lossy())); if let Some(entry) = self.inner.file_index.remove(&interned_path) { for idx in entry.nodes { self.remove_node(idx); @@ -227,11 +223,7 @@ impl CodeGraphBuilder { /// Update file metadata (creates or updates FileEntry) pub fn update_file(&mut self, path: &Path, source: SourceFile) { - let interned_path = Symbol( - self.inner - .symbols - .get_or_intern(path.to_string_lossy().as_ref()), - ); + let interned_path = Symbol(self.inner.symbols.get_or_intern(&path.to_string_lossy())); self.inner .file_index .entry(interned_path) @@ -292,11 +284,7 @@ impl CodeGraphBuilder { self.remove_path(&path); } GraphOp::UpdateIdentifiers { path, identifiers } => { - let path_sym = Symbol( - self.inner - .symbols - .get_or_intern(path.to_string_lossy().as_ref()), - ); + let path_sym = Symbol(self.inner.symbols.get_or_intern(&path.to_string_lossy())); for token in identifiers { let token_sym = Symbol(self.inner.symbols.get_or_intern(token.as_str())); let files = self.inner.reference_index.entry(token_sym).or_default(); @@ -310,14 +298,17 @@ impl CodeGraphBuilder { self.update_file(&path, metadata); } GraphOp::UpdateAssetRoutes { routes } => { - for (prefix, path) in routes { + for (prefix, paths) in routes { let prefix_sym = Symbol(self.inner.symbols.get_or_intern(&prefix)); - let path_sym = Symbol( - self.inner - .symbols - .get_or_intern(path.to_string_lossy().as_ref()), - ); - self.inner.asset_routes.insert(prefix_sym, path_sym); + let entry = self.inner.asset_routes.entry(prefix_sym).or_default(); + + for path in paths { + let path_sym = + Symbol(self.inner.symbols.get_or_intern(&path.to_string_lossy())); + if !entry.contains(&path_sym) { + entry.push(path_sym); + } + } } } } diff --git a/crates/core/src/ingest/resolver/engine.rs b/crates/core/src/ingest/resolver/engine.rs index 177b4f7..ab7097b 100644 --- a/crates/core/src/ingest/resolver/engine.rs +++ b/crates/core/src/ingest/resolver/engine.rs @@ -101,8 +101,9 @@ impl IndexResolver { .and_then(|p| p.get_naming_convention()) } - /// Resolve all parsed files into graph operations using a two-phase process - pub fn resolve(&self, files: Vec) -> Result> { + /// Resolve all parsed files into graph operations using a two-phase process. + /// Returns both the operations and the filled ProjectContext (containing asset_routes). + pub fn resolve(&self, files: Vec) -> Result<(Vec, ProjectContext)> { let (mut all_ops, build_files, source_files) = self.prepare_and_partition(files); // Phase 1: Build Tools @@ -116,9 +117,23 @@ impl IndexResolver { // Phase 2: Source Files let source_ops = self.resolve_source_batch(&source_files, &project_context)?; + + // Apply asset routes from source ops to context so they describe the full state + for op in &source_ops { + if let GraphOp::UpdateAssetRoutes { routes } = op { + for (prefix, paths) in routes { + project_context + .asset_routes + .entry(prefix.clone()) + .or_default() + .extend(paths.clone()); + } + } + } + all_ops.extend(source_ops); - Ok(all_ops) + Ok((all_ops, project_context)) } fn prepare_and_partition( @@ -161,16 +176,21 @@ impl IndexResolver { .to_lowercase(); for plugin in &self.lang_plugins { - // Heuristic: Java plugin handles .jar and .jmod - let is_java_asset = - (ext == "jar" || ext == "jmod") && plugin.name().as_str() == "java"; + let file_name = asset.file_name().and_then(|n| n.to_str()).unwrap_or(""); + // Heuristic: Java plugin handles .jar, .jmod and the 'modules' image + let is_java_asset = (ext == "jar" || ext == "jmod" || file_name == "modules") + && plugin.name().as_str() == "java"; let is_supported_ext = plugin.supported_extensions().contains(&ext.as_str()); if is_java_asset || is_supported_ext { if let Some(external) = plugin.external_resolver() { if let Ok(prefixes) = external.index_asset(&asset) { for prefix in prefixes { - context.asset_routes.insert(prefix, asset.clone()); + context + .asset_routes + .entry(prefix) + .or_default() + .push(asset.clone()); } } } @@ -187,14 +207,16 @@ impl IndexResolver { } } - pub fn schedule_stubs(&self, ops: &[GraphOp], context: Arc) { + pub fn resolve_stubs( + &self, + ops: &[GraphOp], + context: Arc, + ) -> Vec { + use crate::ingest::resolver::StubRequest; use naviscope_api::models::graph::NodeSource; use std::collections::HashSet; - let Some(stubbing) = &self.stubbing else { - return; - }; - + let mut requests = Vec::new(); let mut seen_fqns = HashSet::new(); // 1. Identify all unique external FQNs referenced in the operations @@ -216,14 +238,27 @@ impl IndexResolver { } if seen_fqns.is_empty() || context.asset_routes.is_empty() { - return; + return requests; } // 2. Schedule each FQN for background resolution for fqn in seen_fqns { // We only schedule if we have a route for it - if self.find_asset_for_fqn(&fqn, &context).is_some() { - stubbing.request(fqn, context.clone()); + if let Some(paths) = self.find_asset_for_fqn(&fqn, &context) { + requests.push(StubRequest { + fqn, + candidate_paths: paths.clone(), + }); + } + } + requests + } + + /// Schedule stubs using internal manager (for tests/backward compat) + pub fn schedule_stubs(&self, ops: &[GraphOp], context: Arc) { + if let Some(stubbing) = &self.stubbing { + for req in self.resolve_stubs(ops, context) { + stubbing.send(req); } } } @@ -232,12 +267,12 @@ impl IndexResolver { &self, fqn: &str, context: &'a ProjectContext, - ) -> Option<&'a std::path::PathBuf> { + ) -> Option<&'a Vec> { // Longest prefix match let mut current = fqn.to_string(); while !current.is_empty() { - if let Some(path) = context.asset_routes.get(¤t) { - return Some(path); + if let Some(paths) = context.asset_routes.get(¤t) { + return Some(paths); } if let Some(idx) = current.rfind('.') { current.truncate(idx); @@ -417,11 +452,14 @@ mod tests { assert_eq!(ops.len(), 1); if let GraphOp::UpdateAssetRoutes { routes } = &ops[0] { - assert_eq!(routes.get("com.example"), Some(&asset_path)); + assert_eq!(routes.get("com.example"), Some(&vec![asset_path.clone()])); } else { panic!("Expected UpdateAssetRoutes operation"); } - assert_eq!(context.asset_routes.get("com.example"), Some(&asset_path)); + assert_eq!( + context.asset_routes.get("com.example"), + Some(&vec![asset_path]) + ); } } diff --git a/crates/core/src/ingest/resolver/stub.rs b/crates/core/src/ingest/resolver/stub.rs index 456c8cc..6fdc686 100644 --- a/crates/core/src/ingest/resolver/stub.rs +++ b/crates/core/src/ingest/resolver/stub.rs @@ -1,12 +1,10 @@ -use crate::ingest::resolver::ProjectContext; -use std::sync::Arc; use tokio::sync::mpsc::UnboundedSender; /// A request to asynchronously generate a stub for an external FQN #[derive(Debug, Clone)] pub struct StubRequest { pub fqn: String, - pub context: Arc, + pub candidate_paths: Vec, } pub struct StubbingManager { @@ -18,7 +16,18 @@ impl StubbingManager { Self { tx } } - pub fn request(&self, fqn: String, context: Arc) { - let _ = self.tx.send(StubRequest { fqn, context }); + pub fn request(&self, fqn: String, candidate_paths: Vec) { + self.send(StubRequest { + fqn, + candidate_paths, + }); + } + + pub fn send(&self, req: StubRequest) { + let fqn = req.fqn.clone(); + match self.tx.send(req) { + Ok(_) => tracing::trace!("Sent stub request for {}", fqn), + Err(e) => tracing::warn!("Failed to send stub request for {}: {}", fqn, e), + } } } diff --git a/crates/core/src/logging.rs b/crates/core/src/logging.rs index a2f14e6..5b7bc2c 100644 --- a/crates/core/src/logging.rs +++ b/crates/core/src/logging.rs @@ -20,9 +20,7 @@ pub fn init_logging(component: &str, to_stderr: bool) -> WorkerGuard { .with_ansi(false) .with_target(true); - let registry = tracing_subscriber::registry() - .with(filter) - .with(file_layer); + let registry = tracing_subscriber::registry().with(filter).with(file_layer); if to_stderr { let stderr_layer = fmt::layer() diff --git a/crates/core/src/model/graph.rs b/crates/core/src/model/graph.rs index 8fbff0e..28b7e6b 100644 --- a/crates/core/src/model/graph.rs +++ b/crates/core/src/model/graph.rs @@ -62,7 +62,7 @@ pub struct CodeGraphInner { /// Asset Route Table: Prefix (Package/Symbol) -> Asset Path /// Used for routing FQNs to their defining JARs/files. - pub asset_routes: HashMap, + pub asset_routes: HashMap>, } /// Metadata and nodes associated with a single source file @@ -173,7 +173,7 @@ impl CodeGraph { /// Find node at a specific location in a file (by name range) pub fn find_node_at(&self, path: &Path, line: usize, col: usize) -> Option { let path_str = path.to_string_lossy(); - let key = self.inner.symbols.get(path_str.as_ref())?; + let key = self.inner.symbols.get(&path_str)?; let entry = self.inner.file_index.get(&Symbol(key))?; for &idx in &entry.nodes { @@ -197,7 +197,7 @@ impl CodeGraph { col: usize, ) -> Option { let path_str = path.to_string_lossy(); - let key = self.inner.symbols.get(path_str.as_ref())?; + let key = self.inner.symbols.get(&path_str)?; let entry = self.inner.file_index.get(&Symbol(key))?; let mut best_node = None; @@ -319,7 +319,7 @@ impl CodeGraphLike for CodeGraph { &self.inner.reference_index } - fn asset_routes(&self) -> &std::collections::HashMap { + fn asset_routes(&self) -> &std::collections::HashMap> { &self.inner.asset_routes } @@ -489,7 +489,7 @@ mod tests { builder .apply_op(naviscope_plugin::GraphOp::UpdateAssetRoutes { - routes: [(prefix.to_string(), path.to_path_buf())] + routes: [(prefix.to_string(), vec![path.to_path_buf()])] .into_iter() .collect(), }) @@ -504,7 +504,7 @@ mod tests { assert_eq!( deserialized.asset_routes().get(&prefix_sym), - Some(&path_sym) + Some(&vec![path_sym]) ); } } diff --git a/crates/core/src/model/storage/converter.rs b/crates/core/src/model/storage/converter.rs index b29e99b..05f7fb4 100644 --- a/crates/core/src/model/storage/converter.rs +++ b/crates/core/src/model/storage/converter.rs @@ -202,7 +202,12 @@ pub fn to_storage( asset_routes: inner .asset_routes .iter() - .map(|(prefix, path)| (prefix.0.into_usize() as u32, path.0.into_usize() as u32)) + .map(|(prefix, paths)| { + ( + prefix.0.into_usize() as u32, + paths.iter().map(|p| p.0.into_usize() as u32).collect(), + ) + }) .collect(), } } @@ -312,10 +317,13 @@ pub fn from_storage( asset_routes: storage .asset_routes .into_iter() - .map(|(prefix_sid, path_sid)| { + .map(|(prefix_sid, paths)| { ( Symbol(Spur::try_from_usize(prefix_sid as usize).unwrap()), - Symbol(Spur::try_from_usize(path_sid as usize).unwrap()), + paths + .into_iter() + .map(|sid| Symbol(Spur::try_from_usize(sid as usize).unwrap())) + .collect(), ) }) .collect(), diff --git a/crates/core/src/model/storage/model.rs b/crates/core/src/model/storage/model.rs index 3741c50..c99500b 100644 --- a/crates/core/src/model/storage/model.rs +++ b/crates/core/src/model/storage/model.rs @@ -110,7 +110,7 @@ pub struct StorageGraph { pub name_index: Vec<(u32, Vec)>, // (Symbol, Vec) pub file_index: Vec<(u32, StorageFileEntry)>, // (Symbol, Entry) pub reference_index: Vec<(u32, Vec)>, // (Symbol, Vec) - pub asset_routes: Vec<(u32, u32)>, // (Prefix, Path) + pub asset_routes: Vec<(u32, Vec)>, // (Prefix, Paths) } #[derive(Serialize, Deserialize)] diff --git a/crates/core/src/runtime/orchestrator.rs b/crates/core/src/runtime/orchestrator.rs index 4e13116..4718731 100644 --- a/crates/core/src/runtime/orchestrator.rs +++ b/crates/core/src/runtime/orchestrator.rs @@ -48,32 +48,58 @@ pub struct NaviscopeEngine { stub_cache: Arc, } -impl Drop for NaviscopeEngine { - fn drop(&mut self) { - self.cancel_token.cancel(); - } +pub struct NaviscopeEngineBuilder { + project_root: PathBuf, + build_plugins: Vec>, + lang_plugins: Vec>, } -impl NaviscopeEngine { - /// Create a new engine +impl NaviscopeEngineBuilder { pub fn new(project_root: PathBuf) -> Self { - let canonical_root = project_root + Self { + project_root, + build_plugins: Vec::new(), + lang_plugins: Vec::new(), + } + } + + pub fn with_language(mut self, plugin: Arc) -> Self { + self.lang_plugins.push(plugin); + self + } + + pub fn with_build_tool(mut self, plugin: Arc) -> Self { + self.build_plugins.push(plugin); + self + } + + pub fn build(self) -> NaviscopeEngine { + let canonical_root = self + .project_root .canonicalize() - .unwrap_or_else(|_| project_root.clone()); - let index_path = Self::compute_index_path(&canonical_root); + .unwrap_or_else(|_| self.project_root.clone()); + let index_path = NaviscopeEngine::compute_index_path(&canonical_root); let (stub_tx, stub_rx) = tokio::sync::mpsc::unbounded_channel(); let cancel_token = tokio_util::sync::CancellationToken::new(); // Initialize global cache once let stub_cache = Arc::new(crate::cache::GlobalStubCache::at_default_location()); - let engine = Self { + // Process naming conventions + let mut conventions = HashMap::new(); + for plugin in &self.lang_plugins { + if let Some(nc) = plugin.get_naming_convention() { + conventions.insert(plugin.name().to_string(), nc); + } + } + + let engine = NaviscopeEngine { current: Arc::new(RwLock::new(Arc::new(CodeGraph::empty()))), project_root: canonical_root, index_path, - build_plugins: Arc::new(Vec::new()), - lang_plugins: Arc::new(Vec::new()), - naming_conventions: Arc::new(std::collections::HashMap::new()), + build_plugins: Arc::new(self.build_plugins), + lang_plugins: Arc::new(self.lang_plugins), + naming_conventions: Arc::new(conventions), cancel_token: cancel_token.clone(), stub_tx, stub_cache: stub_cache.clone(), @@ -83,33 +109,22 @@ impl NaviscopeEngine { engine } +} - pub fn register_language(&mut self, plugin: Arc) { - // Register naming convention if available - if let Some(nc) = plugin.get_naming_convention() { - let mut conventions = (*self.naming_conventions).clone(); - conventions.insert(plugin.name().to_string(), nc); - self.naming_conventions = Arc::new(conventions); - } - - let mut plugins = (*self.lang_plugins).clone(); - plugins.push(plugin); - self.lang_plugins = Arc::new(plugins); +impl Drop for NaviscopeEngine { + fn drop(&mut self) { + self.cancel_token.cancel(); } +} - pub fn register_build_tool(&mut self, plugin: Arc) { - // Register naming convention if available - if let Some(nc) = plugin.get_naming_convention() { - let mut conventions = (*self.naming_conventions).clone(); - conventions.insert(plugin.name().to_string(), nc); - self.naming_conventions = Arc::new(conventions); - } - - let mut plugins = (*self.build_plugins).clone(); - plugins.push(plugin); - self.build_plugins = Arc::new(plugins); +impl NaviscopeEngine { + /// Create a builder for the engine + pub fn builder(project_root: PathBuf) -> NaviscopeEngineBuilder { + NaviscopeEngineBuilder::new(project_root) } + // ... helper methods ... + /// Get the project root path pub fn root_path(&self) -> &Path { &self.project_root @@ -188,8 +203,7 @@ impl NaviscopeEngine { let lang_plugins = self.lang_plugins.clone(); let stub_tx = self.stub_tx.clone(); - // Build in blocking pool (CPU-intensive) - let new_graph = tokio::task::spawn_blocking(move || { + let (new_graph, stubs) = tokio::task::spawn_blocking(move || { Self::build_index(&project_root, build_plugins, lang_plugins, stub_tx) }) .await @@ -201,6 +215,16 @@ impl NaviscopeEngine { *lock = Arc::new(new_graph); } + // Schedule stubs AFTER graph update using explicit requests + for req in stubs { + // We use a fresh stub_tx here, actually we need access to self.stub_tx? + // self.stub_tx is async channel. send is async or blocking? + // UnboundedSender::send is non-blocking. + if let Err(e) = self.stub_tx.send(req.clone()) { + tracing::warn!("Failed to schedule stub: {}", e); + } + } + // Save to disk self.save().await?; @@ -275,6 +299,10 @@ impl NaviscopeEngine { resolver.resolve_build_batch(&build_files, &mut project_context_inner)?; initial_ops.extend(build_ops); + // Phase 1.5: Asset Routing (Classpath) + let asset_ops = resolver.resolve_assets_batch(&mut project_context_inner)?; + initial_ops.extend(asset_ops); + let project_context = Arc::new(project_context_inner); // 3. Phase 2: Pipeline Batch Processing for source files @@ -295,10 +323,12 @@ impl NaviscopeEngine { builder.apply_ops(initial_ops)?; + let mut pending_stubs = Vec::new(); // Note: We are in a blocking thread, resolver and context are Thread-safe. pipeline.execute(&*project_context, source_paths, &resolver, |batch_ops| { builder.apply_ops(batch_ops.clone())?; - resolver.schedule_stubs(&batch_ops, project_context.clone()); + let reqs = resolver.resolve_stubs(&batch_ops, project_context.clone()); + pending_stubs.extend(reqs); Ok(()) })?; @@ -310,6 +340,13 @@ impl NaviscopeEngine { *lock = final_graph; }); + // 5. Schedule stubs + for req in pending_stubs { + if let Err(e) = stub_tx.send(req) { + tracing::warn!("Failed to schedule stub: {}", e); + } + } + Ok(()) }) .await @@ -433,41 +470,64 @@ impl NaviscopeEngine { // Resolve let mut ops = Vec::new(); - if let Some(asset_path) = req.context.asset_routes.get(&req.fqn) { + for asset_path in req.candidate_paths { // Try to create asset key for cache lookup - let asset_key = crate::cache::AssetKey::from_path(asset_path).ok(); + let asset_key = crate::cache::AssetKey::from_path(&asset_path).ok(); // Check cache first if let Some(ref key) = asset_key { if let Some(cached_stub) = stub_cache.lookup(key, &req.fqn) { tracing::trace!("Cache hit for {}", req.fqn); - ops.push(GraphOp::AddNode { data: Some(cached_stub) }); + ops.push(GraphOp::AddNode { + data: Some(cached_stub), + }); + break; // Found it } } // If not in cache, generate stub - if ops.is_empty() { - let ext = asset_path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_lowercase(); - - for plugin in lang_plugins.iter() { - if plugin.can_handle_external_asset(&ext) { - if let Some(external) = plugin.external_resolver() { - if let Ok(stub) = external.generate_stub(&req.fqn, asset_path) { + let ext = asset_path + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + + // Also check if file_name is "modules" (JImage without extension) + let file_name = + asset_path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + let is_jimage = file_name == "modules"; + + for plugin in lang_plugins.iter() { + let can_handle = plugin.can_handle_external_asset(&ext); + let jimage_match = is_jimage && plugin.name().as_str() == "java"; + + if can_handle || jimage_match { + if let Some(external) = plugin.external_resolver() { + match external.generate_stub(&req.fqn, &asset_path) { + Ok(stub) => { // Store in cache for future use if let Some(ref key) = asset_key { stub_cache.store(key, &stub); tracing::trace!("Cached stub for {}", req.fqn); } ops.push(GraphOp::AddNode { data: Some(stub) }); + break; + } + Err(e) => { + tracing::debug!( + "Failed to generate stub for {}: {}", + req.fqn, + e + ); } } } } } + + if !ops.is_empty() { + break; + } } if !ops.is_empty() { @@ -482,7 +542,7 @@ impl NaviscopeEngine { if let Ok(()) = builder.apply_ops(ops) { *lock = Arc::new(builder.build()); - tracing::debug!("Applied stub for {}", req.fqn); + tracing::trace!("Applied stub for {}", req.fqn); } } } @@ -622,21 +682,19 @@ impl NaviscopeEngine { build_plugins: Arc>>, lang_plugins: Arc>>, stub_tx: tokio::sync::mpsc::UnboundedSender, - ) -> Result { + ) -> Result<(CodeGraph, Vec)> { // Scan and parse let parse_results = Scanner::scan_and_parse(project_root, &std::collections::HashMap::new()); // Resolve - let project_context_inner = crate::ingest::resolver::ProjectContext::new(); let resolver = IndexResolver::with_plugins((*build_plugins).clone(), (*lang_plugins).clone()) .with_stubbing(StubbingManager::new(stub_tx)); - // Note: build_index currently use a separate resolve path which doesn't use the pipeline for everything. - // We'll manually schedule stubs for the full result set. - let ops = resolver.resolve(parse_results)?; - let project_context = Arc::new(project_context_inner); + // resolve() now returns both ops and the filled ProjectContext (with asset_routes) + let (ops, project_context) = resolver.resolve(parse_results)?; + let project_context = Arc::new(project_context); // Build graph let mut builder = CodeGraphBuilder::new(); @@ -649,9 +707,11 @@ impl NaviscopeEngine { } builder.apply_ops(ops.clone())?; - resolver.schedule_stubs(&ops, project_context); - Ok(builder.build()) + // Generate stubs proactively since we now have correct context + let stubs = resolver.resolve_stubs(&ops, project_context); + + Ok((builder.build(), stubs)) } pub fn get_stub_cache(&self) -> Arc { @@ -665,7 +725,7 @@ mod tests { #[tokio::test] async fn test_snapshot_is_fast() { - let engine = NaviscopeEngine::new(PathBuf::from(".")); + let engine = NaviscopeEngine::builder(PathBuf::from(".")).build(); let start = std::time::Instant::now(); for _ in 0..1000 { @@ -681,7 +741,7 @@ mod tests { async fn test_concurrent_snapshots() { use tokio::task::JoinSet; - let engine = Arc::new(NaviscopeEngine::new(PathBuf::from("."))); + let engine = Arc::new(NaviscopeEngine::builder(PathBuf::from(".")).build()); let mut set = JoinSet::new(); diff --git a/crates/core/tests/async_stubbing.rs b/crates/core/tests/async_stubbing.rs index 0f85fff..05afc9a 100644 --- a/crates/core/tests/async_stubbing.rs +++ b/crates/core/tests/async_stubbing.rs @@ -16,9 +16,8 @@ async fn test_stubbing_manager_sends_requests() { let (tx, mut rx) = mpsc::unbounded_channel(); let manager = StubbingManager::new(tx); - let context = Arc::new(ProjectContext::new()); - manager.request("com.example.Foo".to_string(), context.clone()); - manager.request("com.example.Bar".to_string(), context.clone()); + manager.request("com.example.Foo".to_string(), Vec::new()); + manager.request("com.example.Bar".to_string(), Vec::new()); // Verify requests are received let req1 = rx.recv().await.expect("Should receive first request"); @@ -57,9 +56,10 @@ async fn test_async_stubbing_with_jar() { std::fs::create_dir_all(&temp_dir).unwrap(); // Create engine with Java plugin - let mut engine = NaviscopeEngine::new(temp_dir.clone()); let java_plugin = Arc::new(JavaPlugin::new().expect("Failed to create JavaPlugin")); - engine.register_language(java_plugin); + let engine = NaviscopeEngine::builder(temp_dir.clone()) + .with_language(java_plugin) + .build(); // Find a real JAR file (use JDK's rt.jar or similar) let java_home = std::env::var("JAVA_HOME").ok(); @@ -72,7 +72,7 @@ async fn test_async_stubbing_with_jar() { let mut context = ProjectContext::new(); context .asset_routes - .insert("java.lang.String".to_string(), jmod.clone()); + .insert("java.lang.String".to_string(), vec![jmod.clone()]); let context = Arc::new(context); @@ -126,13 +126,12 @@ async fn test_stubbing_deduplication() { // Simulate what the worker's seen_fqns set does let mut seen = std::collections::HashSet::new(); - let context = Arc::new(ProjectContext::new()); let manager = StubbingManager::new(tx); // Send the same FQN twice - manager.request("com.example.Foo".to_string(), context.clone()); - manager.request("com.example.Foo".to_string(), context.clone()); - manager.request("com.example.Bar".to_string(), context.clone()); + manager.request("com.example.Foo".to_string(), Vec::new()); + manager.request("com.example.Foo".to_string(), Vec::new()); + manager.request("com.example.Bar".to_string(), Vec::new()); // Process like the worker would let mut processed = Vec::new(); diff --git a/crates/core/tests/engine_api.rs b/crates/core/tests/engine_api.rs index d23efe0..af5b30f 100644 --- a/crates/core/tests/engine_api.rs +++ b/crates/core/tests/engine_api.rs @@ -33,7 +33,7 @@ async fn test_engine_handle_query() { let temp_dir = std::env::temp_dir().join("naviscope_test_query"); std::fs::create_dir_all(&temp_dir).ok(); - let engine = Arc::new(CoreEngine::new(temp_dir.clone())); + let engine = Arc::new(CoreEngine::builder(temp_dir.clone()).build()); let handle = EngineHandle::from_engine(engine); // Test query execution via handle diff --git a/crates/core/tests/indexing.rs b/crates/core/tests/indexing.rs index c6bb4cb..0850874 100644 --- a/crates/core/tests/indexing.rs +++ b/crates/core/tests/indexing.rs @@ -94,8 +94,9 @@ async fn test_update_files_persistence_integration() { let build_gradle = dir.path().join("build.gradle"); fs::write(&build_gradle, "println 'hello'").unwrap(); - let mut engine = NaviscopeEngine::new(dir.path().to_path_buf()); - engine.register_build_tool(Arc::new(MockBuildPlugin)); + let engine = NaviscopeEngine::builder(dir.path().to_path_buf()) + .with_build_tool(Arc::new(MockBuildPlugin)) // This requires MockBuildPlugin to implement Clone if not wrapped in Arc, but `with_build_tool` takes Arc so we need to construct it + .build(); // First index engine diff --git a/crates/core/tests/semantic_traits.rs b/crates/core/tests/semantic_traits.rs index 82d41e3..629f1d9 100644 --- a/crates/core/tests/semantic_traits.rs +++ b/crates/core/tests/semantic_traits.rs @@ -301,7 +301,6 @@ impl LspParser for MockLspParser { } fn setup_engine(temp_dir: &Path) -> (CoreEngine, Arc) { - let mut engine = CoreEngine::new(temp_dir.to_path_buf()); let mock_resolver = Arc::new(MockResolver { res_at: std::sync::Mutex::new(None), }); @@ -314,7 +313,11 @@ fn setup_engine(temp_dir: &Path) -> (CoreEngine, Arc) { lsp_parser: mock_parser, lang_resolver: mock_lang_resolver, }); - engine.register_language(plugin.clone()); + + let engine = CoreEngine::builder(temp_dir.to_path_buf()) + .with_language(plugin.clone()) + .build(); + (engine, plugin) } diff --git a/crates/lang-java/Cargo.toml b/crates/lang-java/Cargo.toml index 1a9514b..ed1d64f 100644 --- a/crates/lang-java/Cargo.toml +++ b/crates/lang-java/Cargo.toml @@ -17,6 +17,7 @@ rmp-serde = { workspace = true } lasso = { workspace = true } cafebabe = { workspace = true } zip = { workspace = true } +ristretto_jimage = { workspace = true } [dev-dependencies] naviscope-core = { workspace = true } diff --git a/crates/lang-java/src/jdk.rs b/crates/lang-java/src/jdk.rs new file mode 100644 index 0000000..367da83 --- /dev/null +++ b/crates/lang-java/src/jdk.rs @@ -0,0 +1,69 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Locates the JDK core asset (e.g., `lib/modules` or `rt.jar`). +pub fn find_jdk_asset() -> Option { + // 1. Try JAVA_HOME environment variable + if let Ok(home) = std::env::var("JAVA_HOME") { + if let Some(asset) = check_jdk_home(Path::new(&home)) { + return Some(asset); + } + } + + // 2. Try macOS specific java_home utility + #[cfg(target_os = "macos")] + { + if let Ok(output) = Command::new("/usr/libexec/java_home").output() { + if output.status.success() { + let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !path_str.is_empty() { + if let Some(asset) = check_jdk_home(Path::new(&path_str)) { + return Some(asset); + } + } + } + } + } + + // 3. Try generic java command + if let Ok(output) = Command::new("java") + .arg("-XshowSettings:properties") + .arg("-version") + .output() + { + // Output is on stderr usually + let stderr = String::from_utf8_lossy(&output.stderr); + for line in stderr.lines() { + if line.trim().starts_with("java.home = ") { + let path_str = line.trim().trim_start_matches("java.home = ").trim(); + if let Some(asset) = check_jdk_home(Path::new(path_str)) { + return Some(asset); + } + } + } + } + + None +} + +fn check_jdk_home(home: &Path) -> Option { + // Check for JImage (Java 9+) + let modules = home.join("lib").join("modules"); + if modules.exists() { + return Some(modules); + } + + // Check for rt.jar (Java 8) + let rt = home.join("lib").join("rt.jar"); + if rt.exists() { + return Some(rt); + } + + // Some JRE layouts + let jre_rt = home.join("jre").join("lib").join("rt.jar"); + if jre_rt.exists() { + return Some(jre_rt); + } + + None +} diff --git a/crates/lang-java/src/lib.rs b/crates/lang-java/src/lib.rs index cbe13fd..930bf1a 100644 --- a/crates/lang-java/src/lib.rs +++ b/crates/lang-java/src/lib.rs @@ -1,3 +1,4 @@ +pub mod jdk; pub mod model; pub mod naming; pub mod parser; diff --git a/crates/lang-java/src/resolver/external/mod.rs b/crates/lang-java/src/resolver/external/mod.rs index ca1f73b..1ffb83e 100644 --- a/crates/lang-java/src/resolver/external/mod.rs +++ b/crates/lang-java/src/resolver/external/mod.rs @@ -1,4 +1,5 @@ use naviscope_plugin::{ExternalResolver, GlobalParseResult, IndexNode}; +use ristretto_jimage::Image; use std::collections::HashSet; use std::fs::File; use std::io::Read; @@ -11,18 +12,14 @@ use converter::{JavaModifierConverter, JavaTypeConverter}; pub struct JavaExternalResolver; -impl ExternalResolver for JavaExternalResolver { - fn index_asset( - &self, - asset: &Path, - ) -> std::result::Result, Box> { - let file = File::open(asset)?; - let mut archive = ZipArchive::new(file)?; +impl JavaExternalResolver { + fn extract_packages_from_zip( + archive: &mut ZipArchive, + ) -> std::result::Result, Box> { let mut packages = HashSet::new(); - for i in 0..archive.len() { - let file = archive.by_index(i)?; - let name = file.name(); + let entry = archive.by_index(i)?; + let name = entry.name(); if name.ends_with(".class") && !name.contains('$') { if let Some(slash_idx) = name.rfind('/') { @@ -33,6 +30,56 @@ impl ExternalResolver for JavaExternalResolver { } } } + Ok(packages) + } + + fn extract_packages_from_jimage(image: &Image) -> HashSet { + let mut packages = HashSet::new(); + for resource_result in image.iter() { + if let Ok(resource) = resource_result { + if resource.extension() == "class" && !resource.base().contains('$') { + let package = resource.parent().replace('/', "."); + if !package.is_empty() { + packages.insert(package); + } + } + } + } + packages + } +} + +impl ExternalResolver for JavaExternalResolver { + fn index_asset( + &self, + asset: &Path, + ) -> std::result::Result, Box> { + // Detect format via magic bytes + let mut file = File::open(asset)?; + let mut magic = [0u8; 4]; + if std::io::Read::read(&mut file, &mut magic).is_err() { + return Ok(vec![]); + } + + let packages: HashSet = match &magic { + // ZIP magic: PK\x03\x04 or PK\x05\x06 (empty) or PK\x07\x08 (spanned) + [0x50, 0x4B, _, _] => { + // Reset file position and parse as ZIP + std::io::Seek::seek(&mut file, std::io::SeekFrom::Start(0))?; + let mut archive = ZipArchive::new(file)?; + Self::extract_packages_from_zip(&mut archive)? + } + // JImage magic: CAFEDADA (big-endian) or DADAFECA (little-endian) + [0xCA, 0xFE, 0xDA, 0xDA] | [0xDA, 0xDA, 0xFE, 0xCA] => { + drop(file); // Close file handle before reopening via ristretto_jimage + let image = Image::from_file(asset)?; + Self::extract_packages_from_jimage(&image) + } + _ => { + // Unknown format, skip silently + return Ok(vec![]); + } + }; let mut result: Vec = packages.into_iter().collect(); result.sort(); @@ -45,38 +92,102 @@ impl ExternalResolver for JavaExternalResolver { asset: &Path, ) -> std::result::Result> { let file = File::open(asset)?; - let mut archive = ZipArchive::new(file)?; - let mut current_fqn = fqn.to_string(); let mut member_parts = Vec::new(); - let mut entry = loop { - let class_path = current_fqn.replace('.', "/") + ".class"; - if let Ok(e) = archive.by_name(&class_path) { - break e; + let bytes = match ZipArchive::new(file) { + Ok(mut archive) => { + let mut entry = loop { + let class_path = current_fqn.replace('.', "/") + ".class"; + if let Ok(e) = archive.by_name(&class_path) { + break e; + } + + let inner_path = current_fqn.replace('.', "/"); + if let Some(idx) = inner_path.rfind('/') { + let mut try_inner = inner_path.clone(); + try_inner.replace_range(idx..idx + 1, "$"); + if let Ok(e) = archive.by_name(&(try_inner + ".class")) { + break e; + } + } + + if let Some(idx) = current_fqn.rfind('.') { + member_parts.push(current_fqn[idx + 1..].to_string()); + current_fqn = current_fqn[..idx].to_string(); + } else { + return Err(format!( + "Could not find class for {} in {}", + fqn, + asset.display() + ) + .into()); + } + }; + + let mut b = Vec::new(); + entry.read_to_end(&mut b)?; + b } + Err(_) => { + // Try JImage + let image = Image::from_file(asset)?; + let mut bytes: Option> = None; - let inner_path = current_fqn.replace('.', "/"); - if let Some(idx) = inner_path.rfind('/') { - let mut try_inner = inner_path.clone(); - try_inner.replace_range(idx..idx + 1, "$"); - if let Ok(e) = archive.by_name(&(try_inner + ".class")) { - break e; + loop { + let class_path = current_fqn.replace('.', "/") + ".class"; + // Since we don't know the module, we search all modules + for resource_result in image.iter() { + if let Ok(resource) = resource_result { + if resource.name() == class_path { + bytes = Some(resource.data().to_vec()); + break; + } + } + } + + if bytes.is_some() { + break; + } + + // Try inner class + let inner_path = current_fqn.replace('.', "/"); + if let Some(idx) = inner_path.rfind('/') { + let mut try_inner = inner_path.clone(); + try_inner.replace_range(idx..idx + 1, "$"); + let try_inner_path = try_inner + ".class"; + + for resource_result in image.iter() { + if let Ok(resource) = resource_result { + if resource.name() == try_inner_path { + bytes = Some(resource.data().to_vec()); + break; + } + } + } + } + + if bytes.is_some() { + break; + } + + if let Some(idx) = current_fqn.rfind('.') { + member_parts.push(current_fqn[idx + 1..].to_string()); + current_fqn = current_fqn[..idx].to_string(); + } else { + return Err(format!( + "Could not find class for {} in jimage {}", + fqn, + asset.display() + ) + .into()); + } } - } - if let Some(idx) = current_fqn.rfind('.') { - member_parts.push(current_fqn[idx + 1..].to_string()); - current_fqn = current_fqn[..idx].to_string(); - } else { - return Err( - format!("Could not find class for {} in {}", fqn, asset.display()).into(), - ); + bytes.ok_or_else(|| format!("Class {} not found in jimage", fqn))? } }; - let mut bytes = Vec::new(); - entry.read_to_end(&mut bytes)?; let class = cafebabe::parse_class(&bytes).map_err(|e| format!("Failed to parse class: {:?}", e))?; diff --git a/crates/lang-java/src/resolver/mod.rs b/crates/lang-java/src/resolver/mod.rs index a1b113d..521aa8a 100644 --- a/crates/lang-java/src/resolver/mod.rs +++ b/crates/lang-java/src/resolver/mod.rs @@ -323,6 +323,28 @@ impl LangResolver for JavaResolver { let mut unit = ResolvedUnit::new(); let dummy_index = naviscope_plugin::EmptyCodeGraph; + // Route standardized Java packages to the local JDK asset if found + static JDK_PATH: std::sync::OnceLock> = + std::sync::OnceLock::new(); + if let Some(jdk_path) = JDK_PATH.get_or_init(|| crate::jdk::find_jdk_asset()) { + let prefixes = [ + "java", + "javax", + "jdk", + "sun", + "com.sun", + "org.xml.sax", + "org.w3c.dom", + "org.ietf.jgss", + ]; + let mut routes = std::collections::HashMap::new(); + for prefix in prefixes { + routes.insert(prefix.to_string(), vec![jdk_path.clone()]); + } + + unit.ops.push(GraphOp::UpdateAssetRoutes { routes }); + } + let parse_result_owned; let parse_result = match &file.content { ParsedContent::Language(res) => res, diff --git a/crates/lang-java/tests/common/mod.rs b/crates/lang-java/tests/common/mod.rs index 0ce95c1..76dc3c5 100644 --- a/crates/lang-java/tests/common/mod.rs +++ b/crates/lang-java/tests/common/mod.rs @@ -93,9 +93,10 @@ pub async fn setup_java_engine( use naviscope_core::runtime::orchestrator::NaviscopeEngine as CoreEngine; use naviscope_java::JavaPlugin; - let mut engine = CoreEngine::new(temp_dir.to_path_buf()); let java_plugin = JavaPlugin::new().expect("Failed to create JavaPlugin"); - engine.register_language(Arc::new(java_plugin)); + let engine = CoreEngine::builder(temp_dir.to_path_buf()) + .with_language(Arc::new(java_plugin)) + .build(); // Create files for (path_str, content) in &files { diff --git a/crates/lang-java/tests/repro_modifiers.rs b/crates/lang-java/tests/repro_modifiers.rs new file mode 100644 index 0000000..4483d3e --- /dev/null +++ b/crates/lang-java/tests/repro_modifiers.rs @@ -0,0 +1,122 @@ +use naviscope_java::jdk; +use naviscope_java::model::{JavaIndexMetadata, JavaNodeMetadata}; +use naviscope_plugin::{IndexMetadata, SymbolInterner}; +use std::collections::HashMap; + +// Mock context for interning +struct MockContext { + map: HashMap, + reverse: HashMap, + counter: u32, +} + +impl MockContext { + fn new() -> Self { + Self { + map: HashMap::new(), + reverse: HashMap::new(), + counter: 1, + } + } + + fn resolve(&self, id: u32) -> Option<&str> { + self.reverse.get(&id).map(|s| s.as_str()) + } +} + +impl SymbolInterner for MockContext { + fn intern_str(&mut self, s: &str) -> u32 { + if let Some(&id) = self.map.get(s) { + id + } else { + let id = self.counter; + self.counter += 1; + self.map.insert(s.to_string(), id); + self.reverse.insert(id, s.to_string()); + id + } + } +} + +#[test] +fn test_jdk_discovery() { + let jdk_path = jdk::find_jdk_asset(); + if let Some(path) = jdk_path { + println!("JDK discovered at: {:?}", path); + assert!(path.exists(), "Discovered JDK path must exist"); + + // Check if it's a valid asset (jimage or jar) + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + assert!( + ext == "jar" || path.ends_with("modules"), + "JDK asset must be a JAR or modules file" + ); + } else { + println!("WARNING: No JDK found in environment. This is expected if JAVA_HOME is not set."); + } +} + +#[test] +fn test_metadata_serialization_roundtrip() { + let modifiers = vec![ + "public".to_string(), + "final".to_string(), + "abstract".to_string(), + ]; + let metadata = JavaIndexMetadata::Class { + modifiers: modifiers.clone(), + }; + + // Serialize JavaIndexMetadata (simulating cache storage) + let cached = metadata.to_cached_metadata(); + assert_eq!(cached.version, 1); + assert!(!cached.data.is_empty()); + + // Deserialize back using helper + // Note: In real app, we register this deserializer. Here we call it directly. + let deserialized = JavaIndexMetadata::deserialize_for_cache(cached.version, &cached.data); + + // Convert to Any to downcast/check + let back = deserialized + .as_any() + .downcast_ref::() + .expect("Should be JavaIndexMetadata"); + + match back { + JavaIndexMetadata::Class { modifiers: m } => { + assert_eq!(m, &modifiers); + } + _ => panic!("Wrong variant"), + } +} + +#[test] +fn test_node_metadata_interning() { + let mut ctx = MockContext::new(); + let modifiers = vec!["public".to_string(), "static".to_string()]; + let metadata = JavaIndexMetadata::Class { + modifiers: modifiers.clone(), + }; + + let interned = metadata.intern(&mut ctx); + + // Check if it's JavaNodeMetadata + let node_meta = interned + .as_any() + .downcast_ref::() + .expect("Should be JavaNodeMetadata"); + + match node_meta { + JavaNodeMetadata::Class { modifiers_sids } => { + assert_eq!(modifiers_sids.len(), 2); + // Verify strings + let s1 = ctx.resolve(modifiers_sids[0]).unwrap(); + let s2 = ctx.resolve(modifiers_sids[1]).unwrap(); + + // Order is preserved + assert_eq!(s1, "public"); + assert_eq!(s2, "static"); + } + _ => panic!("Wrong variant"), + } +} diff --git a/crates/plugin/src/graph.rs b/crates/plugin/src/graph.rs index 428bfa6..bfcd3ea 100644 --- a/crates/plugin/src/graph.rs +++ b/crates/plugin/src/graph.rs @@ -27,7 +27,7 @@ pub enum GraphOp { UpdateFile { metadata: SourceFile }, /// Update the asset routing table: Prefix -> Asset Path UpdateAssetRoutes { - routes: HashMap, + routes: HashMap>, }, } diff --git a/crates/plugin/src/resolver.rs b/crates/plugin/src/resolver.rs index 7fb5c80..af3c328 100644 --- a/crates/plugin/src/resolver.rs +++ b/crates/plugin/src/resolver.rs @@ -72,7 +72,7 @@ pub struct ProjectContext { pub builtin_assets: Vec, /// Mapping from symbol prefixes (e.g. "java.lang") to asset paths - pub asset_routes: HashMap, + pub asset_routes: HashMap>, } impl ProjectContext { diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs index 44ac824..b39ae6f 100644 --- a/crates/runtime/src/lib.rs +++ b/crates/runtime/src/lib.rs @@ -8,16 +8,21 @@ use std::sync::Arc; /// This function acts as the central factory for the Naviscope runtime, /// assembling the core engine with language-specific plugins like Java and Gradle. pub fn build_default_engine(path: PathBuf) -> Arc { - let mut engine = naviscope_core::runtime::orchestrator::NaviscopeEngine::new(path); + let mut builder = naviscope_core::runtime::orchestrator::NaviscopeEngine::builder(path); // Register Build Tool Plugins - engine.register_build_tool(Arc::new(naviscope_gradle::GradlePlugin::new())); + builder = builder.with_build_tool(Arc::new(naviscope_gradle::GradlePlugin::new())); // Register Language Plugins - match naviscope_java::JavaPlugin::new() { - Ok(plugin) => engine.register_language(Arc::new(plugin)), - Err(e) => tracing::error!("Failed to load Java plugin: {}", e), - } + builder = match naviscope_java::JavaPlugin::new() { + Ok(plugin) => builder.with_language(Arc::new(plugin)), + Err(e) => { + tracing::error!("Failed to load Java plugin: {}", e); + builder + } + }; + + let engine = builder.build(); // Wrap in the standard EngineHandle which implements all API traits Arc::new(naviscope_core::facade::EngineHandle::from_engine(Arc::new( @@ -39,3 +44,8 @@ pub fn clear_all_indices() -> EngineResult<()> { }, ) } + +/// Get the global stub cache manager. +pub fn get_cache_manager() -> std::sync::Arc { + std::sync::Arc::new(naviscope_core::cache::GlobalStubCache::at_default_location()) +} From a2ed103e02685b885fb43c5a3a574b3c5d242719 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Sat, 7 Feb 2026 20:10:49 +0800 Subject: [PATCH 05/49] feat: Implement asset discovery for Gradle and JDK ecosystems - Added GradleCacheDiscoverer for discovering Gradle assets. - Introduced JdkDiscoverer for locating JDK standard library assets. - Updated GradleResolver to utilize the new GradleCacheDiscoverer. - Refactored JavaPlugin to include global asset discovery via JdkDiscoverer. - Enhanced asset indexing and routing capabilities in the plugin architecture. - Removed legacy asset discovery methods from GradleResolver. - Added tests for JdkDiscoverer and asset discovery functionalities. --- Cargo.lock | 4 + crates/api/src/models/graph.rs | 5 +- crates/core/Cargo.toml | 1 + crates/core/src/asset/mod.rs | 35 ++ crates/core/src/asset/registry.rs | 293 ++++++++++++++++ crates/core/src/asset/scanner.rs | 248 ++++++++++++++ crates/core/src/asset/service.rs | 297 ++++++++++++++++ crates/core/src/features/mod.rs | 5 - crates/core/src/ingest/builder.rs | 30 +- crates/core/src/ingest/parser/utils.rs | 1 + crates/core/src/ingest/resolver/engine.rs | 186 +--------- crates/core/src/lib.rs | 1 + crates/core/src/model/graph.rs | 39 --- crates/core/src/model/storage/converter.rs | 23 -- crates/core/src/model/storage/model.rs | 1 - crates/core/src/runtime/orchestrator.rs | 111 +++++- crates/core/tests/async_stubbing.rs | 26 +- crates/core/tests/global_assets.rs | 29 ++ crates/core/tests/semantic_traits.rs | 1 + crates/lang-gradle/Cargo.toml | 1 + crates/lang-gradle/src/discoverer/cache.rs | 184 ++++++++++ crates/lang-gradle/src/discoverer/mod.rs | 5 + crates/lang-gradle/src/lib.rs | 8 + crates/lang-gradle/src/resolver.rs | 320 ------------------ crates/lang-java/Cargo.toml | 2 + crates/lang-java/src/discoverer/jdk.rs | 284 ++++++++++++++++ crates/lang-java/src/discoverer/mod.rs | 5 + crates/lang-java/src/lib.rs | 16 +- crates/lang-java/src/resolver/external/mod.rs | 44 ++- crates/lang-java/src/resolver/mod.rs | 22 -- crates/plugin/src/asset.rs | 179 ++++++++++ crates/plugin/src/converter.rs | 2 +- crates/plugin/src/graph.rs | 4 - crates/plugin/src/lib.rs | 2 + crates/plugin/src/plugin.rs | 33 ++ crates/plugin/src/resolver.rs | 11 - crates/plugin/src/utils.rs | 1 + 37 files changed, 1801 insertions(+), 658 deletions(-) create mode 100644 crates/core/src/asset/mod.rs create mode 100644 crates/core/src/asset/registry.rs create mode 100644 crates/core/src/asset/scanner.rs create mode 100644 crates/core/src/asset/service.rs create mode 100644 crates/core/tests/global_assets.rs create mode 100644 crates/lang-gradle/src/discoverer/cache.rs create mode 100644 crates/lang-gradle/src/discoverer/mod.rs create mode 100644 crates/lang-java/src/discoverer/jdk.rs create mode 100644 crates/lang-java/src/discoverer/mod.rs create mode 100644 crates/plugin/src/asset.rs diff --git a/Cargo.lock b/Cargo.lock index 8840170..9e5add7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1482,6 +1482,7 @@ version = "0.5.5" dependencies = [ "async-trait", "dashmap 6.1.0", + "dirs", "ignore", "lasso", "log", @@ -1532,6 +1533,7 @@ dependencies = [ "thiserror 2.0.18", "tree-sitter", "tree-sitter-groovy", + "walkdir", ] [[package]] @@ -1539,12 +1541,14 @@ name = "naviscope-java" version = "0.5.5" dependencies = [ "cafebabe", + "dirs", "lasso", "lsp-types", "naviscope-api", "naviscope-core", "naviscope-plugin", "petgraph", + "regex", "ristretto_jimage", "rmp-serde", "serde", diff --git a/crates/api/src/models/graph.rs b/crates/api/src/models/graph.rs index d490b02..55f7399 100644 --- a/crates/api/src/models/graph.rs +++ b/crates/api/src/models/graph.rs @@ -132,7 +132,7 @@ impl Default for NodeSource { } } -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, JsonSchema)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq, Hash, JsonSchema)] #[serde(rename_all = "lowercase")] pub enum ResolutionStatus { /// Just a placeholder (name and ID known, metadata may be empty) @@ -140,6 +140,7 @@ pub enum ResolutionStatus { /// Structure known from bytecode or partial scan (stubs available) Stubbed, /// Full details known from source code or complete parsing + #[default] Resolved, } @@ -222,6 +223,8 @@ pub struct DisplayGraphNode { pub lang: String, #[serde(default)] pub source: NodeSource, + #[serde(default)] + pub status: ResolutionStatus, pub location: Option, // Rendering fields diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 5d4c8e5..fa49ec5 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -32,6 +32,7 @@ naviscope-api = { workspace = true } naviscope-plugin = { workspace = true } async-trait = { workspace = true } url = { workspace = true } +dirs = { workspace = true } serde_bytes = "0.11.19" [dev-dependencies] diff --git a/crates/core/src/asset/mod.rs b/crates/core/src/asset/mod.rs new file mode 100644 index 0000000..96eb4dd --- /dev/null +++ b/crates/core/src/asset/mod.rs @@ -0,0 +1,35 @@ +//! Asset/Stub Layer - Independent module for asset discovery and stub generation. +//! +//! This module provides the core infrastructure for: +//! - Discovering assets (JARs, JDK modules) from various sources +//! - Building route tables (FQN prefix -> asset paths) +//! - Managing stub requests and generation +//! +//! ## Architecture +//! +//! ```text +//! ┌─────────────────────────┐ ┌───────────────────────┐ +//! │ AssetDiscoverer[] │ │ AssetIndexer[] │ +//! │ (JDK, Gradle, Maven) │───▶│ (extract prefixes) │ +//! └─────────────────────────┘ └───────────┬───────────┘ +//! │ +//! ▼ +//! ┌───────────────────────────┐ +//! │ AssetRouteRegistry │ +//! │ (prefix → paths) │ +//! └───────────────────────────┘ +//! ``` +//! +//! ## Note +//! +//! Concrete discoverers are implemented in their respective crates: +//! - `naviscope-java::JdkDiscoverer` - JDK asset discovery +//! - `naviscope-gradle::GradleCacheDiscoverer` - Gradle cache discovery + +pub mod registry; +pub mod scanner; +pub mod service; + +pub use registry::InMemoryRouteRegistry; +pub use scanner::AssetScanner; +pub use service::AssetStubService; diff --git a/crates/core/src/asset/registry.rs b/crates/core/src/asset/registry.rs new file mode 100644 index 0000000..4a2d90d --- /dev/null +++ b/crates/core/src/asset/registry.rs @@ -0,0 +1,293 @@ +//! In-memory implementation of AssetRouteRegistry. +//! +//! Provides thread-safe storage for FQN prefix → AssetEntry mappings. + +use naviscope_plugin::{AssetEntry, AssetRouteRegistry, RegistryStats}; +use std::collections::HashMap; +use std::sync::RwLock; + +/// Thread-safe in-memory route registry +pub struct InMemoryRouteRegistry { + /// Mapping from package prefix to asset entries + routes: RwLock>>, +} + +impl InMemoryRouteRegistry { + pub fn new() -> Self { + Self { + routes: RwLock::new(HashMap::new()), + } + } + + /// Create with initial capacity + pub fn with_capacity(capacity: usize) -> Self { + Self { + routes: RwLock::new(HashMap::with_capacity(capacity)), + } + } + + /// Register multiple routes at once (more efficient than individual calls) + pub fn register_batch(&self, entries: impl IntoIterator) { + let mut routes = self.routes.write().unwrap(); + for (prefix, entry) in entries { + routes.entry(prefix).or_default().push(entry); + } + } + + /// Clear all routes + pub fn clear(&self) { + let mut routes = self.routes.write().unwrap(); + routes.clear(); + } + + /// Get number of unique prefixes + pub fn prefix_count(&self) -> usize { + let routes = self.routes.read().unwrap(); + routes.len() + } +} + +impl Default for InMemoryRouteRegistry { + fn default() -> Self { + Self::new() + } +} + +impl AssetRouteRegistry for InMemoryRouteRegistry { + fn register(&self, prefix: String, entry: AssetEntry) { + let mut routes = self.routes.write().unwrap(); + routes.entry(prefix).or_default().push(entry); + } + + fn lookup(&self, fqn: &str) -> Option> { + let routes = self.routes.read().unwrap(); + + // Try exact match first + if let Some(entries) = routes.get(fqn) { + return Some(entries.clone()); + } + + // Try prefix matching (longest match wins) + let mut best_match: Option<(&str, &Vec)> = None; + + for (prefix, entries) in routes.iter() { + if fqn.starts_with(prefix) { + // Check if this is a valid prefix (followed by '.' or end of string) + let remainder = &fqn[prefix.len()..]; + if remainder.is_empty() || remainder.starts_with('.') { + match &best_match { + None => best_match = Some((prefix, entries)), + Some((best_prefix, _)) if prefix.len() > best_prefix.len() => { + best_match = Some((prefix, entries)) + } + _ => {} + } + } + } + } + + best_match.map(|(_, entries)| entries.clone()) + } + + fn lookup_by_source(&self, fqn: &str, source_type: &str) -> Option> { + self.lookup(fqn).map(|entries| { + entries + .into_iter() + .filter(|e| e.source.source_type() == source_type) + .collect() + }) + } + + fn all_routes(&self) -> HashMap> { + let routes = self.routes.read().unwrap(); + routes.clone() + } + + fn stats(&self) -> RegistryStats { + let routes = self.routes.read().unwrap(); + + let mut total_entries = 0; + let mut by_source: HashMap = HashMap::new(); + + for entries in routes.values() { + total_entries += entries.len(); + for entry in entries { + *by_source + .entry(entry.source.source_type().to_string()) + .or_default() += 1; + } + } + + RegistryStats { + total_prefixes: routes.len(), + total_entries, + by_source, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use naviscope_plugin::AssetSource; + use std::path::PathBuf; + + fn make_entry(path: &str, source: AssetSource) -> AssetEntry { + AssetEntry::new(PathBuf::from(path), source) + } + + #[test] + fn test_register_and_lookup() { + let registry = InMemoryRouteRegistry::new(); + + let entry = make_entry( + "/path/to/rt.jar", + AssetSource::Jdk { + version: Some("17".to_string()), + path: PathBuf::from("/usr/lib/jvm/java-17"), + }, + ); + + registry.register("java.lang".to_string(), entry.clone()); + + // Exact match + let result = registry.lookup("java.lang"); + assert!(result.is_some()); + assert_eq!(result.unwrap().len(), 1); + + // Prefix match + let result = registry.lookup("java.lang.String"); + assert!(result.is_some()); + assert_eq!(result.unwrap().len(), 1); + + // No match + let result = registry.lookup("com.example"); + assert!(result.is_none()); + + // Invalid prefix (must be followed by '.' or end) + registry.register( + "java".to_string(), + make_entry("/other.jar", AssetSource::Unknown), + ); + let result = registry.lookup("javascript.foo"); + assert!(result.is_none()); + } + + #[test] + fn test_longest_prefix_match() { + let registry = InMemoryRouteRegistry::new(); + + let entry1 = make_entry("/jdk.jar", AssetSource::Unknown); + let entry2 = make_entry("/netty.jar", AssetSource::Unknown); + + registry.register("io".to_string(), entry1); + registry.register("io.netty".to_string(), entry2); + + // Should match the longer prefix + let result = registry.lookup("io.netty.channel.Channel"); + assert!(result.is_some()); + let entries = result.unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].path, PathBuf::from("/netty.jar")); + } + + #[test] + fn test_lookup_by_source() { + let registry = InMemoryRouteRegistry::new(); + + let jdk_entry = make_entry( + "/jdk.jar", + AssetSource::Jdk { + version: Some("17".to_string()), + path: PathBuf::from("/jdk"), + }, + ); + let gradle_entry = make_entry( + "/gradle.jar", + AssetSource::Gradle { + group: "test".to_string(), + artifact: "test".to_string(), + version: "1.0".to_string(), + }, + ); + + registry.register("java.lang".to_string(), jdk_entry); + registry.register("java.lang".to_string(), gradle_entry); + + // Filter by source + let jdk_only = registry.lookup_by_source("java.lang", "jdk"); + assert!(jdk_only.is_some()); + assert_eq!(jdk_only.unwrap().len(), 1); + + let gradle_only = registry.lookup_by_source("java.lang", "gradle"); + assert!(gradle_only.is_some()); + assert_eq!(gradle_only.unwrap().len(), 1); + } + + #[test] + fn test_stats() { + let registry = InMemoryRouteRegistry::new(); + + registry.register( + "java.lang".to_string(), + make_entry( + "/a.jar", + AssetSource::Jdk { + version: None, + path: PathBuf::from("/jdk"), + }, + ), + ); + registry.register( + "java.util".to_string(), + make_entry( + "/b.jar", + AssetSource::Jdk { + version: None, + path: PathBuf::from("/jdk"), + }, + ), + ); + registry.register( + "io.netty".to_string(), + make_entry( + "/c.jar", + AssetSource::Gradle { + group: "io.netty".to_string(), + artifact: "netty".to_string(), + version: "4.1".to_string(), + }, + ), + ); + + let stats = registry.stats(); + assert_eq!(stats.total_prefixes, 3); + assert_eq!(stats.total_entries, 3); + assert_eq!(stats.by_source.get("jdk"), Some(&2)); + assert_eq!(stats.by_source.get("gradle"), Some(&1)); + } + + #[test] + fn test_register_batch() { + let registry = InMemoryRouteRegistry::new(); + + let entries = vec![ + ( + "java.lang".to_string(), + make_entry("/a.jar", AssetSource::Unknown), + ), + ( + "java.util".to_string(), + make_entry("/b.jar", AssetSource::Unknown), + ), + ( + "java.io".to_string(), + make_entry("/c.jar", AssetSource::Unknown), + ), + ]; + + registry.register_batch(entries); + + assert_eq!(registry.prefix_count(), 3); + } +} diff --git a/crates/core/src/asset/scanner.rs b/crates/core/src/asset/scanner.rs new file mode 100644 index 0000000..42ad879 --- /dev/null +++ b/crates/core/src/asset/scanner.rs @@ -0,0 +1,248 @@ +//! Asset scanner that combines multiple discoverers and indexers. +//! +//! The scanner orchestrates the discovery and indexing pipeline: +//! 1. Discoverers find asset files (JAR, jimage, etc.) +//! 2. Indexers extract package prefixes from each asset +//! 3. Results are registered in the route registry + +use naviscope_plugin::{AssetDiscoverer, AssetEntry, AssetIndexer, AssetRouteRegistry}; +use std::sync::Arc; +use tracing::{debug, info, warn}; + +/// Combines multiple asset discoverers with indexers +pub struct AssetScanner { + discoverers: Vec>, + indexers: Vec>, +} + +impl AssetScanner { + pub fn new() -> Self { + Self { + discoverers: Vec::new(), + indexers: Vec::new(), + } + } + + /// Add a discoverer + pub fn add_discoverer(mut self, discoverer: Box) -> Self { + self.discoverers.push(discoverer); + self + } + + /// Add an indexer + pub fn add_indexer(mut self, indexer: Arc) -> Self { + self.indexers.push(indexer); + self + } + + /// Add multiple discoverers + pub fn with_discoverers( + mut self, + discoverers: impl IntoIterator>, + ) -> Self { + self.discoverers.extend(discoverers); + self + } + + /// Add multiple indexers + pub fn with_indexers( + mut self, + indexers: impl IntoIterator>, + ) -> Self { + self.indexers.extend(indexers); + self + } + + /// Scan all assets and register routes + /// + /// This method streams through assets to maintain constant memory usage: + /// - Discoverers yield assets one at a time + /// - Indexers extract prefixes and immediately register them + pub fn scan(&self, registry: &dyn AssetRouteRegistry) -> ScanResult { + let mut result = ScanResult::default(); + let start = std::time::Instant::now(); + + for discoverer in &self.discoverers { + debug!("Scanning with discoverer: {}", discoverer.name()); + let discoverer_start = std::time::Instant::now(); + let mut discoverer_assets = 0; + + for entry in discoverer.discover() { + discoverer_assets += 1; + result.total_assets += 1; + + // Find a suitable indexer + if let Some(indexer) = self.find_indexer(&entry) { + match self.index_and_register(&entry, indexer.as_ref(), registry) { + Ok(prefix_count) => { + result.indexed_assets += 1; + result.total_prefixes += prefix_count; + } + Err(e) => { + warn!("Failed to index {:?}: {}", entry.path, e); + result.failed_assets += 1; + } + } + } else { + debug!("No indexer for {:?}", entry.path); + result.skipped_assets += 1; + } + } + + debug!( + "Discoverer {} found {} assets in {:?}", + discoverer.name(), + discoverer_assets, + discoverer_start.elapsed() + ); + } + + result.duration = start.elapsed(); + info!( + "Asset scan complete: {} assets, {} indexed, {} prefixes in {:?}", + result.total_assets, result.indexed_assets, result.total_prefixes, result.duration + ); + + result + } + + /// Find an indexer that can handle the given asset + fn find_indexer(&self, entry: &AssetEntry) -> Option> { + self.indexers + .iter() + .find(|indexer| indexer.can_index(&entry.path)) + .cloned() + } + + /// Index an asset and register all discovered prefixes + fn index_and_register( + &self, + entry: &AssetEntry, + indexer: &dyn AssetIndexer, + registry: &dyn AssetRouteRegistry, + ) -> Result> { + let prefixes = indexer.index(&entry.path)?; + let count = prefixes.len(); + + for prefix in prefixes { + registry.register(prefix, entry.clone()); + } + + Ok(count) + } +} + +impl Default for AssetScanner { + fn default() -> Self { + Self::new() + } +} + +/// Result of an asset scan operation +#[derive(Debug, Default, Clone)] +pub struct ScanResult { + /// Total number of assets discovered + pub total_assets: usize, + /// Number of assets successfully indexed + pub indexed_assets: usize, + /// Number of assets skipped (no indexer available) + pub skipped_assets: usize, + /// Number of assets that failed to index + pub failed_assets: usize, + /// Total number of prefixes registered + pub total_prefixes: usize, + /// Time taken for the scan + pub duration: std::time::Duration, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::asset::registry::InMemoryRouteRegistry; + use naviscope_plugin::{AssetSource, BoxError}; + use std::path::{Path, PathBuf}; + + /// Mock discoverer for testing + struct MockDiscoverer { + assets: Vec, + } + + impl AssetDiscoverer for MockDiscoverer { + fn discover(&self) -> Box + Send + '_> { + Box::new(self.assets.iter().cloned()) + } + + fn name(&self) -> &str { + "Mock Discoverer" + } + + fn source_type(&self) -> &str { + "mock" + } + } + + /// Mock indexer for testing + struct MockIndexer { + prefixes_per_asset: Vec, + } + + impl AssetIndexer for MockIndexer { + fn can_index(&self, _asset: &Path) -> bool { + true + } + + fn index(&self, _asset: &Path) -> Result, BoxError> { + Ok(self.prefixes_per_asset.clone()) + } + } + + #[test] + fn test_scanner_basic() { + let discoverer = Box::new(MockDiscoverer { + assets: vec![ + AssetEntry::new(PathBuf::from("/test1.jar"), AssetSource::Unknown), + AssetEntry::new(PathBuf::from("/test2.jar"), AssetSource::Unknown), + ], + }); + + let indexer = Arc::new(MockIndexer { + prefixes_per_asset: vec!["com.example".to_string(), "org.test".to_string()], + }); + + let scanner = AssetScanner::new() + .add_discoverer(discoverer) + .add_indexer(indexer); + + let registry = InMemoryRouteRegistry::new(); + let result = scanner.scan(®istry); + + assert_eq!(result.total_assets, 2); + assert_eq!(result.indexed_assets, 2); + assert_eq!(result.total_prefixes, 4); // 2 prefixes × 2 assets + + // Verify registry contents + let stats = registry.stats(); + assert_eq!(stats.total_prefixes, 2); // "com.example" and "org.test" + assert_eq!(stats.total_entries, 4); // Each prefix has 2 entries (from 2 assets) + } + + #[test] + fn test_scanner_no_indexer() { + let discoverer = Box::new(MockDiscoverer { + assets: vec![AssetEntry::new( + PathBuf::from("/test.jar"), + AssetSource::Unknown, + )], + }); + + // No indexer added + let scanner = AssetScanner::new().add_discoverer(discoverer); + + let registry = InMemoryRouteRegistry::new(); + let result = scanner.scan(®istry); + + assert_eq!(result.total_assets, 1); + assert_eq!(result.skipped_assets, 1); + assert_eq!(result.indexed_assets, 0); + } +} diff --git a/crates/core/src/asset/service.rs b/crates/core/src/asset/service.rs new file mode 100644 index 0000000..d1b869e --- /dev/null +++ b/crates/core/src/asset/service.rs @@ -0,0 +1,297 @@ +//! Unified Asset/Stub service facade. +//! +//! Provides a single entry point for: +//! - Asset discovery and route management +//! - Stub request handling +//! - Background scanning + +use crate::asset::registry::InMemoryRouteRegistry; +use crate::asset::scanner::{AssetScanner, ScanResult}; +use naviscope_plugin::{ + AssetDiscoverer, AssetEntry, AssetIndexer, AssetRouteRegistry, RegistryStats, StubGenerator, + StubRequest, +}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; +use tracing::debug; + +/// Unified Asset/Stub service +pub struct AssetStubService { + /// Route registry (shared, thread-safe) + registry: Arc, + + /// Asset scanner configuration + scanner: AssetScanner, + + /// Stub generators (from language plugins) + generators: Vec>, + + /// Channel for stub requests + stub_tx: Option>, +} + +impl AssetStubService { + /// Create a new service with discoverers, indexers, and generators + pub fn new( + discoverers: Vec>, + indexers: Vec>, + generators: Vec>, + ) -> Self { + let scanner = AssetScanner::new() + .with_discoverers(discoverers) + .with_indexers(indexers); + + Self { + registry: Arc::new(InMemoryRouteRegistry::new()), + scanner, + generators, + stub_tx: None, + } + } + + /// Create with custom registry (for testing or shared state) + pub fn with_registry( + registry: Arc, + discoverers: Vec>, + indexers: Vec>, + generators: Vec>, + ) -> Self { + let scanner = AssetScanner::new() + .with_discoverers(discoverers) + .with_indexers(indexers); + + Self { + registry, + scanner, + generators, + stub_tx: None, + } + } + + /// Set the stub request channel + pub fn with_stub_channel(mut self, tx: mpsc::UnboundedSender) -> Self { + self.stub_tx = Some(tx); + self + } + + /// Get a reference to the registry + pub fn registry(&self) -> Arc { + self.registry.clone() + } + + /// Perform a synchronous scan (blocks until complete) + pub fn scan_sync(&self) -> ScanResult { + self.scanner.scan(self.registry.as_ref()) + } + + /// Start a background scan task + pub fn spawn_scan(&self) -> JoinHandle { + let registry = self.registry.clone(); + let scanner = self.build_scanner_clone(); + + tokio::spawn(async move { + // Run scan in blocking thread pool + tokio::task::spawn_blocking(move || scanner.scan(registry.as_ref())) + .await + .unwrap_or_default() + }) + } + + /// Lookup asset entries for an FQN + pub fn lookup_asset(&self, fqn: &str) -> Option> { + self.registry.lookup(fqn) + } + + /// Lookup asset paths for an FQN (legacy compatibility) + pub fn lookup_paths(&self, fqn: &str) -> Option> { + self.registry + .lookup(fqn) + .map(|entries| entries.into_iter().map(|e| e.path).collect()) + } + + /// Request stub generation (async, non-blocking) + pub fn request_stub(&self, fqn: String, candidate_entries: Vec) { + if let Some(tx) = &self.stub_tx { + let request = StubRequest::new(fqn.clone(), candidate_entries); + if let Err(e) = tx.send(request) { + tracing::warn!("Failed to send stub request for {}: {}", fqn, e); + } else { + debug!("Sent stub request for {}", fqn); + } + } + } + + /// Get a snapshot of all routes (for serialization or passing to resolver) + pub fn routes_snapshot(&self) -> HashMap> { + self.registry + .all_routes() + .into_iter() + .map(|(k, v)| (k, v.into_iter().map(|e| e.path).collect())) + .collect() + } + + /// Get registry statistics + pub fn stats(&self) -> RegistryStats { + self.registry.stats() + } + + /// Get stub generators + pub fn generators(&self) -> &[Arc] { + &self.generators + } + + /// Find a generator that can handle the given asset + pub fn find_generator(&self, asset: &std::path::Path) -> Option> { + self.generators + .iter() + .find(|g| g.can_generate(asset)) + .cloned() + } + + // Helper to rebuild scanner (since AssetScanner contains non-Clone types) + fn build_scanner_clone(&self) -> AssetScanner { + // Note: This is a limitation - we can't easily clone the scanner + // For now, return a default scanner. The real implementation would + // need to use a different approach (e.g., Arc or factory pattern) + AssetScanner::new() + } +} + +/// Builder for AssetStubService +pub struct AssetStubServiceBuilder { + discoverers: Vec>, + indexers: Vec>, + generators: Vec>, + registry: Option>, + stub_tx: Option>, +} + +impl AssetStubServiceBuilder { + pub fn new() -> Self { + Self { + discoverers: Vec::new(), + indexers: Vec::new(), + generators: Vec::new(), + registry: None, + stub_tx: None, + } + } + + pub fn add_discoverer(mut self, discoverer: Box) -> Self { + self.discoverers.push(discoverer); + self + } + + pub fn add_indexer(mut self, indexer: Arc) -> Self { + self.indexers.push(indexer); + self + } + + pub fn add_generator(mut self, generator: Arc) -> Self { + self.generators.push(generator); + self + } + + pub fn with_registry(mut self, registry: Arc) -> Self { + self.registry = Some(registry); + self + } + + pub fn with_stub_channel(mut self, tx: mpsc::UnboundedSender) -> Self { + self.stub_tx = Some(tx); + self + } + + pub fn build(self) -> AssetStubService { + let mut service = if let Some(registry) = self.registry { + AssetStubService::with_registry( + registry, + self.discoverers, + self.indexers, + self.generators, + ) + } else { + AssetStubService::new(self.discoverers, self.indexers, self.generators) + }; + + if let Some(tx) = self.stub_tx { + service = service.with_stub_channel(tx); + } + + service + } +} + +impl Default for AssetStubServiceBuilder { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use naviscope_plugin::{AssetSource, BoxError}; + use std::path::Path; + + struct MockDiscoverer; + + impl AssetDiscoverer for MockDiscoverer { + fn discover(&self) -> Box + Send + '_> { + Box::new(std::iter::once(AssetEntry::new( + PathBuf::from("/test.jar"), + AssetSource::Unknown, + ))) + } + + fn name(&self) -> &str { + "Mock" + } + + fn source_type(&self) -> &str { + "mock" + } + } + + struct MockIndexer; + + impl AssetIndexer for MockIndexer { + fn can_index(&self, _: &Path) -> bool { + true + } + + fn index(&self, _: &Path) -> Result, BoxError> { + Ok(vec!["com.example".to_string()]) + } + } + + #[test] + fn test_service_basic() { + let service = AssetStubService::new( + vec![Box::new(MockDiscoverer)], + vec![Arc::new(MockIndexer)], + vec![], + ); + + let result = service.scan_sync(); + assert_eq!(result.indexed_assets, 1); + assert_eq!(result.total_prefixes, 1); + + let entries = service.lookup_asset("com.example.Foo"); + assert!(entries.is_some()); + } + + #[test] + fn test_builder() { + let service = AssetStubServiceBuilder::new() + .add_discoverer(Box::new(MockDiscoverer)) + .add_indexer(Arc::new(MockIndexer)) + .build(); + + let result = service.scan_sync(); + assert_eq!(result.indexed_assets, 1); + } +} diff --git a/crates/core/src/features/mod.rs b/crates/core/src/features/mod.rs index 1b12a73..bffb7fc 100644 --- a/crates/core/src/features/mod.rs +++ b/crates/core/src/features/mod.rs @@ -15,7 +15,6 @@ pub trait CodeGraphLike: Send + Sync { fn fqn_map(&self) -> &std::collections::HashMap; fn path_to_nodes(&self, path: &Path) -> Option<&[petgraph::stable_graph::NodeIndex]>; fn reference_index(&self) -> &std::collections::HashMap>; - fn asset_routes(&self) -> &std::collections::HashMap>; fn find_container_node_at( &self, path: &std::path::Path, @@ -84,10 +83,6 @@ impl CodeGraphLike for &T { (*self).reference_index() } - fn asset_routes(&self) -> &std::collections::HashMap> { - (*self).asset_routes() - } - fn find_container_node_at( &self, path: &std::path::Path, diff --git a/crates/core/src/ingest/builder.rs b/crates/core/src/ingest/builder.rs index 051793a..aa7212a 100644 --- a/crates/core/src/ingest/builder.rs +++ b/crates/core/src/ingest/builder.rs @@ -37,7 +37,6 @@ impl CodeGraphBuilder { name_index: HashMap::new(), file_index: HashMap::new(), reference_index: HashMap::new(), - asset_routes: HashMap::new(), }, naming_conventions: HashMap::new(), } @@ -125,6 +124,18 @@ impl CodeGraphBuilder { }; existing_node.metadata = node_data.metadata.intern(&mut ctx); + // Update status and kind (necessary for upgrading placeholders) + existing_node.status = node_data.status; + existing_node.kind = node_data.kind; + + // Update location if it was missing + if existing_node.location.is_none() && node_data.location.is_some() { + existing_node.location = node_data + .location + .as_ref() + .map(|l| l.to_internal(&self.inner.fqns)); + } + // Also update source if it was External and now it's Project (or just keep it updated) existing_node.source = node_data.source; } @@ -297,20 +308,6 @@ impl CodeGraphBuilder { let path = metadata.path.clone(); self.update_file(&path, metadata); } - GraphOp::UpdateAssetRoutes { routes } => { - for (prefix, paths) in routes { - let prefix_sym = Symbol(self.inner.symbols.get_or_intern(&prefix)); - let entry = self.inner.asset_routes.entry(prefix_sym).or_default(); - - for path in paths { - let path_sym = - Symbol(self.inner.symbols.get_or_intern(&path.to_string_lossy())); - if !entry.contains(&path_sym) { - entry.push(path_sym); - } - } - } - } } Ok(()) } @@ -330,8 +327,7 @@ impl CodeGraphBuilder { GraphOp::RemovePath { .. } => destructive.push(op), GraphOp::AddNode { .. } | GraphOp::UpdateFile { .. } - | GraphOp::UpdateIdentifiers { .. } - | GraphOp::UpdateAssetRoutes { .. } => additive.push(op), + | GraphOp::UpdateIdentifiers { .. } => additive.push(op), GraphOp::AddEdge { .. } => relational.push(op), } } diff --git a/crates/core/src/ingest/parser/utils.rs b/crates/core/src/ingest/parser/utils.rs index 3d0d34a..d486009 100644 --- a/crates/core/src/ingest/parser/utils.rs +++ b/crates/core/src/ingest/parser/utils.rs @@ -49,6 +49,7 @@ pub fn build_symbol_hierarchy(raw_symbols: Vec) -> Vec) -> Result<(Vec, ProjectContext)> { let (mut all_ops, build_files, source_files) = self.prepare_and_partition(files); @@ -111,26 +111,8 @@ impl IndexResolver { let build_ops = self.resolve_build_batch(&build_files, &mut project_context)?; all_ops.extend(build_ops); - // Phase 1.5: Asset Routing (Classpath) - let asset_ops = self.resolve_assets_batch(&mut project_context)?; - all_ops.extend(asset_ops); - // Phase 2: Source Files let source_ops = self.resolve_source_batch(&source_files, &project_context)?; - - // Apply asset routes from source ops to context so they describe the full state - for op in &source_ops { - if let GraphOp::UpdateAssetRoutes { routes } = op { - for (prefix, paths) in routes { - project_context - .asset_routes - .entry(prefix.clone()) - .or_default() - .extend(paths.clone()); - } - } - } - all_ops.extend(source_ops); Ok((all_ops, project_context)) @@ -156,61 +138,10 @@ impl IndexResolver { (all_ops, build_files, source_files) } - pub fn resolve_assets_batch(&self, context: &mut ProjectContext) -> Result> { - // 1. Collect and deduplicate all assets - let mut all_assets = context.builtin_assets.clone(); - all_assets.extend(context.external_assets.clone()); - all_assets.sort(); - all_assets.dedup(); - - if all_assets.is_empty() { - return Ok(vec![]); - } - - // 2. Index each asset using appropriate language plugins - for asset in all_assets { - let ext = asset - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_lowercase(); - - for plugin in &self.lang_plugins { - let file_name = asset.file_name().and_then(|n| n.to_str()).unwrap_or(""); - // Heuristic: Java plugin handles .jar, .jmod and the 'modules' image - let is_java_asset = (ext == "jar" || ext == "jmod" || file_name == "modules") - && plugin.name().as_str() == "java"; - let is_supported_ext = plugin.supported_extensions().contains(&ext.as_str()); - - if is_java_asset || is_supported_ext { - if let Some(external) = plugin.external_resolver() { - if let Ok(prefixes) = external.index_asset(&asset) { - for prefix in prefixes { - context - .asset_routes - .entry(prefix) - .or_default() - .push(asset.clone()); - } - } - } - } - } - } - - if context.asset_routes.is_empty() { - Ok(vec![]) - } else { - Ok(vec![GraphOp::UpdateAssetRoutes { - routes: context.asset_routes.clone(), - }]) - } - } - pub fn resolve_stubs( &self, ops: &[GraphOp], - context: Arc, + routes: &std::collections::HashMap>, ) -> Vec { use crate::ingest::resolver::StubRequest; use naviscope_api::models::graph::NodeSource; @@ -237,14 +168,14 @@ impl IndexResolver { } } - if seen_fqns.is_empty() || context.asset_routes.is_empty() { + if seen_fqns.is_empty() || routes.is_empty() { return requests; } // 2. Schedule each FQN for background resolution for fqn in seen_fqns { // We only schedule if we have a route for it - if let Some(paths) = self.find_asset_for_fqn(&fqn, &context) { + if let Some(paths) = self.find_asset_for_fqn(&fqn, routes) { requests.push(StubRequest { fqn, candidate_paths: paths.clone(), @@ -255,9 +186,13 @@ impl IndexResolver { } /// Schedule stubs using internal manager (for tests/backward compat) - pub fn schedule_stubs(&self, ops: &[GraphOp], context: Arc) { + pub fn schedule_stubs( + &self, + ops: &[GraphOp], + routes: &std::collections::HashMap>, + ) { if let Some(stubbing) = &self.stubbing { - for req in self.resolve_stubs(ops, context) { + for req in self.resolve_stubs(ops, routes) { stubbing.send(req); } } @@ -266,12 +201,12 @@ impl IndexResolver { fn find_asset_for_fqn<'a>( &self, fqn: &str, - context: &'a ProjectContext, + routes: &'a std::collections::HashMap>, ) -> Option<&'a Vec> { // Longest prefix match let mut current = fqn.to_string(); while !current.is_empty() { - if let Some(paths) = context.asset_routes.get(¤t) { + if let Some(paths) = routes.get(¤t) { return Some(paths); } if let Some(idx) = current.rfind('.') { @@ -308,8 +243,6 @@ impl IndexResolver { .map_err(crate::error::NaviscopeError::from)?; all_ops.extend(unit.ops); context.path_to_module.extend(ctx.path_to_module); - context.external_assets.extend(ctx.external_assets); - context.builtin_assets.extend(ctx.builtin_assets); } } Ok(all_ops) @@ -368,98 +301,3 @@ impl crate::ingest::pipeline::PipelineStage for IndexResolver { Ok(all_ops) } } - -#[cfg(test)] -mod tests { - use super::*; - use naviscope_api::models::Language; - use naviscope_plugin::{ExternalResolver, GlobalParseResult, LangResolver, LspParser}; - use std::path::{Path, PathBuf}; - - struct MockExternalResolver; - impl ExternalResolver for MockExternalResolver { - fn index_asset( - &self, - asset: &Path, - ) -> std::result::Result, Box> { - if asset.to_str().unwrap().contains("example.jar") { - Ok(vec!["com.example".to_string()]) - } else { - Ok(vec![]) - } - } - fn generate_stub( - &self, - _fqn: &str, - _asset: &Path, - ) -> std::result::Result< - naviscope_plugin::model::IndexNode, - Box, - > { - unimplemented!() - } - fn resolve_source( - &self, - _fqn: &str, - _source_asset: &Path, - ) -> std::result::Result> - { - unimplemented!() - } - } - - struct MockLanguagePlugin; - impl crate::plugin::PluginInstance for MockLanguagePlugin {} - impl crate::plugin::LanguagePlugin for MockLanguagePlugin { - fn name(&self) -> Language { - Language::JAVA - } - fn supported_extensions(&self) -> &[&str] { - &["java"] - } - fn parse_file( - &self, - _source: &str, - _path: &Path, - ) -> std::result::Result> - { - unimplemented!() - } - fn resolver(&self) -> Arc { - unimplemented!() - } - fn lang_resolver(&self) -> Arc { - unimplemented!() - } - fn lsp_parser(&self) -> Arc { - unimplemented!() - } - fn external_resolver(&self) -> Option> { - Some(Arc::new(MockExternalResolver)) - } - } - - #[test] - fn test_resolve_assets_batch() { - let mut resolver = IndexResolver::new(); - resolver.register_language(Arc::new(MockLanguagePlugin)); - - let mut context = ProjectContext::new(); - let asset_path = PathBuf::from("/libs/example.jar"); - context.external_assets.push(asset_path.clone()); - - let ops = resolver.resolve_assets_batch(&mut context).unwrap(); - - assert_eq!(ops.len(), 1); - if let GraphOp::UpdateAssetRoutes { routes } = &ops[0] { - assert_eq!(routes.get("com.example"), Some(&vec![asset_path.clone()])); - } else { - panic!("Expected UpdateAssetRoutes operation"); - } - - assert_eq!( - context.asset_routes.get("com.example"), - Some(&vec![asset_path]) - ); - } -} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 622370b..a32e007 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod asset; pub mod cache; pub mod error; pub mod logging; diff --git a/crates/core/src/model/graph.rs b/crates/core/src/model/graph.rs index 28b7e6b..c759875 100644 --- a/crates/core/src/model/graph.rs +++ b/crates/core/src/model/graph.rs @@ -60,9 +60,6 @@ pub struct CodeGraphInner { /// Used for fast "scouting" during reference discovery. pub reference_index: HashMap>, - /// Asset Route Table: Prefix (Package/Symbol) -> Asset Path - /// Used for routing FQNs to their defining JARs/files. - pub asset_routes: HashMap>, } /// Metadata and nodes associated with a single source file @@ -87,7 +84,6 @@ impl CodeGraph { name_index: HashMap::new(), file_index: HashMap::new(), reference_index: HashMap::new(), - asset_routes: HashMap::new(), }), } } @@ -319,10 +315,6 @@ impl CodeGraphLike for CodeGraph { &self.inner.reference_index } - fn asset_routes(&self) -> &std::collections::HashMap> { - &self.inner.asset_routes - } - fn find_container_node_at( &self, path: &std::path::Path, @@ -416,8 +408,6 @@ impl naviscope_plugin::CodeGraph for CodeGraph { #[cfg(test)] mod tests { use super::*; - use naviscope_api::models::symbol::Symbol; - use std::path::Path; #[test] fn test_arc_clone_is_cheap() { @@ -478,33 +468,4 @@ mod tests { assert_eq!(recovered_node.language(symbols).as_str(), "java"); } - #[test] - fn test_graph_asset_routes_serialization() { - use crate::ingest::builder::CodeGraphBuilder; - let mut builder = CodeGraphBuilder::new(); - - // Add a route - let prefix = "com.example"; - let path = Path::new("/path/to/example.jar"); - - builder - .apply_op(naviscope_plugin::GraphOp::UpdateAssetRoutes { - routes: [(prefix.to_string(), vec![path.to_path_buf()])] - .into_iter() - .collect(), - }) - .unwrap(); - - let graph = builder.build(); - let serialized = graph.serialize(|_| None).unwrap(); - let deserialized = CodeGraph::deserialize(&serialized, |_| None).unwrap(); - - let prefix_sym = Symbol(deserialized.symbols().get(prefix).unwrap()); - let path_sym = Symbol(deserialized.symbols().get(path.to_str().unwrap()).unwrap()); - - assert_eq!( - deserialized.asset_routes().get(&prefix_sym), - Some(&vec![path_sym]) - ); - } } diff --git a/crates/core/src/model/storage/converter.rs b/crates/core/src/model/storage/converter.rs index 05f7fb4..d3143ac 100644 --- a/crates/core/src/model/storage/converter.rs +++ b/crates/core/src/model/storage/converter.rs @@ -199,16 +199,6 @@ pub fn to_storage( name_index, file_index, reference_index, - asset_routes: inner - .asset_routes - .iter() - .map(|(prefix, paths)| { - ( - prefix.0.into_usize() as u32, - paths.iter().map(|p| p.0.into_usize() as u32).collect(), - ) - }) - .collect(), } } @@ -314,18 +304,5 @@ pub fn from_storage( name_index, file_index, reference_index, - asset_routes: storage - .asset_routes - .into_iter() - .map(|(prefix_sid, paths)| { - ( - Symbol(Spur::try_from_usize(prefix_sid as usize).unwrap()), - paths - .into_iter() - .map(|sid| Symbol(Spur::try_from_usize(sid as usize).unwrap())) - .collect(), - ) - }) - .collect(), } } diff --git a/crates/core/src/model/storage/model.rs b/crates/core/src/model/storage/model.rs index c99500b..af6288a 100644 --- a/crates/core/src/model/storage/model.rs +++ b/crates/core/src/model/storage/model.rs @@ -110,7 +110,6 @@ pub struct StorageGraph { pub name_index: Vec<(u32, Vec)>, // (Symbol, Vec) pub file_index: Vec<(u32, StorageFileEntry)>, // (Symbol, Entry) pub reference_index: Vec<(u32, Vec)>, // (Symbol, Vec) - pub asset_routes: Vec<(u32, Vec)>, // (Prefix, Paths) } #[derive(Serialize, Deserialize)] diff --git a/crates/core/src/runtime/orchestrator.rs b/crates/core/src/runtime/orchestrator.rs index 4718731..042ad68 100644 --- a/crates/core/src/runtime/orchestrator.rs +++ b/crates/core/src/runtime/orchestrator.rs @@ -1,12 +1,13 @@ //! Core indexing engine with MVCC support +use crate::asset::service::AssetStubService; use crate::error::{NaviscopeError, Result}; use crate::ingest::builder::CodeGraphBuilder; use crate::ingest::resolver::{IndexResolver, StubRequest, StubbingManager}; use crate::ingest::scanner::Scanner; use crate::model::CodeGraph; use crate::model::GraphOp; -use naviscope_plugin::NamingConvention; +use naviscope_plugin::{AssetDiscoverer, AssetIndexer, NamingConvention}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -46,6 +47,9 @@ pub struct NaviscopeEngine { /// Global stub cache stub_cache: Arc, + + /// Global asset service (new architecture) + asset_service: Option>, } pub struct NaviscopeEngineBuilder { @@ -93,6 +97,54 @@ impl NaviscopeEngineBuilder { } } + // Collect asset indexers from language plugins + let indexers: Vec> = self + .lang_plugins + .iter() + .filter_map(|p| p.asset_indexer()) + .collect(); + + // Collect asset discoverers from all plugins + let mut discoverers: Vec> = Vec::new(); + + // From language plugins (e.g., JdkDiscoverer from Java) + for plugin in &self.lang_plugins { + if let Some(d) = plugin.global_asset_discoverer() { + discoverers.push(d); + } + } + + // From build tool plugins (e.g., GradleCacheDiscoverer from Gradle) + for plugin in &self.build_plugins { + if let Some(d) = plugin.asset_discoverer() { + discoverers.push(d); + } + } + + // Project-local asset discoverers (optional hook) + for plugin in &self.lang_plugins { + if let Some(d) = plugin.project_asset_discoverer(&canonical_root) { + discoverers.push(d); + } + } + + for plugin in &self.build_plugins { + if let Some(d) = plugin.project_asset_discoverer(&canonical_root) { + discoverers.push(d); + } + } + + // Create asset service with discoverers from plugins + let asset_service = if !indexers.is_empty() && !discoverers.is_empty() { + Some(Arc::new(AssetStubService::new( + discoverers, + indexers, + vec![], // Generators will be added later + ))) + } else { + None + }; + let engine = NaviscopeEngine { current: Arc::new(RwLock::new(Arc::new(CodeGraph::empty()))), project_root: canonical_root, @@ -103,6 +155,7 @@ impl NaviscopeEngineBuilder { cancel_token: cancel_token.clone(), stub_tx, stub_cache: stub_cache.clone(), + asset_service, }; engine.spawn_stub_worker(stub_rx, cancel_token, stub_cache); @@ -143,6 +196,34 @@ impl NaviscopeEngine { self.naming_conventions.clone() } + /// Get the asset service (if available) + pub fn asset_service(&self) -> Option<&Arc> { + self.asset_service.as_ref() + } + + /// Run the global asset scan and populate routes + /// Returns the scan result with statistics + pub async fn scan_global_assets(&self) -> Option { + if let Some(service) = &self.asset_service { + let service = service.clone(); + let result = tokio::task::spawn_blocking(move || service.scan_sync()) + .await + .ok(); + result + } else { + None + } + } + + /// Get global asset routes snapshot (for passing to resolvers) + pub fn global_asset_routes(&self) -> HashMap> { + if let Some(service) = &self.asset_service { + service.routes_snapshot() + } else { + HashMap::new() + } + } + /// Compute index storage path for a project fn compute_index_path(project_root: &Path) -> PathBuf { let base_dir = Self::get_base_index_dir(); @@ -198,13 +279,21 @@ impl NaviscopeEngine { /// Rebuild the index from scratch pub async fn rebuild(&self) -> Result<()> { + let _ = self.scan_global_assets().await; let project_root = self.project_root.clone(); let build_plugins = self.build_plugins.clone(); let lang_plugins = self.lang_plugins.clone(); + let global_routes = self.global_asset_routes(); let stub_tx = self.stub_tx.clone(); let (new_graph, stubs) = tokio::task::spawn_blocking(move || { - Self::build_index(&project_root, build_plugins, lang_plugins, stub_tx) + Self::build_index( + &project_root, + build_plugins, + lang_plugins, + stub_tx, + global_routes, + ) }) .await .map_err(|e| NaviscopeError::Internal(e.to_string()))??; @@ -233,9 +322,11 @@ impl NaviscopeEngine { /// Update specific files incrementally pub async fn update_files(&self, files: Vec) -> Result<()> { + let _ = self.scan_global_assets().await; let base_graph = self.snapshot().await; let build_plugins = self.build_plugins.clone(); let lang_plugins = self.lang_plugins.clone(); + let global_routes = Arc::new(self.global_asset_routes()); // Prepare existing file metadata for change detection let mut existing_metadata = std::collections::HashMap::new(); @@ -299,11 +390,8 @@ impl NaviscopeEngine { resolver.resolve_build_batch(&build_files, &mut project_context_inner)?; initial_ops.extend(build_ops); - // Phase 1.5: Asset Routing (Classpath) - let asset_ops = resolver.resolve_assets_batch(&mut project_context_inner)?; - initial_ops.extend(asset_ops); - let project_context = Arc::new(project_context_inner); + let routes = global_routes.clone(); // 3. Phase 2: Pipeline Batch Processing for source files let pipeline = crate::ingest::pipeline::IngestPipeline::new(500); // 500 files per batch @@ -327,7 +415,7 @@ impl NaviscopeEngine { // Note: We are in a blocking thread, resolver and context are Thread-safe. pipeline.execute(&*project_context, source_paths, &resolver, |batch_ops| { builder.apply_ops(batch_ops.clone())?; - let reqs = resolver.resolve_stubs(&batch_ops, project_context.clone()); + let reqs = resolver.resolve_stubs(&batch_ops, routes.as_ref()); pending_stubs.extend(reqs); Ok(()) })?; @@ -682,6 +770,7 @@ impl NaviscopeEngine { build_plugins: Arc>>, lang_plugins: Arc>>, stub_tx: tokio::sync::mpsc::UnboundedSender, + global_routes: HashMap>, ) -> Result<(CodeGraph, Vec)> { // Scan and parse let parse_results = @@ -692,9 +781,8 @@ impl NaviscopeEngine { IndexResolver::with_plugins((*build_plugins).clone(), (*lang_plugins).clone()) .with_stubbing(StubbingManager::new(stub_tx)); - // resolve() now returns both ops and the filled ProjectContext (with asset_routes) - let (ops, project_context) = resolver.resolve(parse_results)?; - let project_context = Arc::new(project_context); + // resolve() now returns both ops and the filled ProjectContext + let (ops, _project_context) = resolver.resolve(parse_results)?; // Build graph let mut builder = CodeGraphBuilder::new(); @@ -708,8 +796,7 @@ impl NaviscopeEngine { builder.apply_ops(ops.clone())?; - // Generate stubs proactively since we now have correct context - let stubs = resolver.resolve_stubs(&ops, project_context); + let stubs = resolver.resolve_stubs(&ops, &global_routes); Ok((builder.build(), stubs)) } diff --git a/crates/core/tests/async_stubbing.rs b/crates/core/tests/async_stubbing.rs index 05afc9a..2893a5d 100644 --- a/crates/core/tests/async_stubbing.rs +++ b/crates/core/tests/async_stubbing.rs @@ -1,7 +1,7 @@ //! Tests for async stubbing workflow use naviscope_api::models::graph::ResolutionStatus; -use naviscope_core::ingest::resolver::{ProjectContext, StubbingManager}; +use naviscope_core::ingest::resolver::StubbingManager; use naviscope_core::model::GraphOp; use naviscope_core::runtime::orchestrator::NaviscopeEngine; use naviscope_java::JavaPlugin; @@ -68,19 +68,11 @@ async fn test_async_stubbing_with_jar() { .map(|h| PathBuf::from(h).join("jmods").join("java.base.jmod")); if let Some(jmod) = jmod_path.filter(|p| p.exists()) { - // Create a project context with the asset route - let mut context = ProjectContext::new(); - context - .asset_routes - .insert("java.lang.String".to_string(), vec![jmod.clone()]); + let mut routes = std::collections::HashMap::new(); + routes.insert("java.lang.String".to_string(), vec![jmod.clone()]); - let context = Arc::new(context); - - // Get the stub_tx from engine and send a request - // Note: In real usage, this happens automatically through IndexResolver let resolver = engine.get_resolver(); - // Create a mock GraphOp that would trigger stubbing let ops = vec![GraphOp::AddNode { data: Some(naviscope_plugin::IndexNode { id: naviscope_api::models::symbol::NodeId::Flat("java.lang.String".to_string()), @@ -94,17 +86,11 @@ async fn test_async_stubbing_with_jar() { }), }]; - // Schedule stubs - this sends to the background worker - resolver.schedule_stubs(&ops, context); + resolver.schedule_stubs(&ops, &routes); - // Give the worker time to process tokio::time::sleep(Duration::from_millis(500)).await; - // Check that the node was updated (in a real test, we'd verify the graph) - // For now, we just verify no panics occurred let graph = engine.snapshot().await; - // The graph might not have nodes yet if the worker hasn't finished, - // but the test ensures the pipeline doesn't crash println!( "Graph after stubbing: {} nodes, {} edges", graph.node_count(), @@ -114,7 +100,6 @@ async fn test_async_stubbing_with_jar() { println!("Skipping JAR test: JAVA_HOME not set or jmods not found"); } - // Cleanup let _ = std::fs::remove_dir_all(&temp_dir); } @@ -123,17 +108,14 @@ async fn test_async_stubbing_with_jar() { async fn test_stubbing_deduplication() { let (tx, mut rx) = mpsc::unbounded_channel(); - // Simulate what the worker's seen_fqns set does let mut seen = std::collections::HashSet::new(); let manager = StubbingManager::new(tx); - // Send the same FQN twice manager.request("com.example.Foo".to_string(), Vec::new()); manager.request("com.example.Foo".to_string(), Vec::new()); manager.request("com.example.Bar".to_string(), Vec::new()); - // Process like the worker would let mut processed = Vec::new(); while let Ok(req) = rx.try_recv() { if seen.insert(req.fqn.clone()) { diff --git a/crates/core/tests/global_assets.rs b/crates/core/tests/global_assets.rs new file mode 100644 index 0000000..e455c16 --- /dev/null +++ b/crates/core/tests/global_assets.rs @@ -0,0 +1,29 @@ +use naviscope_core::runtime::orchestrator::NaviscopeEngine; +use naviscope_java::JavaPlugin; +use std::sync::Arc; +use tempfile::tempdir; + +#[tokio::test] +async fn test_global_asset_scan_produces_routes() { + let dir = tempdir().unwrap(); + let java_plugin = Arc::new(JavaPlugin::new().expect("Failed to create JavaPlugin")); + let engine = NaviscopeEngine::builder(dir.path().to_path_buf()) + .with_language(java_plugin) + .build(); + + let scan = engine + .scan_global_assets() + .await + .expect("Expected asset service to be available"); + + let routes = engine.global_asset_routes(); + + assert!( + scan.total_assets >= scan.indexed_assets + scan.skipped_assets + scan.failed_assets + ); + + if scan.total_assets > 0 { + assert!(scan.total_prefixes > 0, "Expected some prefixes to be indexed"); + assert!(!routes.is_empty(), "Expected routes to be populated"); + } +} diff --git a/crates/core/tests/semantic_traits.rs b/crates/core/tests/semantic_traits.rs index 629f1d9..89868d0 100644 --- a/crates/core/tests/semantic_traits.rs +++ b/crates/core/tests/semantic_traits.rs @@ -62,6 +62,7 @@ impl NodeAdapter for MockPlugin { kind: node.kind.clone(), lang: rodeo.resolve_atom(node.lang).to_string(), source: node.source.clone(), + status: node.status, location: node.location.as_ref().map(|l| l.to_display(rodeo)), detail: None, signature: None, diff --git a/crates/lang-gradle/Cargo.toml b/crates/lang-gradle/Cargo.toml index 0fdaf9b..c52e562 100644 --- a/crates/lang-gradle/Cargo.toml +++ b/crates/lang-gradle/Cargo.toml @@ -17,6 +17,7 @@ once_cell = { workspace = true } rmp-serde = { workspace = true } lasso = { workspace = true } dirs = { workspace = true } +walkdir = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/lang-gradle/src/discoverer/cache.rs b/crates/lang-gradle/src/discoverer/cache.rs new file mode 100644 index 0000000..48dbf6d --- /dev/null +++ b/crates/lang-gradle/src/discoverer/cache.rs @@ -0,0 +1,184 @@ +//! Gradle cache asset discoverer. +//! +//! Discovers JAR files from the Gradle cache directory: +//! `~/.gradle/caches/modules-2/files-2.1` + +use naviscope_plugin::{AssetDiscoverer, AssetEntry, AssetSource}; +use std::path::PathBuf; +use walkdir::WalkDir; + +/// Gradle cache asset discoverer +pub struct GradleCacheDiscoverer { + cache_path: Option, +} + +impl GradleCacheDiscoverer { + pub fn new() -> Self { + let cache_path = dirs::home_dir().map(|h| h.join(".gradle/caches/modules-2/files-2.1")); + + Self { cache_path } + } + + /// Create with a custom cache path (for testing) + pub fn with_path(path: PathBuf) -> Self { + Self { + cache_path: Some(path), + } + } + + /// Parse Gradle cache path to extract Maven coordinates + /// Path format: ~/.gradle/caches/modules-2/files-2.1/{group}/{artifact}/{version}/{hash}/{file} + fn parse_cache_path(&self, path: &std::path::Path) -> AssetSource { + let Some(cache_root) = &self.cache_path else { + return AssetSource::Unknown; + }; + + let Ok(relative) = path.strip_prefix(cache_root) else { + return AssetSource::Unknown; + }; + + let components: Vec<_> = relative.components().collect(); + + // Expected: group/artifact/version/hash/file.jar + if components.len() >= 4 { + let group = components[0].as_os_str().to_string_lossy().to_string(); + let artifact = components[1].as_os_str().to_string_lossy().to_string(); + let version = components[2].as_os_str().to_string_lossy().to_string(); + + return AssetSource::Gradle { + group, + artifact, + version, + }; + } + + AssetSource::Unknown + } +} + +impl Default for GradleCacheDiscoverer { + fn default() -> Self { + Self::new() + } +} + +impl AssetDiscoverer for GradleCacheDiscoverer { + fn discover(&self) -> Box + Send + '_> { + let Some(cache_path) = &self.cache_path else { + return Box::new(std::iter::empty()); + }; + + if !cache_path.exists() { + return Box::new(std::iter::empty()); + } + + // Use WalkDir for lazy/streaming directory traversal + let cache_path_clone = cache_path.clone(); + Box::new( + WalkDir::new(&cache_path_clone) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| { + let path = e.path(); + // Only include JAR files + path.extension().and_then(|ext| ext.to_str()) == Some("jar") + }) + .filter(|e| { + // Exclude sources and javadoc JARs + let name = e.path().file_name().and_then(|n| n.to_str()).unwrap_or(""); + !name.ends_with("-sources.jar") && !name.ends_with("-javadoc.jar") + }) + .map(move |e| { + let path = e.path().to_path_buf(); + let source = self.parse_cache_path(&path); + AssetEntry::new(path, source) + }), + ) + } + + fn name(&self) -> &str { + "Gradle Cache Discoverer" + } + + fn source_type(&self) -> &str { + "gradle" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn test_gradle_cache_discoverer_empty() { + let temp = tempfile::tempdir().unwrap(); + let discoverer = GradleCacheDiscoverer::with_path(temp.path().to_path_buf()); + + let assets: Vec<_> = discoverer.discover().collect(); + assert!(assets.is_empty()); + } + + #[test] + fn test_gradle_cache_discoverer_finds_jars() { + let temp = tempfile::tempdir().unwrap(); + let cache_path = temp.path().to_path_buf(); + + // Create mock Gradle cache structure + // group/artifact/version/hash/file.jar + let jar_dir = cache_path.join("io.netty/netty-common/4.1.100.Final/abc123"); + fs::create_dir_all(&jar_dir).unwrap(); + + let jar_path = jar_dir.join("netty-common-4.1.100.Final.jar"); + fs::File::create(&jar_path).unwrap(); + + // Create sources jar (should be excluded) + let sources_jar = jar_dir.join("netty-common-4.1.100.Final-sources.jar"); + fs::File::create(&sources_jar).unwrap(); + + let discoverer = GradleCacheDiscoverer::with_path(cache_path); + let assets: Vec<_> = discoverer.discover().collect(); + + assert_eq!(assets.len(), 1); + assert_eq!(assets[0].path, jar_path); + + if let AssetSource::Gradle { + group, + artifact, + version, + } = &assets[0].source + { + assert_eq!(group, "io.netty"); + assert_eq!(artifact, "netty-common"); + assert_eq!(version, "4.1.100.Final"); + } else { + panic!("Expected Gradle source"); + } + } + + #[test] + fn test_parse_cache_path() { + let temp = tempfile::tempdir().unwrap(); + let cache_path = temp.path().to_path_buf(); + + let discoverer = GradleCacheDiscoverer::with_path(cache_path.clone()); + + let jar_path = + cache_path.join("com.google.guava/guava/31.1-jre/deadbeef/guava-31.1-jre.jar"); + + let source = discoverer.parse_cache_path(&jar_path); + + if let AssetSource::Gradle { + group, + artifact, + version, + } = source + { + assert_eq!(group, "com.google.guava"); + assert_eq!(artifact, "guava"); + assert_eq!(version, "31.1-jre"); + } else { + panic!("Expected Gradle source"); + } + } +} diff --git a/crates/lang-gradle/src/discoverer/mod.rs b/crates/lang-gradle/src/discoverer/mod.rs new file mode 100644 index 0000000..b5d24bf --- /dev/null +++ b/crates/lang-gradle/src/discoverer/mod.rs @@ -0,0 +1,5 @@ +//! Asset discoverers for Gradle ecosystem. + +mod cache; + +pub use cache::GradleCacheDiscoverer; diff --git a/crates/lang-gradle/src/lib.rs b/crates/lang-gradle/src/lib.rs index 12db9eb..adf1f3d 100644 --- a/crates/lang-gradle/src/lib.rs +++ b/crates/lang-gradle/src/lib.rs @@ -1,8 +1,11 @@ +pub mod discoverer; pub mod model; pub mod parser; pub mod queries; pub mod resolver; +pub use discoverer::GradleCacheDiscoverer; + use naviscope_api::models::BuildTool; use naviscope_api::models::graph::DisplayGraphNode; use naviscope_api::models::symbol::FqnReader; @@ -29,6 +32,7 @@ impl NodeAdapter for GradlePlugin { kind: node.kind.clone(), lang: "gradle".to_string(), source: node.source.clone(), + status: node.status, location: node.location.as_ref().map(|l| l.to_display(fqns)), detail: None, signature: None, @@ -130,4 +134,8 @@ impl BuildToolPlugin for GradlePlugin { fn build_resolver(&self) -> Arc { self.resolver.clone() } + + fn asset_discoverer(&self) -> Option> { + Some(Box::new(crate::discoverer::GradleCacheDiscoverer::new())) + } } diff --git a/crates/lang-gradle/src/resolver.rs b/crates/lang-gradle/src/resolver.rs index a1e369c..bc8bcf9 100644 --- a/crates/lang-gradle/src/resolver.rs +++ b/crates/lang-gradle/src/resolver.rs @@ -20,249 +20,6 @@ impl GradleResolver { fn normalize_path(&self, path: &Path) -> PathBuf { path.canonicalize().unwrap_or_else(|_| path.to_path_buf()) } - - /// Discovers built-in assets (SDK libraries like lib/modules, rt.jar or jmods). - fn discover_builtin_assets(&self) -> Vec { - let mut assets = Vec::new(); - - // 1. Check JAVA_HOME - if let Ok(java_home) = std::env::var("JAVA_HOME") { - self.collect_sdk_assets(Path::new(&java_home), &mut assets); - } - - // 2. macOS specific: Use java_home tool - #[cfg(target_os = "macos")] - if assets.is_empty() { - if let Ok(output) = std::process::Command::new("/usr/libexec/java_home").output() { - if output.status.success() { - let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); - self.collect_sdk_assets(Path::new(&path_str), &mut assets); - } - } - } - - // 3. Search common installation paths - if assets.is_empty() { - let mut search_roots = Vec::new(); - - #[cfg(target_os = "macos")] - { - search_roots.push(PathBuf::from("/Library/Java/JavaVirtualMachines/")); - search_roots.push(PathBuf::from("/opt/homebrew/opt/openjdk/")); - search_roots.push(PathBuf::from("/usr/local/opt/openjdk/")); - } - #[cfg(target_os = "linux")] - { - search_roots.push(PathBuf::from("/usr/lib/jvm/")); - } - #[cfg(target_os = "windows")] - { - search_roots.push(PathBuf::from("C:\\Program Files\\Java\\")); - } - - // SDKMAN - if let Some(mut sdkman) = dirs::home_dir() { - sdkman.push(".sdkman/candidates/java/"); - search_roots.push(sdkman); - } - - for root in search_roots { - if !root.exists() { - continue; - } - - // If root itself is a JDK (e.g. Homebrew symlink) - self.collect_sdk_assets(&root, &mut assets); - if !assets.is_empty() { - break; - } - - // If root is a parent directory containing multiple SDKs - if let Ok(entries) = std::fs::read_dir(root) { - for entry in entries.flatten() { - let mut sdk_path = entry.path(); - if cfg!(target_os = "macos") && sdk_path.join("Contents/Home").exists() { - sdk_path.push("Contents/Home"); - } - self.collect_sdk_assets(&sdk_path, &mut assets); - if !assets.is_empty() { - break; - } - } - } - - if !assets.is_empty() { - break; - } - } - } - - assets - } - - fn collect_sdk_assets(&self, sdk_path: &Path, assets: &mut Vec) { - if !sdk_path.exists() { - return; - } - - // Priority 1: Java 9+ Runtime Image (The most correct way for modern Java) - let modules = sdk_path.join("lib/modules"); - if modules.exists() { - assets.push(modules); - return; - } - - // Priority 2: Java 8 Legacy Runtime - let rt_jar = sdk_path.join("jre/lib/rt.jar"); - if rt_jar.exists() { - assets.push(rt_jar); - return; - } - let lib_rt_jar = sdk_path.join("lib/rt.jar"); - if lib_rt_jar.exists() { - assets.push(lib_rt_jar); - return; - } - - // Priority 3: jmods (Fallback for some JDK builds without lib/modules) - let jmods = sdk_path.join("jmods"); - if jmods.exists() { - if let Ok(entries) = std::fs::read_dir(jmods) { - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) == Some("jmod") { - assets.push(path); - } - } - } - } - } - - /// Discovers external assets (third-party JARs). - /// Discovers external assets (third-party JARs). - fn discover_external_assets(&self, files: &[&ParsedFile]) -> Vec { - let mut assets = Vec::new(); - - // 1. Fast Path: Scan .idea/libraries if available (IntelliJ projects) - self.discover_from_idea(files, &mut assets); - - // 2. Accurate Path: Scan Gradle cache based on parsed dependencies - self.discover_from_gradle_cache(files, &mut assets); - - assets.sort(); - assets.dedup(); - assets - } - - fn discover_from_idea(&self, files: &[&ParsedFile], assets: &mut Vec) { - let Some(first_file) = files.first() else { - return; - }; - - let mut current = first_file.file.path.clone(); - while let Some(parent) = current.parent() { - let idea_libs = parent.join(".idea/libraries"); - if idea_libs.exists() { - if let Ok(entries) = std::fs::read_dir(idea_libs) { - for entry in entries.flatten() { - self.parse_idea_lib_xml(&entry.path(), assets); - } - } - break; - } - current = parent.to_path_buf(); - } - } - - fn parse_idea_lib_xml(&self, path: &Path, assets: &mut Vec) { - if path.extension().and_then(|e| e.to_str()) != Some("xml") { - return; - } - - let Ok(content) = std::fs::read_to_string(path) else { - return; - }; - - for line in content.lines() { - let Some(jar_path) = self.extract_jar_path_from_xml_line(line) else { - continue; - }; - - if jar_path.exists() && jar_path.extension().and_then(|e| e.to_str()) == Some("jar") { - assets.push(jar_path); - } - } - } - - fn extract_jar_path_from_xml_line(&self, line: &str) -> Option { - if !line.contains("url=\"jar://") || !line.contains("!/\"") { - return None; - } - - let start = line.find("jar://")? + 6; - let end = line.find("!/\"")?; - let path_str = &line[start..end]; - - if path_str.contains("$USER_HOME$") { - dirs::home_dir().map(|home| { - PathBuf::from(path_str.replace("$USER_HOME$", home.to_str().unwrap_or(""))) - }) - } else { - Some(PathBuf::from(path_str)) - } - } - - fn discover_from_gradle_cache(&self, files: &[&ParsedFile], assets: &mut Vec) { - let Some(mut cache_path) = dirs::home_dir() else { - return; - }; - cache_path.push(".gradle/caches/modules-2/files-2.1"); - - if !cache_path.exists() { - return; - } - - for file in files { - let ParsedContent::Metadata(value) = &file.content else { - continue; - }; - - let Ok(res) = serde_json::from_value::(value.clone()) - else { - continue; - }; - - for dep in res.dependencies { - if dep.is_project { - continue; - } - if let (Some(group), Some(version)) = (dep.group, dep.version) { - let dep_dir = cache_path.join(&group).join(&dep.name).join(&version); - if dep_dir.exists() { - self.collect_jars_from_dir(&dep_dir, assets); - } - } - } - } - } - - fn collect_jars_from_dir(&self, dir: &Path, assets: &mut Vec) { - let Ok(entries) = std::fs::read_dir(dir) else { - return; - }; - - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - self.collect_jars_from_dir(&path, assets); - } else if path.extension().and_then(|e| e.to_str()) == Some("jar") { - let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - if !name.ends_with("-sources.jar") && !name.ends_with("-javadoc.jar") { - assets.push(path); - } - } - } - } } impl BuildResolver for GradleResolver { @@ -274,10 +31,6 @@ impl BuildResolver for GradleResolver { let mut unit = ResolvedUnit::new(); let mut context = ProjectContext::new(); - // --- Step 0: Discover environment (Builtin & External) --- - context.builtin_assets = self.discover_builtin_assets(); - context.external_assets = self.discover_external_assets(files); - // --- Step 1: Discover all potential module paths --- let mut module_map: HashMap = HashMap::new(); @@ -696,77 +449,4 @@ mod tests { == "project:spring-boot-build::module:spring-boot-project" && t == "project:spring-boot-build::module:spring-boot-project/spring-boot")); } - - #[test] - fn test_discover_external_assets_from_idea() { - let resolver = GradleResolver::new(); - let temp = tempfile::tempdir().unwrap(); - let idea_libs = temp.path().join(".idea/libraries"); - std::fs::create_dir_all(&idea_libs).unwrap(); - - let jar_path = temp.path().join("mock.jar"); - std::fs::File::create(&jar_path).unwrap(); - - let lib_xml = idea_libs.join("mock_lib.xml"); - let xml_content = format!( - r#" - - - - - - - -"#, - jar_path.display() - ); - std::fs::write(&lib_xml, xml_content).unwrap(); - - let mock_file = create_mock_file( - temp.path().join("build.gradle").to_str().unwrap(), - ParsedContent::Unparsed("".to_string()), - ); - let assets: Vec<_> = resolver - .discover_external_assets(&[&mock_file]) - .into_iter() - .map(|p| p.canonicalize().unwrap()) - .collect(); - - assert_eq!(assets.len(), 1); - assert_eq!(assets[0], jar_path.canonicalize().unwrap()); - } - - #[test] - fn test_discover_builtin_assets_java_11() { - let resolver = GradleResolver::new(); - let temp = tempfile::tempdir().unwrap(); - let sdk_path = temp.path().to_path_buf(); - - let modules_path = sdk_path.join("lib/modules"); - std::fs::create_dir_all(modules_path.parent().unwrap()).unwrap(); - std::fs::File::create(&modules_path).unwrap(); - - let mut assets = Vec::new(); - resolver.collect_sdk_assets(&sdk_path, &mut assets); - - assert_eq!(assets.len(), 1); - assert_eq!(assets[0], modules_path); - } - - #[test] - fn test_discover_builtin_assets_java_8() { - let resolver = GradleResolver::new(); - let temp = tempfile::tempdir().unwrap(); - let sdk_path = temp.path().to_path_buf(); - - let rt_jar_path = sdk_path.join("jre/lib/rt.jar"); - std::fs::create_dir_all(rt_jar_path.parent().unwrap()).unwrap(); - std::fs::File::create(&rt_jar_path).unwrap(); - - let mut assets = Vec::new(); - resolver.collect_sdk_assets(&sdk_path, &mut assets); - - assert_eq!(assets.len(), 1); - assert_eq!(assets[0], rt_jar_path); - } } diff --git a/crates/lang-java/Cargo.toml b/crates/lang-java/Cargo.toml index ed1d64f..edefb90 100644 --- a/crates/lang-java/Cargo.toml +++ b/crates/lang-java/Cargo.toml @@ -18,6 +18,8 @@ lasso = { workspace = true } cafebabe = { workspace = true } zip = { workspace = true } ristretto_jimage = { workspace = true } +dirs = { workspace = true } +regex = { workspace = true } [dev-dependencies] naviscope-core = { workspace = true } diff --git a/crates/lang-java/src/discoverer/jdk.rs b/crates/lang-java/src/discoverer/jdk.rs new file mode 100644 index 0000000..deb57c5 --- /dev/null +++ b/crates/lang-java/src/discoverer/jdk.rs @@ -0,0 +1,284 @@ +//! JDK asset discoverer. +//! +//! Discovers JDK standard library assets from: +//! - JAVA_HOME environment variable +//! - macOS java_home tool +//! - Common installation paths +//! - SDKMAN + +use naviscope_plugin::{AssetDiscoverer, AssetEntry, AssetSource}; +use std::path::{Path, PathBuf}; + +/// JDK asset discoverer +pub struct JdkDiscoverer { + /// Cached JDK assets (discovered once) + cached_assets: Vec, +} + +impl JdkDiscoverer { + pub fn new() -> Self { + let mut discoverer = Self { + cached_assets: Vec::new(), + }; + discoverer.discover_jdk(); + discoverer + } + + /// Get the discovered JDK root path (if any) + pub fn jdk_root(&self) -> Option<&Path> { + self.cached_assets.first().map(|e| { + // Navigate up from lib/modules or jre/lib/rt.jar to JDK root + let path = &e.path; + if path.ends_with("lib/modules") { + path.parent().and_then(|p| p.parent()) + } else if path.ends_with("jre/lib/rt.jar") || path.ends_with("lib/rt.jar") { + path.parent() + .and_then(|p| p.parent()) + .and_then(|p| p.parent()) + } else { + // jmods case + path.parent().and_then(|p| p.parent()) + } + .unwrap_or(path.as_path()) + }) + } + + fn discover_jdk(&mut self) { + let mut jdk_root: Option = None; + + // 1. Check JAVA_HOME + if let Ok(java_home) = std::env::var("JAVA_HOME") { + let path = PathBuf::from(&java_home); + if self.collect_sdk_assets(&path).is_some() { + jdk_root = Some(path); + } + } + + // 2. macOS specific: Use java_home tool + #[cfg(target_os = "macos")] + if self.cached_assets.is_empty() { + if let Ok(output) = std::process::Command::new("/usr/libexec/java_home").output() { + if output.status.success() { + let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let path = PathBuf::from(&path_str); + if self.collect_sdk_assets(&path).is_some() { + jdk_root = Some(path); + } + } + } + } + + // 3. Search common installation paths + if self.cached_assets.is_empty() { + let mut search_roots = Vec::new(); + + #[cfg(target_os = "macos")] + { + search_roots.push(PathBuf::from("/Library/Java/JavaVirtualMachines/")); + search_roots.push(PathBuf::from("/opt/homebrew/opt/openjdk/")); + search_roots.push(PathBuf::from("/usr/local/opt/openjdk/")); + } + #[cfg(target_os = "linux")] + { + search_roots.push(PathBuf::from("/usr/lib/jvm/")); + } + #[cfg(target_os = "windows")] + { + search_roots.push(PathBuf::from("C:\\Program Files\\Java\\")); + } + + // SDKMAN + if let Some(mut sdkman) = dirs::home_dir() { + sdkman.push(".sdkman/candidates/java/"); + search_roots.push(sdkman); + } + + for root in search_roots { + if !root.exists() { + continue; + } + + // If root itself is a JDK (e.g. Homebrew symlink) + if self.collect_sdk_assets(&root).is_some() { + jdk_root = Some(root.clone()); + break; + } + + // If root is a parent directory containing multiple SDKs + if let Ok(entries) = std::fs::read_dir(&root) { + for entry in entries.flatten() { + let mut sdk_path = entry.path(); + if cfg!(target_os = "macos") && sdk_path.join("Contents/Home").exists() { + sdk_path = sdk_path.join("Contents/Home"); + } + if self.collect_sdk_assets(&sdk_path).is_some() { + jdk_root = Some(sdk_path); + break; + } + } + } + + if !self.cached_assets.is_empty() { + break; + } + } + } + + // Update all entries with JDK source info + if let Some(root) = jdk_root { + let version = self.detect_jdk_version(&root); + for entry in &mut self.cached_assets { + entry.source = AssetSource::Jdk { + version: version.clone(), + path: root.clone(), + }; + } + } + } + + fn detect_jdk_version(&self, jdk_root: &Path) -> Option { + // Try to read release file + let release_file = jdk_root.join("release"); + if let Ok(content) = std::fs::read_to_string(&release_file) { + for line in content.lines() { + if line.starts_with("JAVA_VERSION=") { + let version = line + .trim_start_matches("JAVA_VERSION=") + .trim_matches('"') + .to_string(); + return Some(version); + } + } + } + + // Fallback: try to extract from path + let path_str = jdk_root.to_string_lossy(); + if let Some(cap) = regex::Regex::new(r"jdk-?(\d+(?:\.\d+)*)") + .ok() + .and_then(|re| re.captures(&path_str)) + { + return cap.get(1).map(|m| m.as_str().to_string()); + } + + None + } + + fn collect_sdk_assets(&mut self, sdk_path: &Path) -> Option<()> { + if !sdk_path.exists() { + return None; + } + + // Priority 1: Java 9+ Runtime Image (The most correct way for modern Java) + let modules = sdk_path.join("lib/modules"); + if modules.exists() { + self.cached_assets.push(AssetEntry::unknown(modules)); + return Some(()); + } + + // Priority 2: Java 8 Legacy Runtime + let rt_jar = sdk_path.join("jre/lib/rt.jar"); + if rt_jar.exists() { + self.cached_assets.push(AssetEntry::unknown(rt_jar)); + return Some(()); + } + let lib_rt_jar = sdk_path.join("lib/rt.jar"); + if lib_rt_jar.exists() { + self.cached_assets.push(AssetEntry::unknown(lib_rt_jar)); + return Some(()); + } + + // Priority 3: jmods (Fallback for some JDK builds without lib/modules) + let jmods = sdk_path.join("jmods"); + if jmods.exists() { + if let Ok(entries) = std::fs::read_dir(&jmods) { + let mut found = false; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("jmod") { + self.cached_assets.push(AssetEntry::unknown(path)); + found = true; + } + } + if found { + return Some(()); + } + } + } + + None + } +} + +impl Default for JdkDiscoverer { + fn default() -> Self { + Self::new() + } +} + +impl AssetDiscoverer for JdkDiscoverer { + fn discover(&self) -> Box + Send + '_> { + Box::new(self.cached_assets.iter().cloned()) + } + + fn name(&self) -> &str { + "JDK Discoverer" + } + + fn source_type(&self) -> &str { + "jdk" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_jdk_discoverer_creation() { + let discoverer = JdkDiscoverer::new(); + // Should not panic, may or may not find JDK depending on environment + let assets: Vec<_> = discoverer.discover().collect(); + println!("Found {} JDK assets", assets.len()); + for asset in &assets { + println!(" - {:?} from {:?}", asset.path, asset.source); + } + } + + #[test] + fn test_collect_sdk_assets_java_11() { + let temp = tempfile::tempdir().unwrap(); + let sdk_path = temp.path().to_path_buf(); + + let modules_path = sdk_path.join("lib/modules"); + std::fs::create_dir_all(modules_path.parent().unwrap()).unwrap(); + std::fs::File::create(&modules_path).unwrap(); + + let mut discoverer = JdkDiscoverer { + cached_assets: Vec::new(), + }; + let result = discoverer.collect_sdk_assets(&sdk_path); + + assert!(result.is_some()); + assert_eq!(discoverer.cached_assets.len(), 1); + assert_eq!(discoverer.cached_assets[0].path, modules_path); + } + + #[test] + fn test_collect_sdk_assets_java_8() { + let temp = tempfile::tempdir().unwrap(); + let sdk_path = temp.path().to_path_buf(); + + let rt_jar_path = sdk_path.join("jre/lib/rt.jar"); + std::fs::create_dir_all(rt_jar_path.parent().unwrap()).unwrap(); + std::fs::File::create(&rt_jar_path).unwrap(); + + let mut discoverer = JdkDiscoverer { + cached_assets: Vec::new(), + }; + let result = discoverer.collect_sdk_assets(&sdk_path); + + assert!(result.is_some()); + assert_eq!(discoverer.cached_assets.len(), 1); + assert_eq!(discoverer.cached_assets[0].path, rt_jar_path); + } +} diff --git a/crates/lang-java/src/discoverer/mod.rs b/crates/lang-java/src/discoverer/mod.rs new file mode 100644 index 0000000..3f34460 --- /dev/null +++ b/crates/lang-java/src/discoverer/mod.rs @@ -0,0 +1,5 @@ +//! Asset discoverers for Java ecosystem. + +mod jdk; + +pub use jdk::JdkDiscoverer; diff --git a/crates/lang-java/src/lib.rs b/crates/lang-java/src/lib.rs index 930bf1a..ab5acb3 100644 --- a/crates/lang-java/src/lib.rs +++ b/crates/lang-java/src/lib.rs @@ -1,3 +1,4 @@ +pub mod discoverer; pub mod jdk; pub mod model; pub mod naming; @@ -5,13 +6,15 @@ pub mod parser; pub mod queries; pub mod resolver; +pub use discoverer::JdkDiscoverer; + use lasso::Key; use naviscope_api::models::graph::{EmptyMetadata, GraphNode, NodeKind}; use naviscope_api::models::symbol::{FqnReader, Symbol, SymbolResolution}; use naviscope_api::models::{DisplayGraphNode, Language}; use naviscope_plugin::{ - GlobalParseResult, LangResolver, LanguagePlugin, LspParser, NamingConvention, NodeAdapter, - PluginInstance, SemanticResolver, StorageContext, + AssetIndexer, GlobalParseResult, LangResolver, LanguagePlugin, LspParser, NamingConvention, + NodeAdapter, PluginInstance, SemanticResolver, StorageContext, }; use std::path::Path; use std::sync::Arc; @@ -30,6 +33,7 @@ impl NodeAdapter for JavaPlugin { kind: node.kind.clone(), lang: "java".to_string(), source: node.source.clone(), + status: node.status, location: node.location.as_ref().map(|l| l.to_display(fqns)), detail: None, signature: None, @@ -294,6 +298,14 @@ impl LanguagePlugin for JavaPlugin { fn can_handle_external_asset(&self, ext: &str) -> bool { ext == "jar" || ext == "jmod" || ext == "class" } + + fn global_asset_discoverer(&self) -> Option> { + Some(Box::new(crate::discoverer::JdkDiscoverer::new())) + } + + fn asset_indexer(&self) -> Option> { + Some(Arc::new(crate::resolver::external::JavaExternalResolver)) + } } impl LspParser for JavaPlugin { diff --git a/crates/lang-java/src/resolver/external/mod.rs b/crates/lang-java/src/resolver/external/mod.rs index 1ffb83e..4d87074 100644 --- a/crates/lang-java/src/resolver/external/mod.rs +++ b/crates/lang-java/src/resolver/external/mod.rs @@ -1,4 +1,4 @@ -use naviscope_plugin::{ExternalResolver, GlobalParseResult, IndexNode}; +use naviscope_plugin::{AssetIndexer, ExternalResolver, GlobalParseResult, IndexNode}; use ristretto_jimage::Image; use std::collections::HashSet; use std::fs::File; @@ -38,7 +38,19 @@ impl JavaExternalResolver { for resource_result in image.iter() { if let Ok(resource) = resource_result { if resource.extension() == "class" && !resource.base().contains('$') { - let package = resource.parent().replace('/', "."); + let parent = resource.parent(); + let path_without_module = if parent.starts_with('/') { + let s = &parent[1..]; + if let Some(idx) = s.find('/') { + &s[idx + 1..] + } else { + s + } + } else { + &parent + }; + + let package = path_without_module.replace('/', "."); if !package.is_empty() { packages.insert(package); } @@ -139,7 +151,8 @@ impl ExternalResolver for JavaExternalResolver { // Since we don't know the module, we search all modules for resource_result in image.iter() { if let Ok(resource) = resource_result { - if resource.name() == class_path { + let name = resource.name(); + if name == class_path || name.ends_with(&format!("/{}", class_path)) { bytes = Some(resource.data().to_vec()); break; } @@ -159,7 +172,10 @@ impl ExternalResolver for JavaExternalResolver { for resource_result in image.iter() { if let Ok(resource) = resource_result { - if resource.name() == try_inner_path { + let name = resource.name(); + if name == try_inner_path + || name.ends_with(&format!("/{}", try_inner_path)) + { bytes = Some(resource.data().to_vec()); break; } @@ -295,6 +311,26 @@ impl ExternalResolver for JavaExternalResolver { } } +impl AssetIndexer for JavaExternalResolver { + fn can_index(&self, asset: &Path) -> bool { + let ext = asset + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("") + .to_lowercase(); + let file_name = asset.file_name().and_then(|n| n.to_str()).unwrap_or(""); + + ext == "jar" || ext == "jmod" || file_name == "modules" + } + + fn index( + &self, + asset: &Path, + ) -> std::result::Result, Box> { + ExternalResolver::index_asset(self, asset) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/lang-java/src/resolver/mod.rs b/crates/lang-java/src/resolver/mod.rs index 521aa8a..a1b113d 100644 --- a/crates/lang-java/src/resolver/mod.rs +++ b/crates/lang-java/src/resolver/mod.rs @@ -323,28 +323,6 @@ impl LangResolver for JavaResolver { let mut unit = ResolvedUnit::new(); let dummy_index = naviscope_plugin::EmptyCodeGraph; - // Route standardized Java packages to the local JDK asset if found - static JDK_PATH: std::sync::OnceLock> = - std::sync::OnceLock::new(); - if let Some(jdk_path) = JDK_PATH.get_or_init(|| crate::jdk::find_jdk_asset()) { - let prefixes = [ - "java", - "javax", - "jdk", - "sun", - "com.sun", - "org.xml.sax", - "org.w3c.dom", - "org.ietf.jgss", - ]; - let mut routes = std::collections::HashMap::new(); - for prefix in prefixes { - routes.insert(prefix.to_string(), vec![jdk_path.clone()]); - } - - unit.ops.push(GraphOp::UpdateAssetRoutes { routes }); - } - let parse_result_owned; let parse_result = match &file.content { ParsedContent::Language(res) => res, diff --git a/crates/plugin/src/asset.rs b/crates/plugin/src/asset.rs new file mode 100644 index 0000000..3928bd6 --- /dev/null +++ b/crates/plugin/src/asset.rs @@ -0,0 +1,179 @@ +//! Asset and Stub trait definitions for the Global Asset Scanner architecture. +//! +//! This module defines the core abstractions for: +//! - Asset discovery (finding JAR files, JDK modules, etc.) +//! - Asset indexing (extracting package prefixes from assets) +//! - Stub generation (creating type stubs from bytecode) +//! - Route registry (mapping FQNs to asset paths) + +use crate::model::IndexNode; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +/// Error type for asset operations +pub type BoxError = Box; + +// ==================== Asset Source ==================== + +/// Asset source type - where the asset comes from +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum AssetSource { + /// JDK standard library (lib/modules, rt.jar, jmods) + Jdk { + version: Option, // e.g. "17.0.1" + path: PathBuf, // JDK root path + }, + + /// Gradle cache + Gradle { + group: String, // e.g. "io.netty" + artifact: String, // e.g. "netty-common" + version: String, // e.g. "4.1.100.Final" + }, + + /// Maven local repository + Maven { + group: String, + artifact: String, + version: String, + }, + + /// Project local dependency (lib/*.jar) + Local { project_path: PathBuf }, + + /// Unknown source + Unknown, +} + +impl AssetSource { + /// Get the source type as a string (for statistics and filtering) + pub fn source_type(&self) -> &'static str { + match self { + AssetSource::Jdk { .. } => "jdk", + AssetSource::Gradle { .. } => "gradle", + AssetSource::Maven { .. } => "maven", + AssetSource::Local { .. } => "local", + AssetSource::Unknown => "unknown", + } + } +} + +/// Asset entry with source metadata +#[derive(Debug, Clone)] +pub struct AssetEntry { + pub path: PathBuf, + pub source: AssetSource, +} + +impl AssetEntry { + pub fn new(path: PathBuf, source: AssetSource) -> Self { + Self { path, source } + } + + pub fn unknown(path: PathBuf) -> Self { + Self { + path, + source: AssetSource::Unknown, + } + } +} + +// ==================== Asset Layer ==================== + +/// Asset discoverer - knows where to find assets +/// Uses Iterator pattern for streaming (constant memory) +pub trait AssetDiscoverer: Send + Sync { + /// Returns an asset iterator (streaming, does not load all at once) + fn discover(&self) -> Box + Send + '_>; + + /// Discoverer name (for logging/debugging) + fn name(&self) -> &str; + + /// Default source type for this discoverer + fn source_type(&self) -> &str; +} + +/// Asset indexer - knows how to read asset internal structure +/// Returns package prefixes found in the asset +pub trait AssetIndexer: Send + Sync { + /// Check if this indexer can handle the asset + fn can_index(&self, asset: &Path) -> bool; + + /// Extract package prefixes from the asset + /// Returns a list of package prefixes (e.g., "java.lang", "io.netty.channel") + fn index(&self, asset: &Path) -> Result, BoxError>; +} + +/// Asset route registry - manages prefix → AssetEntry[] mapping +pub trait AssetRouteRegistry: Send + Sync { + /// Register a route (with source info) + fn register(&self, prefix: String, entry: AssetEntry); + + /// Query asset entries for an FQN (with source info) + fn lookup(&self, fqn: &str) -> Option>; + + /// Query by source type + fn lookup_by_source(&self, fqn: &str, source_type: &str) -> Option>; + + /// Get all routes (for serialization) + fn all_routes(&self) -> HashMap>; + + /// Get statistics + fn stats(&self) -> RegistryStats; +} + +/// Registry statistics +#[derive(Debug, Default, Clone)] +pub struct RegistryStats { + pub total_prefixes: usize, + pub total_entries: usize, + pub by_source: HashMap, // e.g. {"jdk": 100, "gradle": 5000} +} + +// ==================== Stub Layer ==================== + +/// Stub generator - knows how to generate type info from asset +/// Implemented by language plugins (e.g. JavaExternalResolver) +pub trait StubGenerator: Send + Sync { + /// Check if this generator can handle the asset + fn can_generate(&self, asset: &Path) -> bool; + + /// Generate stub for the specified FQN from asset + /// source is used to set the source info on generated nodes + fn generate(&self, fqn: &str, entry: &AssetEntry) -> Result; +} + +/// Stub request (with source info) +#[derive(Debug, Clone)] +pub struct StubRequest { + pub fqn: String, + pub candidate_entries: Vec, // candidates with source info +} + +impl StubRequest { + pub fn new(fqn: String, candidate_entries: Vec) -> Self { + Self { + fqn, + candidate_entries, + } + } + + /// Create from legacy format (paths only) + pub fn from_paths(fqn: String, paths: Vec) -> Self { + Self { + fqn, + candidate_entries: paths.into_iter().map(AssetEntry::unknown).collect(), + } + } +} + +/// Stub request sender (producer side) +pub trait StubRequestSender: Send + Sync { + fn send(&self, request: StubRequest); +} + +/// Stub request receiver (consumer side) +pub trait StubRequestReceiver: Send { + fn recv(&mut self) -> Option; +} diff --git a/crates/plugin/src/converter.rs b/crates/plugin/src/converter.rs index b70e35e..cfd6766 100644 --- a/crates/plugin/src/converter.rs +++ b/crates/plugin/src/converter.rs @@ -30,7 +30,7 @@ impl ModelConverter for DisplayGraphNode { kind: self.kind.clone(), lang: interner.intern_atom(&self.lang), source: self.source.clone(), - status: naviscope_api::models::graph::ResolutionStatus::Resolved, // Default for display nodes + status: self.status, location: self.location.as_ref().map(|l| l.to_internal(interner)), metadata: Arc::new(naviscope_api::models::graph::EmptyMetadata), } diff --git a/crates/plugin/src/graph.rs b/crates/plugin/src/graph.rs index bfcd3ea..bef7bf1 100644 --- a/crates/plugin/src/graph.rs +++ b/crates/plugin/src/graph.rs @@ -25,10 +25,6 @@ pub enum GraphOp { }, /// Update file metadata (hash, mtime) UpdateFile { metadata: SourceFile }, - /// Update the asset routing table: Prefix -> Asset Path - UpdateAssetRoutes { - routes: HashMap>, - }, } /// Result of resolving a single file or unit diff --git a/crates/plugin/src/lib.rs b/crates/plugin/src/lib.rs index 4c45c5b..ab4ae51 100644 --- a/crates/plugin/src/lib.rs +++ b/crates/plugin/src/lib.rs @@ -1,3 +1,4 @@ +pub mod asset; pub mod converter; pub mod graph; pub mod interner; @@ -7,6 +8,7 @@ pub mod plugin; pub mod resolver; pub mod utils; +pub use asset::*; pub use converter::*; pub use graph::*; pub use interner::*; diff --git a/crates/plugin/src/plugin.rs b/crates/plugin/src/plugin.rs index 064addd..43fa686 100644 --- a/crates/plugin/src/plugin.rs +++ b/crates/plugin/src/plugin.rs @@ -94,6 +94,25 @@ pub trait LanguagePlugin: PluginInstance + Send + Sync { fn can_handle_external_asset(&self, _ext: &str) -> bool { false } + + /// Get the asset indexer for this language (Global Asset Scanner architecture) + fn asset_indexer(&self) -> Option> { + None + } + + /// Get the asset discoverer for this language (e.g., JdkDiscoverer for Java) + fn global_asset_discoverer(&self) -> Option> { + None + } + + /// Get the project-local asset discoverer for this language (optional hook). + /// Use this for assets that exist only inside the current project (e.g. build outputs). + fn project_asset_discoverer( + &self, + _project_root: &Path, + ) -> Option> { + None + } } /// Unified interface for build tool support. @@ -112,4 +131,18 @@ pub trait BuildToolPlugin: PluginInstance + Send + Sync { /// Get the build resolver fn build_resolver(&self) -> Arc; + + /// Get the asset discoverer for this build tool (e.g., GradleCacheDiscoverer) + fn asset_discoverer(&self) -> Option> { + None + } + + /// Get the project-local asset discoverer for this build tool (optional hook). + /// Use this for assets that exist only inside the current project (e.g. libs/*.jar). + fn project_asset_discoverer( + &self, + _project_root: &Path, + ) -> Option> { + None + } } diff --git a/crates/plugin/src/resolver.rs b/crates/plugin/src/resolver.rs index af3c328..1720921 100644 --- a/crates/plugin/src/resolver.rs +++ b/crates/plugin/src/resolver.rs @@ -65,23 +65,12 @@ pub struct ProjectContext { /// Mapping from path prefixes to module IDs (e.g., "/project/app" -> "module::app") pub path_to_module: HashMap, - /// Third-party library asset paths (JARs, node_modules, etc.) - pub external_assets: Vec, - - /// SDK/Runtime asset paths (Builtin libs) - pub builtin_assets: Vec, - - /// Mapping from symbol prefixes (e.g. "java.lang") to asset paths - pub asset_routes: HashMap>, } impl ProjectContext { pub fn new() -> Self { Self { path_to_module: HashMap::new(), - external_assets: Vec::new(), - builtin_assets: Vec::new(), - asset_routes: HashMap::new(), } } diff --git a/crates/plugin/src/utils.rs b/crates/plugin/src/utils.rs index 29f21c8..690d723 100644 --- a/crates/plugin/src/utils.rs +++ b/crates/plugin/src/utils.rs @@ -54,6 +54,7 @@ pub fn build_symbol_hierarchy(raw_symbols: Vec) -> Vec Date: Sat, 7 Feb 2026 20:57:20 +0800 Subject: [PATCH 06/49] feat: Introduce AssetSourceLocator for mapping binary assets to their source assets and integrate it into the asset service and language plugins. --- crates/core/src/asset/service.rs | 87 +++++++++++++++++-- crates/core/src/runtime/orchestrator.rs | 16 +++- crates/lang-java/src/lib.rs | 8 +- crates/lang-java/src/resolver/external/mod.rs | 31 ++++++- crates/plugin/src/asset.rs | 5 ++ crates/plugin/src/plugin.rs | 10 +++ 6 files changed, 146 insertions(+), 11 deletions(-) diff --git a/crates/core/src/asset/service.rs b/crates/core/src/asset/service.rs index d1b869e..609a007 100644 --- a/crates/core/src/asset/service.rs +++ b/crates/core/src/asset/service.rs @@ -8,13 +8,14 @@ use crate::asset::registry::InMemoryRouteRegistry; use crate::asset::scanner::{AssetScanner, ScanResult}; use naviscope_plugin::{ - AssetDiscoverer, AssetEntry, AssetIndexer, AssetRouteRegistry, RegistryStats, StubGenerator, - StubRequest, + AssetDiscoverer, AssetEntry, AssetIndexer, AssetRouteRegistry, AssetSourceLocator, + RegistryStats, StubGenerator, StubRequest, }; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::mpsc; +use tokio::sync::RwLock; use tokio::task::JoinHandle; use tracing::debug; @@ -31,6 +32,12 @@ pub struct AssetStubService { /// Channel for stub requests stub_tx: Option>, + + /// Mapping from binary asset path to source asset path (if available) + source_map: Arc>>, + + /// Source locators (from language/build plugins) + source_locators: Vec>, } impl AssetStubService { @@ -39,6 +46,7 @@ impl AssetStubService { discoverers: Vec>, indexers: Vec>, generators: Vec>, + source_locators: Vec>, ) -> Self { let scanner = AssetScanner::new() .with_discoverers(discoverers) @@ -49,6 +57,8 @@ impl AssetStubService { scanner, generators, stub_tx: None, + source_map: Arc::new(RwLock::new(HashMap::new())), + source_locators, } } @@ -58,6 +68,7 @@ impl AssetStubService { discoverers: Vec>, indexers: Vec>, generators: Vec>, + source_locators: Vec>, ) -> Self { let scanner = AssetScanner::new() .with_discoverers(discoverers) @@ -68,6 +79,8 @@ impl AssetStubService { scanner, generators, stub_tx: None, + source_map: Arc::new(RwLock::new(HashMap::new())), + source_locators, } } @@ -84,17 +97,27 @@ impl AssetStubService { /// Perform a synchronous scan (blocks until complete) pub fn scan_sync(&self) -> ScanResult { - self.scanner.scan(self.registry.as_ref()) + let result = self.scanner.scan(self.registry.as_ref()); + self.refresh_source_map(); + result } /// Start a background scan task pub fn spawn_scan(&self) -> JoinHandle { let registry = self.registry.clone(); let scanner = self.build_scanner_clone(); + let source_map = self.source_map.clone(); tokio::spawn(async move { // Run scan in blocking thread pool - tokio::task::spawn_blocking(move || scanner.scan(registry.as_ref())) + tokio::task::spawn_blocking(move || { + let result = scanner.scan(registry.as_ref()); + let map = Self::build_source_map(registry.as_ref(), &source_locators); + if let Ok(mut guard) = source_map.try_write() { + *guard = map; + } + result + }) .await .unwrap_or_default() }) @@ -112,6 +135,14 @@ impl AssetStubService { .map(|entries| entries.into_iter().map(|e| e.path).collect()) } + /// Lookup source asset for a binary asset path + pub fn lookup_source(&self, binary_path: &std::path::Path) -> Option { + self.source_map + .try_read() + .ok() + .and_then(|map| map.get(binary_path).cloned()) + } + /// Request stub generation (async, non-blocking) pub fn request_stub(&self, fqn: String, candidate_entries: Vec) { if let Some(tx) = &self.stub_tx { @@ -133,6 +164,14 @@ impl AssetStubService { .collect() } + /// Refresh source map using discovered binary assets + pub fn refresh_source_map(&self) { + let map = Self::build_source_map(self.registry.as_ref(), &self.source_locators); + if let Ok(mut guard) = self.source_map.try_write() { + *guard = map; + } + } + /// Get registry statistics pub fn stats(&self) -> RegistryStats { self.registry.stats() @@ -158,6 +197,28 @@ impl AssetStubService { // need to use a different approach (e.g., Arc or factory pattern) AssetScanner::new() } + + fn build_source_map( + registry: &InMemoryRouteRegistry, + locators: &[Arc], + ) -> HashMap { + let mut map = HashMap::new(); + let mut seen = HashSet::new(); + for entries in registry.all_routes().values() { + for entry in entries { + if !seen.insert(entry.path.clone()) { + continue; + } + for locator in locators { + if let Some(source) = locator.locate_source(entry) { + map.insert(entry.path.clone(), source); + break; + } + } + } + } + map + } } /// Builder for AssetStubService @@ -165,6 +226,7 @@ pub struct AssetStubServiceBuilder { discoverers: Vec>, indexers: Vec>, generators: Vec>, + source_locators: Vec>, registry: Option>, stub_tx: Option>, } @@ -175,6 +237,7 @@ impl AssetStubServiceBuilder { discoverers: Vec::new(), indexers: Vec::new(), generators: Vec::new(), + source_locators: Vec::new(), registry: None, stub_tx: None, } @@ -195,6 +258,11 @@ impl AssetStubServiceBuilder { self } + pub fn add_source_locator(mut self, locator: Arc) -> Self { + self.source_locators.push(locator); + self + } + pub fn with_registry(mut self, registry: Arc) -> Self { self.registry = Some(registry); self @@ -212,9 +280,15 @@ impl AssetStubServiceBuilder { self.discoverers, self.indexers, self.generators, + self.source_locators, ) } else { - AssetStubService::new(self.discoverers, self.indexers, self.generators) + AssetStubService::new( + self.discoverers, + self.indexers, + self.generators, + self.source_locators, + ) }; if let Some(tx) = self.stub_tx { @@ -274,6 +348,7 @@ mod tests { vec![Box::new(MockDiscoverer)], vec![Arc::new(MockIndexer)], vec![], + vec![], ); let result = service.scan_sync(); diff --git a/crates/core/src/runtime/orchestrator.rs b/crates/core/src/runtime/orchestrator.rs index 042ad68..7b84c08 100644 --- a/crates/core/src/runtime/orchestrator.rs +++ b/crates/core/src/runtime/orchestrator.rs @@ -7,7 +7,7 @@ use crate::ingest::resolver::{IndexResolver, StubRequest, StubbingManager}; use crate::ingest::scanner::Scanner; use crate::model::CodeGraph; use crate::model::GraphOp; -use naviscope_plugin::{AssetDiscoverer, AssetIndexer, NamingConvention}; +use naviscope_plugin::{AssetDiscoverer, AssetIndexer, AssetSourceLocator, NamingConvention}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -121,6 +121,19 @@ impl NaviscopeEngineBuilder { } } + // Collect asset source locators from all plugins + let mut source_locators: Vec> = Vec::new(); + for plugin in &self.lang_plugins { + if let Some(locator) = plugin.asset_source_locator() { + source_locators.push(locator); + } + } + for plugin in &self.build_plugins { + if let Some(locator) = plugin.asset_source_locator() { + source_locators.push(locator); + } + } + // Project-local asset discoverers (optional hook) for plugin in &self.lang_plugins { if let Some(d) = plugin.project_asset_discoverer(&canonical_root) { @@ -140,6 +153,7 @@ impl NaviscopeEngineBuilder { discoverers, indexers, vec![], // Generators will be added later + source_locators, ))) } else { None diff --git a/crates/lang-java/src/lib.rs b/crates/lang-java/src/lib.rs index ab5acb3..2121d0c 100644 --- a/crates/lang-java/src/lib.rs +++ b/crates/lang-java/src/lib.rs @@ -13,8 +13,8 @@ use naviscope_api::models::graph::{EmptyMetadata, GraphNode, NodeKind}; use naviscope_api::models::symbol::{FqnReader, Symbol, SymbolResolution}; use naviscope_api::models::{DisplayGraphNode, Language}; use naviscope_plugin::{ - AssetIndexer, GlobalParseResult, LangResolver, LanguagePlugin, LspParser, NamingConvention, - NodeAdapter, PluginInstance, SemanticResolver, StorageContext, + AssetIndexer, AssetSourceLocator, GlobalParseResult, LangResolver, LanguagePlugin, LspParser, + NamingConvention, NodeAdapter, PluginInstance, SemanticResolver, StorageContext, }; use std::path::Path; use std::sync::Arc; @@ -306,6 +306,10 @@ impl LanguagePlugin for JavaPlugin { fn asset_indexer(&self) -> Option> { Some(Arc::new(crate::resolver::external::JavaExternalResolver)) } + + fn asset_source_locator(&self) -> Option> { + Some(Arc::new(crate::resolver::external::JavaExternalResolver)) + } } impl LspParser for JavaPlugin { diff --git a/crates/lang-java/src/resolver/external/mod.rs b/crates/lang-java/src/resolver/external/mod.rs index 4d87074..46cde85 100644 --- a/crates/lang-java/src/resolver/external/mod.rs +++ b/crates/lang-java/src/resolver/external/mod.rs @@ -1,9 +1,12 @@ -use naviscope_plugin::{AssetIndexer, ExternalResolver, GlobalParseResult, IndexNode}; +use naviscope_plugin::{ + AssetEntry, AssetIndexer, AssetSource, AssetSourceLocator, ExternalResolver, GlobalParseResult, + IndexNode, +}; use ristretto_jimage::Image; use std::collections::HashSet; use std::fs::File; use std::io::Read; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; use zip::ZipArchive; @@ -331,6 +334,30 @@ impl AssetIndexer for JavaExternalResolver { } } +impl AssetSourceLocator for JavaExternalResolver { + fn locate_source(&self, entry: &AssetEntry) -> Option { + if !matches!( + entry.source, + AssetSource::Gradle { .. } | AssetSource::Maven { .. } | AssetSource::Local { .. } + ) { + return None; + } + let file_name = entry.path.file_name()?.to_string_lossy(); + if !file_name.ends_with(".jar") || file_name.ends_with("-sources.jar") { + return None; + } + let mut source_name = file_name.to_string(); + source_name.truncate(source_name.len() - 4); + source_name.push_str("-sources.jar"); + let source_path = entry.path.with_file_name(source_name); + if source_path.exists() { + Some(source_path) + } else { + None + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/plugin/src/asset.rs b/crates/plugin/src/asset.rs index 3928bd6..d7d90f3 100644 --- a/crates/plugin/src/asset.rs +++ b/crates/plugin/src/asset.rs @@ -123,6 +123,11 @@ pub trait AssetRouteRegistry: Send + Sync { fn stats(&self) -> RegistryStats; } +/// Asset source locator - maps a binary asset to its source asset if available +pub trait AssetSourceLocator: Send + Sync { + fn locate_source(&self, entry: &AssetEntry) -> Option; +} + /// Registry statistics #[derive(Debug, Default, Clone)] pub struct RegistryStats { diff --git a/crates/plugin/src/plugin.rs b/crates/plugin/src/plugin.rs index 43fa686..af0b225 100644 --- a/crates/plugin/src/plugin.rs +++ b/crates/plugin/src/plugin.rs @@ -105,6 +105,11 @@ pub trait LanguagePlugin: PluginInstance + Send + Sync { None } + /// Get the asset source locator for this language (optional hook). + fn asset_source_locator(&self) -> Option> { + None + } + /// Get the project-local asset discoverer for this language (optional hook). /// Use this for assets that exist only inside the current project (e.g. build outputs). fn project_asset_discoverer( @@ -137,6 +142,11 @@ pub trait BuildToolPlugin: PluginInstance + Send + Sync { None } + /// Get the asset source locator for this build tool (optional hook). + fn asset_source_locator(&self) -> Option> { + None + } + /// Get the project-local asset discoverer for this build tool (optional hook). /// Use this for assets that exist only inside the current project (e.g. libs/*.jar). fn project_asset_discoverer( From fed526af03fc5c0aaffb18ebb5404ec1407bbb64 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Sat, 7 Feb 2026 22:03:14 +0800 Subject: [PATCH 07/49] add docs --- docs/README.md | 25 +++++++++ docs/architecture/code-graph.md | 37 +++++++++++++ docs/architecture/crate-map.md | 51 ++++++++++++++++++ docs/architecture/indexing-pipeline.md | 34 ++++++++++++ docs/architecture/reference-discovery.md | 24 +++++++++ docs/architecture/system-overview.md | 68 ++++++++++++++++++++++++ docs/build-tools/gradle.md | 29 ++++++++++ docs/design/asset-scanner.md | 31 +++++++++++ docs/design/classpath-three-tier.md | 30 +++++++++++ docs/design/source-resolution.md | 21 ++++++++ docs/design/stub-cache.md | 23 ++++++++ docs/interfaces/cli.md | 20 +++++++ docs/interfaces/lsp.md | 27 ++++++++++ docs/interfaces/mcp.md | 25 +++++++++ docs/language/java.md | 24 +++++++++ docs/plugins/contracts.md | 27 ++++++++++ docs/runtime/orchestrator.md | 24 +++++++++ docs/runtime/services.md | 19 +++++++ docs/storage/persistence.md | 22 ++++++++ docs/vision/product-overview.md | 24 +++++++++ 20 files changed, 585 insertions(+) create mode 100644 docs/README.md create mode 100644 docs/architecture/code-graph.md create mode 100644 docs/architecture/crate-map.md create mode 100644 docs/architecture/indexing-pipeline.md create mode 100644 docs/architecture/reference-discovery.md create mode 100644 docs/architecture/system-overview.md create mode 100644 docs/build-tools/gradle.md create mode 100644 docs/design/asset-scanner.md create mode 100644 docs/design/classpath-three-tier.md create mode 100644 docs/design/source-resolution.md create mode 100644 docs/design/stub-cache.md create mode 100644 docs/interfaces/cli.md create mode 100644 docs/interfaces/lsp.md create mode 100644 docs/interfaces/mcp.md create mode 100644 docs/language/java.md create mode 100644 docs/plugins/contracts.md create mode 100644 docs/runtime/orchestrator.md create mode 100644 docs/runtime/services.md create mode 100644 docs/storage/persistence.md create mode 100644 docs/vision/product-overview.md diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..67f00bd --- /dev/null +++ b/docs/README.md @@ -0,0 +1,25 @@ +# Naviscope Docs + +This directory contains structured documentation outlines for the whole project. + +## Suggested Reading Order +1. `docs/vision/product-overview.md` +2. `docs/architecture/system-overview.md` +3. `docs/architecture/crate-map.md` +4. `docs/architecture/code-graph.md` +5. `docs/architecture/indexing-pipeline.md` +6. `docs/architecture/reference-discovery.md` +7. `docs/design/classpath-three-tier.md` +8. `docs/design/asset-scanner.md` +9. `docs/design/stub-cache.md` +10. `docs/design/source-resolution.md` +11. `docs/runtime/orchestrator.md` +12. `docs/runtime/services.md` +13. `docs/plugins/contracts.md` +14. `docs/language/java.md` +15. `docs/build-tools/gradle.md` +16. `docs/interfaces/cli.md` +17. `docs/interfaces/lsp.md` +18. `docs/interfaces/mcp.md` +19. `docs/storage/persistence.md` +20. `docs/plans/README.md` \ No newline at end of file diff --git a/docs/architecture/code-graph.md b/docs/architecture/code-graph.md new file mode 100644 index 0000000..51eb418 --- /dev/null +++ b/docs/architecture/code-graph.md @@ -0,0 +1,37 @@ +# Code Graph + +## What It Represents +The graph is the unified representation of code semantics. Every symbol is a node, and relationships between symbols are edges. It is the single source of truth for queries across CLI, LSP, and MCP. + +## Node Model +Nodes include packages, types, fields, methods, and variables. Each node has: +- A stable `NodeId` +- A `NodeKind` +- Metadata produced by language plugins +- A source classification (project, external, builtin) + +## Edge Types +- Structural containment (package -> class -> method) +- Inheritance and implementation +- Type usage and annotations +- Build system relationships + +## Indexing and Queries +```mermaid +flowchart TD + Parse[Parse Source] --> Build[Build Nodes and Edges] + Build --> Persist[Persist Graph] + Query[Query Request] --> Resolve[Resolve Context] + Resolve --> Graph[Graph Search] + Graph --> Result[Return Results] +``` + +## Consistency and Versioning +- `NodeId` must be stable across source and bytecode. +- Metadata is versioned for forward compatibility. +- External asset changes invalidate cached stubs. + +## Operational Notes +- The graph is language-agnostic. +- Plugins are responsible for semantic accuracy. +- External symbols are first-class nodes. diff --git a/docs/architecture/crate-map.md b/docs/architecture/crate-map.md new file mode 100644 index 0000000..5206475 --- /dev/null +++ b/docs/architecture/crate-map.md @@ -0,0 +1,51 @@ +# Crate Map + +## Layered Structure +```mermaid +graph TD + API[naviscope-api] + Plugin[naviscope-plugin] + Core[naviscope-core] + Runtime[naviscope-runtime] + Java[naviscope-java] + Gradle[naviscope-gradle] + CLI[naviscope-cli] + LSP[naviscope-lsp] + MCP[naviscope-mcp] + + CLI --> Runtime + LSP --> Runtime + MCP --> Runtime + + Runtime --> Core + Runtime --> Java + Runtime --> Gradle + Runtime --> Plugin + + Java --> Plugin + Gradle --> Plugin + Core --> Plugin + + Plugin --> API + Core --> API + Runtime --> API + Java --> API + Gradle --> API + CLI --> API + LSP --> API + MCP --> API +``` + +## What Each Layer Provides +- **API**: shared models, identifiers, and common traits used across crates. +- **Plugin**: contracts for language/build tool integrations; keeps Core independent. +- **Core**: graph storage, indexing, persistence, and asset services. +- **Runtime**: orchestration, lifecycle, background tasks, and query serving. +- **Language/Build**: concrete strategies (Java parsing, Gradle structure resolution). +- **Interfaces**: CLI/LSP/MCP entry points that expose the same graph. + +## Flow Through Crates +1. Interfaces call Runtime to build an index or run a query. +2. Runtime invokes language/build plugins to parse and resolve structure. +3. Plugins emit nodes/edges into Core Graph. +4. Runtime serves queries from Core Graph and coordinates background stubbing. diff --git a/docs/architecture/indexing-pipeline.md b/docs/architecture/indexing-pipeline.md new file mode 100644 index 0000000..d3ebc90 --- /dev/null +++ b/docs/architecture/indexing-pipeline.md @@ -0,0 +1,34 @@ +# Indexing Pipeline + +## Inputs and Parsing +- Language plugins discover and parse source files. +- Build-tool plugins resolve project/module structure. +- Parsing produces symbol definitions and references. + +## Graph Construction Flow +```mermaid +sequenceDiagram + participant RT as Runtime + participant LG as Language Plugin + participant CG as Core Graph + + RT->>LG: parse files + LG->>CG: emit nodes + LG->>CG: emit edges + RT->>CG: finalize graph +``` + +## Incremental Updates +- File change detection triggers partial re-index. +- Only affected nodes and edges are rebuilt. +- External caches remain stable between runs. + +## Concurrency and Performance +- Parsing and indexing are parallelized. +- Asset scanning runs in the background. +- Queries read from the current graph state without waiting for stubs. + +## Fault Tolerance +- Partial indexing is allowed. +- Errors are isolated per file. +- The system returns best-effort results. diff --git a/docs/architecture/reference-discovery.md b/docs/architecture/reference-discovery.md new file mode 100644 index 0000000..6fb063b --- /dev/null +++ b/docs/architecture/reference-discovery.md @@ -0,0 +1,24 @@ +# Reference Discovery + +## Two-Phase Strategy +```mermaid +flowchart LR + Tokens[Token Index] --> Candidates[Candidate Files] + Candidates --> Parse[Syntax Parse] + Parse --> Matches[Verified References] +``` + +## Meso-Level Filtering +- Build a token-to-file inverted index. +- Cheap coarse filtering reduces search space. +- Keeps reference queries fast on large repos. + +## Micro-Level Validation +- Tree-sitter parsing for precise matches. +- Symbol-aware filtering by scope and type. +- Avoids false positives from raw text search. + +## Practical Flow +1. Token index returns a candidate file set. +2. Each candidate is parsed for real symbol usage. +3. Verified results are returned to the caller. diff --git a/docs/architecture/system-overview.md b/docs/architecture/system-overview.md new file mode 100644 index 0000000..f393b64 --- /dev/null +++ b/docs/architecture/system-overview.md @@ -0,0 +1,68 @@ +# System Overview + +## Purpose +Naviscope builds a unified code knowledge graph that serves both AI agents (MCP) and developer tools (LSP/CLI). The system is designed for fast startup, incremental enrichment, and consistent semantics across interfaces. + +## High-Level Architecture +```mermaid +graph TD + subgraph Interfaces + CLI[CLI] --> Runtime + LSP[LSP Server] --> Runtime + MCP[MCP Server] --> Runtime + end + + subgraph Runtime + Runtime[Naviscope Engine / Orchestrator] + end + + subgraph Language + Java[Java Strategy] + Gradle[Gradle Strategy] + end + + subgraph Core + CoreGraph[Core Graph + Indexing] + Asset[Asset/Stub Service] + end + + subgraph Plugins + Contracts[Plugin Contracts] + end + + Runtime --> CoreGraph + Runtime --> Language + Language --> Contracts + CoreGraph --> Contracts + Runtime --> Asset +``` + +## Main Flow (Index + Query) +```mermaid +sequenceDiagram + participant UI as Interfaces + participant RT as Runtime Orchestrator + participant LG as Language Plugins + participant CG as Core Graph + participant AS as Asset/Stub Service + + UI->>RT: index/project open + RT->>LG: parse + resolve modules + LG->>CG: emit nodes/edges + RT->>AS: scan assets (async) + AS-->>CG: enrich external nodes (stubs) + UI->>RT: query (find/ls/cat) + RT->>CG: graph query + CG-->>UI: results +``` + +## How to Read the System +- The **Runtime Orchestrator** is the control plane: it wires plugins, triggers indexing, and serves queries. +- The **Core Graph** is the single source of truth: all interfaces read from it. +- The **Asset/Stub Service** enriches external symbols without blocking startup. +- **Plugins** supply language/build-tool-specific logic; Core stays language-agnostic. + +## Key Design Expectations +- Indexing should be usable even if external dependencies are still scanning. +- External symbols must be represented as first-class nodes, not special cases. +- The same symbol must have a stable `NodeId` across source and bytecode. diff --git a/docs/build-tools/gradle.md b/docs/build-tools/gradle.md new file mode 100644 index 0000000..fa0a73f --- /dev/null +++ b/docs/build-tools/gradle.md @@ -0,0 +1,29 @@ +# Gradle Integration + +## Purpose +Gradle integration provides project structure and dependency metadata for indexing without relying on executing Gradle builds. + +## Scope and Boundaries +- Resolve modules and project structure +- Delegate external asset discovery to global scanners +- Avoid heavy Gradle invocation during indexing + +## Module Resolution +```mermaid +sequenceDiagram + participant GR as Gradle Plugin + participant RT as Runtime + participant CG as Core Graph + + RT->>GR: resolve project + GR->>CG: emit modules + CG-->>RT: project context +``` + +## Dependency Extraction +- Read Gradle metadata when available +- Prefer static configuration to avoid execution + +## Edge Cases +- Composite builds +- Custom configurations diff --git a/docs/design/asset-scanner.md b/docs/design/asset-scanner.md new file mode 100644 index 0000000..0e2d306 --- /dev/null +++ b/docs/design/asset-scanner.md @@ -0,0 +1,31 @@ +# Asset Scanner + +## Purpose +The asset scanner discovers external libraries (JAR/JMOD/class files) and builds package-prefix routes used by resolvers and stub generators. + +## Core Flow +```mermaid +flowchart TD + D[AssetDiscoverer] --> A[AssetEntry] + A --> I[AssetIndexer] + I --> R[Route Registry] +``` + +## Responsibilities +- Find assets from multiple sources (JDK, Gradle, Maven, local) +- Index assets to extract package prefixes +- Register routes for fast FQN lookup + +## Route Registry Behavior +- Prefix to asset mapping +- Multiple candidates per prefix (split packages) +- Source metadata for debugging and conflict resolution + +## Streaming Scan and Memory Model +- Discoverers yield assets as iterators +- Registry is updated incrementally +- No full asset list held in memory + +## Failure Handling +- Skip unreadable assets +- Keep partial routes and continue scanning diff --git a/docs/design/classpath-three-tier.md b/docs/design/classpath-three-tier.md new file mode 100644 index 0000000..ef49850 --- /dev/null +++ b/docs/design/classpath-three-tier.md @@ -0,0 +1,30 @@ +# Classpath Three-Tier Design + +## Goal +Balance instant usability with deep understanding of external dependencies by layering the resolution strategy. + +## Three Tiers +```mermaid +flowchart TB + T1[Shallow Index] --> T2[Background Stub] + T2 --> T3[On-Demand Source] +``` + +## What Each Tier Does +- **Shallow Index**: project source is indexed immediately; external symbols become placeholders. +- **Background Stub**: bytecode is parsed to enrich external placeholders. +- **On-Demand Source**: `*-sources.jar` is parsed when a user requests source. + +## Build vs Query Timeline +- Build: project graph + external placeholders +- Runtime: background stubs fill metadata +- Query: source resolution for precise navigation + +## Contracts +- Placeholder nodes must be upgradable. +- `NodeId` must be identical across source and bytecode. +- Cache invalidates on asset changes. + +## Practical Notes +- Prioritize referenced external symbols. +- Never block initial navigation on external scans. diff --git a/docs/design/source-resolution.md b/docs/design/source-resolution.md new file mode 100644 index 0000000..a7afafe --- /dev/null +++ b/docs/design/source-resolution.md @@ -0,0 +1,21 @@ +# Source Resolution + +## Purpose +Source resolution attaches real source code to external symbols at query time, improving navigation and documentation visibility. + +## Flow +```mermaid +flowchart TD + Query[Go to Definition] --> Lookup[Locate sources.jar] + Lookup --> Parse[Parse Source] + Parse --> Return[Return Rich Source] +``` + +## Relationship to Stubs +- Stubs provide structure and fast navigation +- Source parsing adds comments and full body +- Fallback to stubs when sources are missing + +## UX Notes +- On-demand only to avoid startup cost +- Clear messaging when sources are unavailable diff --git a/docs/design/stub-cache.md b/docs/design/stub-cache.md new file mode 100644 index 0000000..e8729f3 --- /dev/null +++ b/docs/design/stub-cache.md @@ -0,0 +1,23 @@ +# Stub Cache + +## Purpose +The stub cache stores parsed external symbol metadata so that repeated projects can reuse the same external knowledge without re-parsing bytecode. + +## Flow +```mermaid +flowchart LR + Asset[Asset File] --> Hash[Fingerprint] + Hash --> Cache[Stub Cache] + Cache --> Load[Load Stubs] + Asset --> Change{Asset Changed?} + Change -- yes --> Invalidate[Invalidate Cache] +``` + +## Key Behaviors +- Cache key is asset fingerprint +- Cached payload is compressed and versioned +- Stubs are shared across projects + +## Invalidation +- Any asset file change invalidates its cache +- Versioned metadata enables safe migrations diff --git a/docs/interfaces/cli.md b/docs/interfaces/cli.md new file mode 100644 index 0000000..6e48503 --- /dev/null +++ b/docs/interfaces/cli.md @@ -0,0 +1,20 @@ +# CLI Interface + +## Purpose +The CLI provides direct access to indexing and graph exploration for developers and agents in terminal workflows. + +## Commands and Flags +- Index, shell, watch, clear +- Cache operations and diagnostics + +## Common Flow +```mermaid +flowchart LR + Start[Project Path] --> Index[Index] + Index --> Shell[Interactive Shell] + Shell --> Query[Query Graph] +``` + +## Output Formats +- Human-readable tables +- Machine-friendly JSON where applicable diff --git a/docs/interfaces/lsp.md b/docs/interfaces/lsp.md new file mode 100644 index 0000000..bed44b1 --- /dev/null +++ b/docs/interfaces/lsp.md @@ -0,0 +1,27 @@ +# LSP Interface + +## Purpose +LSP exposes navigation and insight features to editors using the same graph as CLI/MCP. + +## Supported Capabilities +- Definition, references, implementation +- Hover, symbols, hierarchy + +## Request/Response Mapping +```mermaid +sequenceDiagram + participant Client + participant LSP + participant Runtime + participant Graph + + Client->>LSP: textDocument/definition + LSP->>Runtime: resolve symbol + Runtime->>Graph: query + Graph-->>LSP: results + LSP-->>Client: locations +``` + +## Performance Considerations +- Avoid full re-index on open +- Serve from graph cache first diff --git a/docs/interfaces/mcp.md b/docs/interfaces/mcp.md new file mode 100644 index 0000000..b54a9a3 --- /dev/null +++ b/docs/interfaces/mcp.md @@ -0,0 +1,25 @@ +# MCP Interface + +## Purpose +MCP exposes graph queries to AI agents via a stable tool surface. It is optimized for structured, precise context delivery. + +## Tooling Surface +- `get_guide` +- `ls`, `find`, `cat`, `deps` + +## Usage Flow +```mermaid +sequenceDiagram + participant Agent + participant MCP + participant Runtime + + Agent->>MCP: find "Symbol" + MCP->>Runtime: query graph + Runtime-->>MCP: results + MCP-->>Agent: structured response +``` + +## Safety and Limits +- Read-only by default +- Resource limits per request diff --git a/docs/language/java.md b/docs/language/java.md new file mode 100644 index 0000000..e9d875c --- /dev/null +++ b/docs/language/java.md @@ -0,0 +1,24 @@ +# Java Language Strategy + +## Parsing +- Tree-sitter based source parsing +- Package and type extraction +- Identifier indexing for reference discovery + +## Bytecode Stubs +```mermaid +flowchart TD + ClassFile[.class] --> Parse[Bytecode Parser] + Parse --> Stub[IndexNode Stub] + Stub --> Graph[Graph Update] +``` + +## Inheritance and Type System +- Class and interface relations +- Field and method signatures +- Access modifiers and annotations + +## Edge Cases +- Inner classes +- Generics and annotations +- Split packages across multiple JARs diff --git a/docs/plugins/contracts.md b/docs/plugins/contracts.md new file mode 100644 index 0000000..a9821f9 --- /dev/null +++ b/docs/plugins/contracts.md @@ -0,0 +1,27 @@ +# Plugin Contracts + +## Purpose +Plugin contracts define how language and build-tool integrations attach their logic to the core engine without introducing hard dependencies. + +## LanguagePlugin Responsibilities +- Provide parsing and semantic analysis +- Emit nodes and edges into the graph +- Optionally expose an external resolver for stubs and sources + +## BuildToolPlugin Responsibilities +- Resolve module structure +- Provide dependency metadata +- Avoid executing build tools where possible + +## ExternalResolver Flow +```mermaid +flowchart LR + Index[Asset Index] --> Route[Routes] + Route --> Stub[Stub Generation] + Stub --> Graph[Graph Update] +``` + +## Compatibility Notes +- Traits are stable and versioned +- Metadata serialization is forward-compatible +- Default implementations should degrade gracefully diff --git a/docs/runtime/orchestrator.md b/docs/runtime/orchestrator.md new file mode 100644 index 0000000..cadc264 --- /dev/null +++ b/docs/runtime/orchestrator.md @@ -0,0 +1,24 @@ +# Runtime Orchestrator + +## Purpose +The orchestrator wires plugins, builds the graph, runs background tasks, and serves queries. It is the control plane for the engine. + +## Assembly Flow +```mermaid +flowchart TD + Runtime[Orchestrator] --> Plugins[Collect Plugins] + Runtime --> Core[Create Core Graph] + Runtime --> Asset[Start Asset Service] + Runtime --> Interfaces[Expose CLI/LSP/MCP] +``` + +## Lifecycle +- Build project graph first +- Start asset scanning in the background +- Serve queries immediately +- Upgrade placeholders as stubs arrive + +## Injection Points +- AssetStubService +- Language plugin registry +- Cache managers diff --git a/docs/runtime/services.md b/docs/runtime/services.md new file mode 100644 index 0000000..0ce4b47 --- /dev/null +++ b/docs/runtime/services.md @@ -0,0 +1,19 @@ +# Runtime Services + +## AssetStubService +- Coordinates discovery, indexing, and stub requests +- Provides route lookup for external symbols +- Emits stub results to graph updates + +## Indexing Service +- Triggers parsing and graph build +- Manages incremental updates +- Handles error isolation per file + +## Cache Service +- Handles global stub cache +- Supports cache statistics and inspection + +## Other Runtime Services +- File watching +- Diagnostics and logging diff --git a/docs/storage/persistence.md b/docs/storage/persistence.md new file mode 100644 index 0000000..94f13bf --- /dev/null +++ b/docs/storage/persistence.md @@ -0,0 +1,22 @@ +# Storage and Persistence + +## Purpose +Persistence enables fast restarts and cross-session reuse of graph data and external stubs. + +## On-Disk Layout +- Project index storage +- Global stub cache + +## Formats and Versioning +```mermaid +flowchart LR + Data[Index Data] --> Encode[Encode] + Encode --> Store[Store] + Store --> Load[Load] + Load --> Decode[Decode] +``` + +## Migration Strategy +- Versioned metadata for safe upgrades +- Fallback read for older formats +- Progressive migrations where possible diff --git a/docs/vision/product-overview.md b/docs/vision/product-overview.md new file mode 100644 index 0000000..0bd8b57 --- /dev/null +++ b/docs/vision/product-overview.md @@ -0,0 +1,24 @@ +# Product Overview + +## Positioning +Naviscope is a unified code knowledge graph engine that serves both AI agents and developer tools with the same structural understanding of a codebase. It sits between language-specific parsers and user-facing interfaces, providing consistent semantics and fast queries. + +## Core Value +- One graph shared by CLI, LSP, and MCP +- Fast startup with incremental enrichment +- Stable identifiers for long-lived references +- External dependency understanding without blocking indexing + +## Target Users +- Developers who need reliable navigation and dependency analysis +- AI agents that require structured, precise context for reasoning + +## Key Scenarios +- Search and inspect symbols across a large codebase +- Understand type hierarchies and relationships quickly +- Navigate external dependencies with stubs and on-demand sources + +## Differentiation +- Graph-first model instead of text-only search +- Background stubbing for external assets +- Shared caches across projects to reduce cold start cost From d472a79ce65558dbc1cee67cb0624d293d579a05 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Sun, 8 Feb 2026 13:00:42 +0800 Subject: [PATCH 08/49] feat: Introduce Java type inference system and refactor resolver scope management. --- crates/api/src/models/graph.rs | 1 + crates/core/src/asset/service.rs | 7 +- .../lang-java/src/inference/adapters/graph.rs | 461 ++++++++++++ .../src/inference/adapters/heuristic.rs | 138 ++++ .../lang-java/src/inference/adapters/mod.rs | 7 + crates/lang-java/src/inference/chain.rs | 174 +++++ crates/lang-java/src/inference/context.rs | 117 +++ crates/lang-java/src/inference/core/mod.rs | 5 + .../src/inference/core/normalization.rs | 56 ++ .../lang-java/src/inference/core/subtyping.rs | 74 ++ .../src/inference/core/type_system.rs | 157 ++++ crates/lang-java/src/inference/core/types.rs | 103 +++ .../src/inference/core/unification.rs | 112 +++ crates/lang-java/src/inference/mod.rs | 82 ++ .../lang-java/src/inference/scope/builder.rs | 270 +++++++ .../lang-java/src/inference/scope/manager.rs | 130 ++++ crates/lang-java/src/inference/scope/mod.rs | 7 + crates/lang-java/src/inference/scope/table.rs | 40 + .../src/inference/strategy/combinator.rs | 88 +++ .../lang-java/src/inference/strategy/field.rs | 63 ++ .../src/inference/strategy/lambda.rs | 40 + .../lang-java/src/inference/strategy/local.rs | 91 +++ .../src/inference/strategy/method.rs | 88 +++ .../lang-java/src/inference/strategy/mod.rs | 133 ++++ .../src/inference/strategy/new_expr.rs | 36 + .../lang-java/src/inference/strategy/this.rs | 20 + .../src/inference/strategy/type_id.rs | 59 ++ crates/lang-java/src/lib.rs | 1 + crates/lang-java/src/naming.rs | 66 ++ crates/lang-java/src/parser/scope.rs | 115 +-- crates/lang-java/src/resolver/lang.rs | 274 +++++++ crates/lang-java/src/resolver/mod.rs | 701 ++++-------------- .../lang-java/src/resolver/scope/builtin.rs | 88 --- .../src/resolver/scope/import_scope.rs | 92 --- crates/lang-java/src/resolver/scope/local.rs | 75 -- crates/lang-java/src/resolver/scope/member.rs | 493 ------------ crates/lang-java/src/resolver/scope/mod.rs | 17 - .../src/resolver/scope/package_scope.rs | 45 -- crates/lang-java/src/resolver/semantic.rs | 161 ++++ crates/lang-java/src/resolver/types.rs | 56 ++ crates/lang-java/tests/inference_mock_test.rs | 569 ++++++++++++++ crates/lang-java/tests/java_integration.rs | 1 + 42 files changed, 3843 insertions(+), 1470 deletions(-) create mode 100644 crates/lang-java/src/inference/adapters/graph.rs create mode 100644 crates/lang-java/src/inference/adapters/heuristic.rs create mode 100644 crates/lang-java/src/inference/adapters/mod.rs create mode 100644 crates/lang-java/src/inference/chain.rs create mode 100644 crates/lang-java/src/inference/context.rs create mode 100644 crates/lang-java/src/inference/core/mod.rs create mode 100644 crates/lang-java/src/inference/core/normalization.rs create mode 100644 crates/lang-java/src/inference/core/subtyping.rs create mode 100644 crates/lang-java/src/inference/core/type_system.rs create mode 100644 crates/lang-java/src/inference/core/types.rs create mode 100644 crates/lang-java/src/inference/core/unification.rs create mode 100644 crates/lang-java/src/inference/mod.rs create mode 100644 crates/lang-java/src/inference/scope/builder.rs create mode 100644 crates/lang-java/src/inference/scope/manager.rs create mode 100644 crates/lang-java/src/inference/scope/mod.rs create mode 100644 crates/lang-java/src/inference/scope/table.rs create mode 100644 crates/lang-java/src/inference/strategy/combinator.rs create mode 100644 crates/lang-java/src/inference/strategy/field.rs create mode 100644 crates/lang-java/src/inference/strategy/lambda.rs create mode 100644 crates/lang-java/src/inference/strategy/local.rs create mode 100644 crates/lang-java/src/inference/strategy/method.rs create mode 100644 crates/lang-java/src/inference/strategy/mod.rs create mode 100644 crates/lang-java/src/inference/strategy/new_expr.rs create mode 100644 crates/lang-java/src/inference/strategy/this.rs create mode 100644 crates/lang-java/src/inference/strategy/type_id.rs create mode 100644 crates/lang-java/src/resolver/lang.rs delete mode 100644 crates/lang-java/src/resolver/scope/builtin.rs delete mode 100644 crates/lang-java/src/resolver/scope/import_scope.rs delete mode 100644 crates/lang-java/src/resolver/scope/local.rs delete mode 100644 crates/lang-java/src/resolver/scope/member.rs delete mode 100644 crates/lang-java/src/resolver/scope/mod.rs delete mode 100644 crates/lang-java/src/resolver/scope/package_scope.rs create mode 100644 crates/lang-java/src/resolver/semantic.rs create mode 100644 crates/lang-java/src/resolver/types.rs create mode 100644 crates/lang-java/tests/inference_mock_test.rs diff --git a/crates/api/src/models/graph.rs b/crates/api/src/models/graph.rs index 55f7399..af488f9 100644 --- a/crates/api/src/models/graph.rs +++ b/crates/api/src/models/graph.rs @@ -234,6 +234,7 @@ pub struct DisplayGraphNode { pub modifiers: Vec, // Hierarchy support + #[serde(skip_serializing_if = "Option::is_none")] pub children: Option>, } diff --git a/crates/core/src/asset/service.rs b/crates/core/src/asset/service.rs index 609a007..454b00a 100644 --- a/crates/core/src/asset/service.rs +++ b/crates/core/src/asset/service.rs @@ -14,8 +14,8 @@ use naviscope_plugin::{ use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::Arc; -use tokio::sync::mpsc; use tokio::sync::RwLock; +use tokio::sync::mpsc; use tokio::task::JoinHandle; use tracing::debug; @@ -107,6 +107,7 @@ impl AssetStubService { let registry = self.registry.clone(); let scanner = self.build_scanner_clone(); let source_map = self.source_map.clone(); + let source_locators = self.source_locators.clone(); tokio::spawn(async move { // Run scan in blocking thread pool @@ -118,8 +119,8 @@ impl AssetStubService { } result }) - .await - .unwrap_or_default() + .await + .unwrap_or_default() }) } diff --git a/crates/lang-java/src/inference/adapters/graph.rs b/crates/lang-java/src/inference/adapters/graph.rs new file mode 100644 index 0000000..d9fe2c4 --- /dev/null +++ b/crates/lang-java/src/inference/adapters/graph.rs @@ -0,0 +1,461 @@ +//! CodeGraph adapter for JavaTypeSystem. +//! +//! Adapts the CodeGraph to the JavaTypeSystem trait. + +use naviscope_api::models::TypeRef; +use naviscope_api::models::graph::{EdgeType, NodeKind, NodeMetadata}; +use naviscope_api::models::symbol::FqnId; +use naviscope_plugin::{CodeGraph, Direction}; +use std::sync::Arc; + +use crate::inference::{InheritanceProvider, MemberProvider, TypeProvider}; +use crate::inference::{MemberInfo, MemberKind, TypeInfo, TypeKind, TypeResolutionContext}; +use crate::model::{JavaIndexMetadata, JavaNodeMetadata}; + +/// Adapter that implements JavaTypeSystem using CodeGraph. +pub struct CodeGraphTypeSystem<'a> { + graph: &'a dyn CodeGraph, +} + +impl<'a> CodeGraphTypeSystem<'a> { + /// Create a new adapter wrapping the given CodeGraph. + pub fn new(graph: &'a dyn CodeGraph) -> Self { + Self { graph } + } + + /// Convert NodeKind to TypeKind. + fn node_kind_to_type_kind(&self, kind: &NodeKind) -> TypeKind { + match kind { + NodeKind::Class => TypeKind::Class, + NodeKind::Interface => TypeKind::Interface, + NodeKind::Enum => TypeKind::Enum, + NodeKind::Annotation => TypeKind::Annotation, + _ => TypeKind::Class, + } + } + + /// Extract modifiers from node metadata. + fn extract_modifiers(&self, metadata: &Arc) -> Vec { + if let Some(java_meta) = metadata.as_any().downcast_ref::() { + return match java_meta { + JavaIndexMetadata::Class { modifiers } => modifiers.clone(), + JavaIndexMetadata::Interface { modifiers } => modifiers.clone(), + JavaIndexMetadata::Enum { modifiers, .. } => modifiers.clone(), + JavaIndexMetadata::Method { modifiers, .. } => modifiers.clone(), + JavaIndexMetadata::Field { modifiers, .. } => modifiers.clone(), + _ => vec![], + }; + } + vec![] + } + + /// Extract type ref from metadata. + fn extract_type_from_metadata(&self, metadata: &Arc) -> TypeRef { + if let Some(java_meta) = metadata.as_any().downcast_ref::() { + return match java_meta { + JavaNodeMetadata::Method { return_type, .. } => return_type.clone(), + JavaNodeMetadata::Field { type_ref, .. } => type_ref.clone(), + _ => TypeRef::Id("java.lang.Object".to_string()), + }; + } + + if let Some(java_meta) = metadata.as_any().downcast_ref::() { + return match java_meta { + JavaIndexMetadata::Method { return_type, .. } => return_type.clone(), + JavaIndexMetadata::Field { type_ref, .. } => type_ref.clone(), + _ => TypeRef::Id("java.lang.Object".to_string()), + }; + } + TypeRef::Id("java.lang.Object".to_string()) + } + + /// Render FQN for a node ID. + fn render_fqn_id(&self, node_id: FqnId) -> String { + use crate::naming::JavaNamingConvention; + use naviscope_plugin::NamingConvention; + + // Use Java naming convention to render FQN + JavaNamingConvention.render_fqn(node_id, self.graph.fqns()) + } + + /// Extract parameters from metadata. + fn extract_parameters( + &self, + metadata: &Arc, + ) -> Option> { + use crate::inference::ParameterInfo; + + if let Some(java_meta) = metadata.as_any().downcast_ref::() { + match java_meta { + JavaNodeMetadata::Method { parameters, .. } => { + return Some( + parameters + .iter() + .enumerate() + .map(|(i, p)| ParameterInfo { + name: format!("arg{}", i), // Cannot resolve SID without interner + type_ref: p.type_ref.clone(), + }) + .collect(), + ); + } + _ => return None, + } + } + + if let Some(java_meta) = metadata.as_any().downcast_ref::() { + match java_meta { + JavaIndexMetadata::Method { parameters, .. } => { + return Some( + parameters + .iter() + .map(|p| ParameterInfo { + name: p.name.clone(), + type_ref: p.type_ref.clone(), + }) + .collect(), + ); + } + _ => return None, + } + } + None + } +} + +impl<'a> TypeProvider for CodeGraphTypeSystem<'a> { + fn get_type_info(&self, fqn: &str) -> Option { + let node_ids = self.graph.resolve_fqn(fqn); + let node_id = *node_ids.first()?; + let node = self.graph.get_node(node_id)?; + + Some(TypeInfo { + fqn: fqn.to_string(), + kind: self.node_kind_to_type_kind(&node.kind), + modifiers: self.extract_modifiers(&node.metadata), + type_parameters: vec![], + }) + } + + fn resolve_type_name(&self, simple_name: &str, ctx: &TypeResolutionContext) -> Option { + // 1. Check explicit imports + for imp in &ctx.imports { + if imp.ends_with(&format!(".{}", simple_name)) { + return Some(imp.clone()); + } + } + + // 2. Check wildcard imports + for imp in &ctx.imports { + if imp.ends_with(".*") { + let prefix = &imp[..imp.len() - 2]; + let candidate = format!("{}.{}", prefix, simple_name); + if !self.graph.resolve_fqn(&candidate).is_empty() { + return Some(candidate); + } + } + } + + // 3. Check same package + if let Some(pkg) = &ctx.package { + let candidate = format!("{}.{}", pkg, simple_name); + if !self.graph.resolve_fqn(&candidate).is_empty() { + return Some(candidate); + } + } + + // 4. Check java.lang + let java_lang = format!("java.lang.{}", simple_name); + if !self.graph.resolve_fqn(&java_lang).is_empty() { + return Some(java_lang); + } + + // 5. Fallback to raw name if it exists in graph (Default Package) + let results = self.graph.resolve_fqn(simple_name); + if !results.is_empty() { + return Some(simple_name.to_string()); + } + + None + } +} + +impl<'a> InheritanceProvider for CodeGraphTypeSystem<'a> { + fn get_superclass(&self, fqn: &str) -> Option { + let node_ids = self.graph.resolve_fqn(fqn); + + for node_id in node_ids { + let neighbors = self.graph.get_neighbors( + node_id, + Direction::Outgoing, + Some(EdgeType::InheritsFrom), + ); + + if let Some(&parent_id) = neighbors.first() { + return Some(self.render_fqn_id(parent_id)); + } + } + + None + } + + fn get_interfaces(&self, fqn: &str) -> Vec { + let node_ids = self.graph.resolve_fqn(fqn); + let mut result = vec![]; + + for node_id in node_ids { + let neighbors = + self.graph + .get_neighbors(node_id, Direction::Outgoing, Some(EdgeType::Implements)); + + for iface_id in neighbors { + result.push(self.render_fqn_id(iface_id)); + } + } + + result + } + + fn walk_ancestors(&self, fqn: &str) -> Box + '_> { + Box::new(AncestorIterator::new(self, fqn, 10)) + } + + fn get_direct_subtypes(&self, fqn: &str) -> Vec { + let node_ids = self.graph.resolve_fqn(fqn); + let mut result = vec![]; + + for node_id in node_ids { + // Find nodes that inherit from or implement this type (Incoming edges) + let subs = self.graph.get_neighbors( + node_id, + Direction::Incoming, + Some(EdgeType::InheritsFrom), + ); + for sub_id in subs { + result.push(self.render_fqn_id(sub_id)); + } + + let impls = + self.graph + .get_neighbors(node_id, Direction::Incoming, Some(EdgeType::Implements)); + for sub_id in impls { + result.push(self.render_fqn_id(sub_id)); + } + } + + result + } + + fn walk_descendants(&self, fqn: &str) -> Box + '_> { + Box::new(DescendantIterator::new(self, fqn, 10)) + } +} + +impl<'a> MemberProvider for CodeGraphTypeSystem<'a> { + fn get_members(&self, type_fqn: &str, member_name: &str) -> Vec { + // Use unified member FQN format + let member_fqn = + crate::naming::JavaNamingConvention::build_member_fqn(type_fqn, member_name); + let node_ids = self.graph.resolve_fqn(&member_fqn); + let mut members = Vec::new(); + + for &node_id in &node_ids { + if let Some(node) = self.graph.get_node(node_id) { + let kind = match &node.kind { + NodeKind::Method => MemberKind::Method, + NodeKind::Field => MemberKind::Field, + NodeKind::Constructor => MemberKind::Constructor, + _ => MemberKind::Method, + }; + + let type_ref = self.extract_type_from_metadata(&node.metadata); + + members.push(MemberInfo { + name: member_name.to_string(), + fqn: member_fqn.clone(), + kind, + declaring_type: type_fqn.to_string(), + type_ref, + parameters: self.extract_parameters(&node.metadata), + modifiers: vec![], + generic_signature: None, + }); + } + } + + members + } + + fn get_all_members(&self, type_fqn: &str) -> Vec { + let node_ids = self.graph.resolve_fqn(type_fqn); + let mut members = Vec::new(); + + if let Some(&node_id) = node_ids.first() { + let children = + self.graph + .get_neighbors(node_id, Direction::Outgoing, Some(EdgeType::Contains)); + + for child_id in children { + if let Some(node) = self.graph.get_node(child_id) { + let kind = match &node.kind { + NodeKind::Method => MemberKind::Method, + NodeKind::Field => MemberKind::Field, + NodeKind::Constructor => MemberKind::Constructor, + _ => continue, + }; + + let child_fqn = self.render_fqn_id(child_id); + // Extract member name using unified convention + // Members always use '#' separator, so this should always succeed + let name = crate::naming::JavaNamingConvention::extract_member_name(&child_fqn) + .unwrap_or_else(|| self.graph.fqns().resolve_atom(node.name)) + .to_string(); + + let type_ref = self.extract_type_from_metadata(&node.metadata); + + members.push(MemberInfo { + name, + fqn: child_fqn, + kind, + declaring_type: type_fqn.to_string(), + type_ref, + parameters: self.extract_parameters(&node.metadata), + modifiers: vec![], + generic_signature: None, + }); + } + } + } + members + } +} + +/// Iterator over ancestor types (BFS). +struct AncestorIterator<'a> { + provider: &'a CodeGraphTypeSystem<'a>, + queue: std::collections::VecDeque, + visited: std::collections::HashSet, + max_depth: usize, + current_depth: usize, +} + +impl<'a> AncestorIterator<'a> { + fn new(provider: &'a CodeGraphTypeSystem<'a>, start: &str, max_depth: usize) -> Self { + let mut queue = std::collections::VecDeque::new(); + let mut visited = std::collections::HashSet::new(); + + // Start with direct parents + if let Some(super_class) = provider.get_superclass(start) { + queue.push_back(super_class); + } + for iface in provider.get_interfaces(start) { + queue.push_back(iface); + } + + visited.insert(start.to_string()); + + Self { + provider, + queue, + visited, + max_depth, + current_depth: 0, + } + } +} + +impl<'a> Iterator for AncestorIterator<'a> { + type Item = String; + + fn next(&mut self) -> Option { + if self.current_depth >= self.max_depth { + return None; + } + + while let Some(fqn) = self.queue.pop_front() { + if self.visited.contains(&fqn) { + continue; + } + + self.visited.insert(fqn.clone()); + self.current_depth += 1; + + // Add parents of this type + if let Some(super_class) = self.provider.get_superclass(&fqn) { + if !self.visited.contains(&super_class) { + self.queue.push_back(super_class); + } + } + for iface in self.provider.get_interfaces(&fqn) { + if !self.visited.contains(&iface) { + self.queue.push_back(iface); + } + } + + return Some(fqn); + } + + None + } +} + +/// Iterator over descendant types (BFS). +struct DescendantIterator<'a> { + provider: &'a CodeGraphTypeSystem<'a>, + queue: std::collections::VecDeque, + visited: std::collections::HashSet, + max_depth: usize, + current_depth: usize, +} + +impl<'a> DescendantIterator<'a> { + fn new(provider: &'a CodeGraphTypeSystem<'a>, start: &str, max_depth: usize) -> Self { + let mut queue = std::collections::VecDeque::new(); + let mut visited = std::collections::HashSet::new(); + + // Start with direct children + for sub in provider.get_direct_subtypes(start) { + queue.push_back(sub); + } + + visited.insert(start.to_string()); + + Self { + provider, + queue, + visited, + max_depth, + current_depth: 0, + } + } +} + +impl<'a> Iterator for DescendantIterator<'a> { + type Item = String; + + fn next(&mut self) -> Option { + if self.current_depth >= self.max_depth { + return None; + } + + while let Some(fqn) = self.queue.pop_front() { + if self.visited.contains(&fqn) { + continue; + } + + self.visited.insert(fqn.clone()); + self.current_depth += 1; + + // Add children of this type + for sub in self.provider.get_direct_subtypes(&fqn) { + if !self.visited.contains(&sub) { + self.queue.push_back(sub); + } + } + + return Some(fqn); + } + + None + } +} diff --git a/crates/lang-java/src/inference/adapters/heuristic.rs b/crates/lang-java/src/inference/adapters/heuristic.rs new file mode 100644 index 0000000..3252a1a --- /dev/null +++ b/crates/lang-java/src/inference/adapters/heuristic.rs @@ -0,0 +1,138 @@ +use crate::inference::{ + InheritanceProvider, MemberInfo, MemberProvider, TypeInfo, TypeProvider, TypeResolutionContext, +}; + +/// A TypeProvider that uses heuristics to resolve type names without a backing graph. +/// +/// This is primarily used during indexing when the graph is incomplete, +/// allowing for optimistic resolution of FQNs based on conventions and imports. +pub struct HeuristicAdapter; + +impl TypeProvider for HeuristicAdapter { + fn get_type_info(&self, _fqn: &str) -> Option { + None + } + + fn resolve_type_name(&self, type_name: &str, ctx: &TypeResolutionContext) -> Option { + // 1. Check if it's a primitive type + const PRIMITIVES: &[&str] = &[ + "int", "long", "short", "byte", "float", "double", "boolean", "char", "void", + ]; + if PRIMITIVES.contains(&type_name) { + return Some(type_name.to_string()); + } + + // 2. Handle dotted names (e.g. Config.KEY or com.example.Config) + if type_name.contains('.') { + let parts: Vec<&str> = type_name.split('.').collect(); + let first_part = parts[0]; + + if !first_part.is_empty() && first_part.chars().next().unwrap_or(' ').is_lowercase() { + return Some(type_name.to_string()); + } + + if let Some(p) = &ctx.package { + if first_part == p { + return Some(type_name.to_string()); + } + } + + if let Some(first_fqn) = self.resolve_type_name(first_part, ctx) { + if first_fqn != first_part { + let mut full_fqn = first_fqn; + for part in &parts[1..] { + full_fqn.push('.'); + full_fqn.push_str(part); + } + return Some(full_fqn); + } + } + return Some(type_name.to_string()); + } + + // 3. Precise imports + for imp in &ctx.imports { + if imp.ends_with(&format!(".{}", type_name)) { + return Some(imp.clone()); + } + } + + // 4. java.lang (implicit import) + const JAVA_LANG_CLASSES: &[&str] = &[ + "String", + "Object", + "Integer", + "Long", + "Double", + "Float", + "Boolean", + "Byte", + "Character", + "Short", + "Exception", + "RuntimeException", + "Throwable", + "Error", + "Thread", + "System", + "Class", + "Iterable", + "Runnable", + "Comparable", + "SuppressWarnings", + "Override", + "Deprecated", + ]; + if JAVA_LANG_CLASSES.contains(&type_name) { + return Some(format!("java.lang.{}", type_name)); + } + + // 5. Current package + if let Some(p) = &ctx.package { + if type_name.starts_with(&(p.to_string() + ".")) + || type_name.starts_with("java.") + || type_name.starts_with("javax.") + || type_name.starts_with("com.") + || type_name.starts_with("org.") + || type_name.starts_with("net.") + { + return Some(type_name.to_string()); + } + return Some(format!("{}.{}", p, type_name)); + } + + Some(type_name.to_string()) + } +} + +impl InheritanceProvider for HeuristicAdapter { + fn get_superclass(&self, _fqn: &str) -> Option { + None + } + + fn get_interfaces(&self, _fqn: &str) -> Vec { + vec![] + } + + fn walk_ancestors(&self, _fqn: &str) -> Box + '_> { + Box::new(std::iter::empty()) + } + + fn get_direct_subtypes(&self, _fqn: &str) -> Vec { + vec![] + } + + fn walk_descendants(&self, _fqn: &str) -> Box + '_> { + Box::new(std::iter::empty()) + } +} + +impl MemberProvider for HeuristicAdapter { + fn get_members(&self, _type_fqn: &str, _member_name: &str) -> Vec { + vec![] + } + + fn get_all_members(&self, _type_fqn: &str) -> Vec { + vec![] + } +} diff --git a/crates/lang-java/src/inference/adapters/mod.rs b/crates/lang-java/src/inference/adapters/mod.rs new file mode 100644 index 0000000..af428e3 --- /dev/null +++ b/crates/lang-java/src/inference/adapters/mod.rs @@ -0,0 +1,7 @@ +//! Adapters that implement JavaTypeSystem for various data sources. + +mod graph; +mod heuristic; + +pub use graph::CodeGraphTypeSystem; +pub use heuristic::HeuristicAdapter; diff --git a/crates/lang-java/src/inference/chain.rs b/crates/lang-java/src/inference/chain.rs new file mode 100644 index 0000000..9c69f45 --- /dev/null +++ b/crates/lang-java/src/inference/chain.rs @@ -0,0 +1,174 @@ +//! Chain resolution for method chaining. +//! +//! Uses functional unfold pattern to resolve chains like: +//! `response.getContext().get("key")` + +use crate::inference::strategy::infer_expression; +use crate::inference::{InferContext, TypeRefExt}; +use naviscope_api::models::TypeRef; +use tree_sitter::Node; + +/// Result of resolving a chain. +#[derive(Debug, Clone)] +pub struct ChainResolution { + /// The final member FQN (e.g., "SessionContext#get") + pub member_fqn: Option, + /// The result type of the chain + pub result_type: TypeRef, +} + +impl ChainResolution { + /// Create from just a type (for simple expressions) + pub fn from_type(ty: TypeRef) -> Self { + Self { + member_fqn: None, + result_type: ty, + } + } + + /// Create with both member and type + pub fn with_member(member_fqn: String, result_type: TypeRef) -> Self { + Self { + member_fqn: Some(member_fqn), + result_type, + } + } +} + +/// A step in the chain resolution process. +#[allow(dead_code)] // WithReceiver variant is part of incomplete chain resolution +enum ChainStep<'a> { + /// Initial node to resolve + Initial(&'a Node<'a>), + /// Have a receiver type, resolving member + WithReceiver { + receiver_type: TypeRef, + node: &'a Node<'a>, + }, + /// Final resolution + Resolved(ChainResolution), +} + +impl<'a> ChainStep<'a> { + /// Get the resolution if this is the final state. + fn resolution(&self) -> Option { + match self { + ChainStep::Resolved(r) => Some(r.clone()), + _ => None, + } + } + + /// Advance to the next step. + fn next(&self, ctx: &InferContext) -> Option> { + match self { + ChainStep::Initial(node) => { + // Infer type of initial expression + let ty = infer_expression(node, ctx)?; + + // Check if there's a parent chain node + if let Some(parent) = node.parent() { + if is_chain_parent(parent.kind()) { + // Store reference to parent - this is tricky with lifetimes + // For now, resolve immediately + return Some(ChainStep::Resolved(ChainResolution::from_type(ty))); + } + } + + Some(ChainStep::Resolved(ChainResolution::from_type(ty))) + } + + ChainStep::WithReceiver { + receiver_type, + node, + } => { + // Get member name from the node + let member_name = extract_member_name(node, ctx)?; + let type_fqn = receiver_type.as_fqn()?; + + // Find member in hierarchy + let members = ctx.ts.find_member_in_hierarchy(&type_fqn, &member_name); + let member = members.first()?; + + // Check for more chain + if let Some(parent) = node.parent() { + if is_chain_parent(parent.kind()) { + // Continue chain - simplified for now + return Some(ChainStep::Resolved(ChainResolution::with_member( + member.fqn.clone(), + member.type_ref.clone(), + ))); + } + } + + Some(ChainStep::Resolved(ChainResolution::with_member( + member.fqn.clone(), + member.type_ref.clone(), + ))) + } + + ChainStep::Resolved(_) => None, // Terminal state + } + } +} + +/// Check if a node kind is a chain parent (method_invocation, field_access). +fn is_chain_parent(kind: &str) -> bool { + matches!(kind, "method_invocation" | "field_access") +} + +/// Extract member name from a chain node. +fn extract_member_name(node: &Node, ctx: &InferContext) -> Option { + let name_node = match node.kind() { + "method_invocation" => node.child_by_field_name("name"), + "field_access" => node.child_by_field_name("field"), + _ => None, + }?; + + name_node + .utf8_text(ctx.source.as_bytes()) + .ok() + .map(|s| s.to_string()) +} + +/// Resolve a chain of method calls / field accesses. +/// +/// Uses `std::iter::successors` for functional unfold. +/// +/// # Example +/// +/// For `response.getContext().get("key")`: +/// 1. Resolve `response` → HttpResponseMessage +/// 2. Find `getContext` in HttpResponseMessage hierarchy → SessionContext +/// 3. Find `get` in SessionContext hierarchy → Object +pub fn resolve_chain<'a>( + initial: &'a Node<'a>, + ctx: &'a InferContext<'a>, +) -> Option { + const MAX_DEPTH: usize = 20; + + // Use successors to unfold the chain + let steps: Vec<_> = + std::iter::successors(Some(ChainStep::Initial(initial)), |step| step.next(ctx)) + .take(MAX_DEPTH) + .collect(); + + // Return the last resolved step + steps.into_iter().rev().find_map(|s| s.resolution()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_chain_resolution_from_type() { + let res = ChainResolution::from_type(TypeRef::Id("String".into())); + assert!(res.member_fqn.is_none()); + } + + #[test] + fn test_chain_resolution_with_member() { + let res = ChainResolution::with_member("List#get".into(), TypeRef::Id("Object".into())); + assert_eq!(res.member_fqn, Some("List#get".into())); + } +} diff --git a/crates/lang-java/src/inference/context.rs b/crates/lang-java/src/inference/context.rs new file mode 100644 index 0000000..56ef986 --- /dev/null +++ b/crates/lang-java/src/inference/context.rs @@ -0,0 +1,117 @@ +//! Context for type inference. +//! +//! Holds the state passed through the inference strategy chain. + +use crate::inference::core::type_system::JavaTypeSystem; +use crate::inference::core::types::TypeResolutionContext; +use crate::inference::scope::ScopeManager; +use naviscope_api::models::TypeRef; + +/// Context for type inference +/// +/// This is passed through the inference chain. It is immutable; +/// updates create new contexts. +#[derive(Clone)] +pub struct InferContext<'a> { + /// Source code being analyzed + pub source: &'a str, + /// Type system for lookups + pub ts: &'a dyn JavaTypeSystem, + /// Current package + pub package: Option, + /// Imports in the current file + pub imports: Vec, + /// Enclosing class FQN (for `this` resolution) + pub enclosing_class: Option, + /// Type parameters in scope + pub type_parameters: Vec, + /// Expected type for bidirectional inference (check mode) + pub expected_type: Option, + /// Optional Scope Manager for optimized lookup + pub scope_manager: Option<&'a ScopeManager>, + /// Types defined in the current file + pub known_fqns: Vec, +} + +impl<'a> InferContext<'a> { + /// Create a new inference context + pub fn new(source: &'a str, ts: &'a dyn JavaTypeSystem) -> Self { + Self { + source, + ts, + package: None, + imports: Vec::new(), + enclosing_class: None, + type_parameters: Vec::new(), + expected_type: None, + scope_manager: None, + known_fqns: Vec::new(), + } + } + + /// Create a context with package and imports + pub fn with_file_context( + source: &'a str, + ts: &'a dyn JavaTypeSystem, + package: Option, + imports: Vec, + ) -> Self { + Self { + source, + ts, + package, + imports, + enclosing_class: None, + type_parameters: Vec::new(), + expected_type: None, + scope_manager: None, + known_fqns: Vec::new(), + } + } + + /// Set the enclosing class + pub fn with_enclosing_class(mut self, class: String) -> Self { + self.enclosing_class = Some(class); + self + } + + /// Set imports + pub fn with_imports(mut self, imports: Vec) -> Self { + self.imports = imports; + self + } + + /// Set package + pub fn with_package(mut self, package: Option) -> Self { + self.package = package; + self + } + + /// Set known FQNs + pub fn with_known_fqns(mut self, fqns: Vec) -> Self { + self.known_fqns = fqns; + self + } + + /// Set expected type for checking mode + pub fn with_expected_type(mut self, expected: Option) -> Self { + self.expected_type = expected; + self + } + + /// Set scope manager + pub fn with_scope_manager(mut self, manager: &'a ScopeManager) -> Self { + self.scope_manager = Some(manager); + self + } + + /// Convert to TypeResolutionContext + pub fn to_resolution_context(&self) -> TypeResolutionContext { + TypeResolutionContext { + package: self.package.clone(), + imports: self.imports.clone(), + type_parameters: self.type_parameters.clone(), + known_fqns: self.known_fqns.clone(), + } + } +} diff --git a/crates/lang-java/src/inference/core/mod.rs b/crates/lang-java/src/inference/core/mod.rs new file mode 100644 index 0000000..df923e1 --- /dev/null +++ b/crates/lang-java/src/inference/core/mod.rs @@ -0,0 +1,5 @@ +pub mod normalization; +pub mod subtyping; +pub mod type_system; +pub mod types; +pub mod unification; diff --git a/crates/lang-java/src/inference/core/normalization.rs b/crates/lang-java/src/inference/core/normalization.rs new file mode 100644 index 0000000..1ea718c --- /dev/null +++ b/crates/lang-java/src/inference/core/normalization.rs @@ -0,0 +1,56 @@ +//! Type normalization logic. +//! +//! Converts raw/string types into structured, fully-qualified TypeRefs. + +use crate::inference::InferContext; +use naviscope_api::models::TypeRef; + +/// Normalize a TypeRef using the given context. +/// +/// This resolves simple names to FQNs and handles generics. +pub fn normalize_type(ty: TypeRef, ctx: &InferContext) -> TypeRef { + match ty { + TypeRef::Raw(s) => { + // Try to parse and resolve + // For now, simple FQN resolution + if let Some(fqn) = ctx.ts.resolve_type_name(&s, &ctx.to_resolution_context()) { + TypeRef::Id(fqn) + } else { + // If not resolved, keep as Raw or assume it's FQN if contains dot? + if s.contains('.') { + TypeRef::Id(s) + } else { + TypeRef::Raw(s) + } + } + } + TypeRef::Id(id) => { + // Ensure ID is fully qualified if possible + if !id.contains('.') { + if let Some(fqn) = ctx.ts.resolve_type_name(&id, &ctx.to_resolution_context()) { + TypeRef::Id(fqn) + } else { + TypeRef::Id(id) + } + } else { + TypeRef::Id(id) + } + } + TypeRef::Generic { base, args } => { + let base = Box::new(normalize_type(*base, ctx)); + let args = args + .into_iter() + .map(|arg| normalize_type(arg, ctx)) + .collect(); + TypeRef::Generic { base, args } + } + TypeRef::Array { + element, + dimensions, + } => TypeRef::Array { + element: Box::new(normalize_type(*element, ctx)), + dimensions, + }, + _ => ty, + } +} diff --git a/crates/lang-java/src/inference/core/subtyping.rs b/crates/lang-java/src/inference/core/subtyping.rs new file mode 100644 index 0000000..76d5363 --- /dev/null +++ b/crates/lang-java/src/inference/core/subtyping.rs @@ -0,0 +1,74 @@ +//! Subtyping rules implementation. +//! +//! Determines if one type is a subtype of another. +//! Supports: +//! - Identity +//! - Primitives (widening) +//! - Classes (extends) +//! - Interfaces (implements) +//! - Arrays (covariant) + +use crate::inference::core::type_system::JavaTypeSystem; +use naviscope_api::models::TypeRef; + +/// Check if `sub` is a subtype of `super_type`. +pub fn is_subtype(sub: &TypeRef, super_type: &TypeRef, ts: &T) -> bool { + // 1. Reflexivity + if sub == super_type { + return true; + } + + // 2. java.lang.Object is supertype of all reference types + if let TypeRef::Id(id) = super_type { + if id == "java.lang.Object" { + // Primitives are not Objects (unless autoboxed, but strict subtyping usually separates them) + // For now, assume strict subtyping for checking + return !matches!(sub, TypeRef::Raw(_)); + } + } + + match (sub, super_type) { + // Primitive widening + (TypeRef::Raw(s1), TypeRef::Raw(s2)) => is_primitive_subtype(s1, s2), + + // Class/Interface hierarchy + (TypeRef::Id(sub_id), TypeRef::Id(super_id)) => is_class_subtype(sub_id, super_id, ts), + + // Arrays (Covariant for references) + (TypeRef::Array { element: e1, .. }, TypeRef::Array { element: e2, .. }) => { + is_subtype(e1, e2, ts) + } + + // TODO: Generics (Invariant? Covariant with wildcards?) + // For now, simple equality on generics was caught by step 1 + _ => false, + } +} + +fn is_primitive_subtype(sub: &str, sup: &str) -> bool { + match sub { + "byte" => matches!(sup, "short" | "int" | "long" | "float" | "double"), + "short" => matches!(sup, "int" | "long" | "float" | "double"), + "char" => matches!(sup, "int" | "long" | "float" | "double"), + "int" => matches!(sup, "long" | "float" | "double"), + "long" => matches!(sup, "float" | "double"), + "float" => matches!(sup, "double"), + _ => false, + } +} + +fn is_class_subtype(sub_fqn: &str, super_fqn: &str, ts: &T) -> bool { + if sub_fqn == super_fqn { + return true; + } + + // BFS search up the hierarchy + // JavaTypeSystem::walk_ancestors returns an iterator of all ancestors + for ancestor in ts.walk_ancestors(sub_fqn) { + if ancestor == super_fqn { + return true; + } + } + + false +} diff --git a/crates/lang-java/src/inference/core/type_system.rs b/crates/lang-java/src/inference/core/type_system.rs new file mode 100644 index 0000000..9cd498e --- /dev/null +++ b/crates/lang-java/src/inference/core/type_system.rs @@ -0,0 +1,157 @@ +//! Core trait definitions for the type system abstraction. +//! +//! These traits abstract away the data source, allowing the inference +//! engine to work with CodeGraph, stubs, or mock implementations. + +use super::types::{MemberInfo, TypeInfo, TypeResolutionContext}; + +/// Provides type information by FQN. +/// +/// This is the primary way to look up type metadata. +pub trait TypeProvider: Send + Sync { + /// Get type info for a fully qualified name. + /// + /// Returns `None` if the type is not found. + fn get_type_info(&self, fqn: &str) -> Option; + + /// Resolve a simple type name to its FQN. + /// + /// Uses the provided context (imports, package) to resolve the name. + fn resolve_type_name( + &self, + simple_name: &str, + context: &TypeResolutionContext, + ) -> Option; +} + +/// Provides inheritance relationship information. +/// +/// This is used to traverse the type hierarchy when looking for inherited members. +pub trait InheritanceProvider: Send + Sync { + /// Get the direct superclass of a type. + /// + /// Returns `None` for `java.lang.Object` or interfaces. + fn get_superclass(&self, fqn: &str) -> Option; + + /// Get the interfaces directly implemented by a type. + fn get_interfaces(&self, fqn: &str) -> Vec; + + /// Walk all ancestor types (superclasses and interfaces). + /// + /// The iterator yields types in BFS order, stopping at max depth. + fn walk_ancestors(&self, fqn: &str) -> Box + '_>; + + /// Get all direct subtypes (classes that extend or implement this type). + fn get_direct_subtypes(&self, fqn: &str) -> Vec; + + /// Walk all descendant types (subclasses and sub-interfaces). + fn walk_descendants(&self, fqn: &str) -> Box + '_>; +} + +/// Provides member (field/method) lookup. +/// +/// This is used to find members within a single type (not walking inheritance). +pub trait MemberProvider: Send + Sync { + /// Find all members directly declared in the given type with the matching name. + /// + /// Does NOT search the inheritance hierarchy. + fn get_members(&self, type_fqn: &str, member_name: &str) -> Vec; + + /// Get all members directly declared in the given type. + fn get_all_members(&self, type_fqn: &str) -> Vec; +} + +use naviscope_api::models::TypeRef; + +/// The combined type system interface. +/// +/// Provides a unified facade for type inference operations. +/// Includes a default implementation for hierarchy search. +pub trait JavaTypeSystem: TypeProvider + InheritanceProvider + MemberProvider { + /// Find a member in the type hierarchy. + /// + /// Searches the type itself first, then walks ancestors. + fn find_member_in_hierarchy(&self, type_fqn: &str, member_name: &str) -> Vec { + // Check the type itself first + let mut members = self.get_members(type_fqn, member_name); + if !members.is_empty() { + return members; + } + + // Walk ancestors + for ancestor in self.walk_ancestors(type_fqn) { + members = self.get_members(&ancestor, member_name); + if !members.is_empty() { + return members; + } + } + + vec![] + } + + /// Resolve the best matching method among candidates based on argument types. + /// + /// Implements basic Java overload resolution rules. + fn resolve_method( + &self, + candidates: &[MemberInfo], + arg_types: &[TypeRef], + ) -> Option { + if candidates.is_empty() { + return None; + } + + // 1. Precise match (exact signatures) + for cand in candidates { + if let Some(params) = &cand.parameters { + if params.len() == arg_types.len() { + let mut match_all = true; + for (p, a) in params.iter().zip(arg_types.iter()) { + if p.type_ref != *a { + match_all = false; + break; + } + } + if match_all { + return Some(cand.clone()); + } + } + } + } + + // 2. Subtype match (widening) + for cand in candidates { + if let Some(params) = &cand.parameters { + if params.len() == arg_types.len() { + let mut match_all = true; + for (p, a) in params.iter().zip(arg_types.iter()) { + if !self.is_subtype(a, &p.type_ref) { + match_all = false; + break; + } + } + if match_all { + return Some(cand.clone()); + } + } + } + } + + // Fallback to first if no parameters expected or provided + // (Helpful for field access or methods we couldn't match precisely) + candidates.first().cloned() + } + + /// Check if sub is a subtype of super_type. + /// + /// This is a basic implementation that can be overridden by more sophisticated logic. + /// Check if sub is a subtype of super_type. + /// + /// Delegates to `subtyping::is_subtype` logic. + fn is_subtype(&self, sub: &TypeRef, super_type: &TypeRef) -> bool { + crate::inference::core::subtyping::is_subtype(sub, super_type, self) + } +} + +// Blanket implementation: any type implementing all three traits gets JavaTypeSystem +impl JavaTypeSystem for T {} diff --git a/crates/lang-java/src/inference/core/types.rs b/crates/lang-java/src/inference/core/types.rs new file mode 100644 index 0000000..0981af5 --- /dev/null +++ b/crates/lang-java/src/inference/core/types.rs @@ -0,0 +1,103 @@ +//! Data structures for the type inference system. +//! +//! These are pure data types with no behavior logic. + +use naviscope_api::models::TypeRef; + +/// Helper trait for TypeRef operations +pub trait TypeRefExt { + fn as_fqn(&self) -> Option; +} + +impl TypeRefExt for TypeRef { + fn as_fqn(&self) -> Option { + match self { + TypeRef::Id(fqn) => Some(fqn.clone()), + TypeRef::Generic { base, .. } => base.as_fqn(), + _ => None, + } + } +} + +/// Information about a type (class, interface, enum, etc.) +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TypeInfo { + /// Fully qualified name, e.g., "java.util.List" + pub fqn: String, + /// Kind of type + pub kind: TypeKind, + /// Modifiers like public, abstract, final + pub modifiers: Vec, + /// Generic type parameters, e.g., `>` + pub type_parameters: Vec, +} + +/// Kind of type +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TypeKind { + Class, + Interface, + Enum, + Annotation, + Primitive, +} + +/// A generic type parameter declaration +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TypeParameter { + /// Parameter name, e.g., "T" + pub name: String, + /// Upper bounds, e.g., ["Comparable", "Serializable"] for `T extends Comparable & Serializable` + pub bounds: Vec, +} + +/// Information about a member (field, method, constructor) +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MemberInfo { + /// Simple name, e.g., "get" or "size" + pub name: String, + /// Fully qualified name, e.g., "java.util.List#get" + pub fqn: String, + /// Kind of member + pub kind: MemberKind, + /// The type that declares this member (may differ from lookup type due to inheritance) + pub declaring_type: String, + /// Field type or method return type + pub type_ref: TypeRef, + /// Method parameters (None for fields) + pub parameters: Option>, + /// Modifiers like public, static, final + pub modifiers: Vec, + /// Raw generic signature from bytecode, if available + pub generic_signature: Option, +} + +/// Kind of member +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MemberKind { + Field, + Method, + Constructor, +} + +/// Information about a method parameter +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParameterInfo { + /// Parameter name (may be synthetic like "arg0") + pub name: String, + /// Parameter type + pub type_ref: TypeRef, +} + +/// Context for type name resolution +#[derive(Debug, Clone, Default)] +pub struct TypeResolutionContext { + /// Current package, e.g., "com.example" + pub package: Option, + /// Import statements in the file + pub imports: Vec, + /// Type parameters in scope (for generic methods/classes) + pub type_parameters: Vec, + /// Types defined in the current file (FQN list) + pub known_fqns: Vec, +} diff --git a/crates/lang-java/src/inference/core/unification.rs b/crates/lang-java/src/inference/core/unification.rs new file mode 100644 index 0000000..72fd34a --- /dev/null +++ b/crates/lang-java/src/inference/core/unification.rs @@ -0,0 +1,112 @@ +//! Type unification and substitution. +//! +//! Handles generic type parameter resolution and constraints solving. + +use naviscope_api::models::TypeRef; +use std::collections::HashMap; + +/// A map from type variables to concrete types. +#[derive(Debug, Default, Clone)] +pub struct Substitution { + map: HashMap, +} + +impl Substitution { + /// Create a new empty substitution. + pub fn new() -> Self { + Self::default() + } + + /// Add a mapping. + pub fn insert(&mut self, var: String, ty: TypeRef) { + self.map.insert(var, ty); + } + + /// Apply this substitution to a type. + pub fn apply(&self, ty: &TypeRef) -> TypeRef { + match ty { + TypeRef::Id(name) => { + if let Some(sub) = self.map.get(name) { + sub.clone() + } else { + ty.clone() + } + } + TypeRef::Array { + element, + dimensions, + } => TypeRef::Array { + element: Box::new(self.apply(element)), + dimensions: *dimensions, + }, + TypeRef::Generic { base, args } => TypeRef::Generic { + base: Box::new(self.apply(base)), + args: args.iter().map(|arg| self.apply(arg)).collect(), + }, + // Primitives and others remain unchanged + _ => ty.clone(), + } + } +} + +/// Attempt to unify two types, producing a substitution that makes them equal. +/// +/// Note: Very basic implementation. Does not handle deep recursive constraints well yet. +pub fn unify(t1: &TypeRef, t2: &TypeRef) -> Option { + let mut subst = Substitution::new(); + if unify_internal(t1, t2, &mut subst) { + Some(subst) + } else { + None + } +} + +fn unify_internal(t1: &TypeRef, t2: &TypeRef, subst: &mut Substitution) -> bool { + // 1. Resolve current substitutions + let t1_resolved = subst.apply(t1); + let t2_resolved = subst.apply(t2); + + if t1_resolved == t2_resolved { + return true; + } + + match (t1_resolved, t2_resolved) { + // Variable binding (very simplified: treating any single ID as potential var if not FQN) + // In reality, we need to know which IDs are "type variables" vs "concrete types". + // For now, assume single-letter IDs might be variables (heuristic). + (TypeRef::Id(v), other) if is_type_variable(&v) => { + // Occurs check omitted for simplicity + subst.insert(v, other); + true + } + (other, TypeRef::Id(v)) if is_type_variable(&v) => { + subst.insert(v, other); + true + } + + // Structural unification + (TypeRef::Array { element: e1, .. }, TypeRef::Array { element: e2, .. }) => { + unify_internal(&e1, &e2, subst) + } + (TypeRef::Generic { base: b1, args: a1 }, TypeRef::Generic { base: b2, args: a2 }) => { + if !unify_internal(&b1, &b2, subst) { + return false; + } + if a1.len() != a2.len() { + return false; + } + for (arg1, arg2) in a1.iter().zip(a2.iter()) { + if !unify_internal(arg1, arg2, subst) { + return false; + } + } + true + } + _ => false, + } +} + +fn is_type_variable(s: &str) -> bool { + // Heuristic: Single uppercase letter is likely a type var (T, E, K, V) + s.len() == 1 && s.chars().next().unwrap().is_uppercase() +} diff --git a/crates/lang-java/src/inference/mod.rs b/crates/lang-java/src/inference/mod.rs new file mode 100644 index 0000000..4636644 --- /dev/null +++ b/crates/lang-java/src/inference/mod.rs @@ -0,0 +1,82 @@ +//! Java Type Inference System +//! +//! This module provides a functional-style type inference engine for Java. +//! +//! # Architecture +//! +//! ```text +//! InferStrategy (trait) → combines via or_else(), map() +//! │ +//! ▼ +//! InferContext (immutable) → passed through inference chain +//! │ +//! ▼ +//! JavaTypeSystem (trait) → provides type/member lookup +//! ``` +//! +//! # Key Traits +//! +//! - [`TypeProvider`] - Resolves FQN to type information +//! - [`InheritanceProvider`] - Walks supertype hierarchy +//! - [`MemberProvider`] - Finds members in types +//! - [`JavaTypeSystem`] - Combines all three +//! +//! # Usage +//! +//! ```ignore +//! use naviscope_java::inference::{InferContext, infer_expression}; +//! +//! let ctx = InferContext::new(source, &type_system); +//! let result = infer_expression(&node, &ctx); +//! ``` + +pub mod adapters; +mod chain; +pub mod context; +pub mod core; +pub mod scope; +pub mod strategy; + +// Re-export public API +pub use core::type_system::{InheritanceProvider, JavaTypeSystem, MemberProvider, TypeProvider}; + +pub use core::types::{ + MemberInfo, MemberKind, ParameterInfo, TypeInfo, TypeKind, TypeRefExt, TypeResolutionContext, +}; + +pub use context::InferContext; + +pub use chain::{ChainResolution, resolve_chain}; +pub use strategy::{InferStrategy, infer_expression}; + +use crate::inference::scope::{ScopeBuilder, ScopeManager}; + +/// Creates a new `InferContext` that is fully populated with scope information. +/// +/// This helper: +/// 1. Creates a `ScopeManager`. +/// 2. Iterates over the entire source file (AST root) to build scopes. +/// 3. Returns an `InferContext` ready for type inference. +pub fn create_inference_context<'a>( + root: &tree_sitter::Node, + source: &'a str, + ts: &'a dyn JavaTypeSystem, + scope_manager: &'a mut ScopeManager, + package: Option, + imports: Vec, +) -> InferContext<'a> { + let mut ctx = InferContext::new(source, ts) + .with_package(package) + .with_imports(imports); + + // 1. Pre-scan scopes + { + let mut builder = ScopeBuilder::new(&ctx, scope_manager); + builder.build(root); + } // builder dropped, releasing mutable borrow + + // 2. Attach populated scope manager to context + ctx.scope_manager = Some(scope_manager); // Downgrade to shared reference + + ctx +} diff --git a/crates/lang-java/src/inference/scope/builder.rs b/crates/lang-java/src/inference/scope/builder.rs new file mode 100644 index 0000000..f15a013 --- /dev/null +++ b/crates/lang-java/src/inference/scope/builder.rs @@ -0,0 +1,270 @@ +//! Scope Builder implementation. +//! +//! Walks the AST to populate the ScopeManager with variable declarations. + +use super::ScopeManager; +use super::manager::ScopeKind; +use crate::inference::InferContext; +use naviscope_api::models::TypeRef; +use naviscope_plugin::utils::range_from_ts; +use tree_sitter::{Node, TreeCursor}; + +/// Builds the scope tree and symbol table for a method or block. +pub struct ScopeBuilder<'a, 'b> { + ctx: &'a InferContext<'b>, + manager: &'a mut ScopeManager, +} + +impl<'a, 'b> ScopeBuilder<'a, 'b> { + pub fn new(ctx: &'a InferContext<'b>, manager: &'a mut ScopeManager) -> Self { + Self { ctx, manager } + } + + /// Build scopes for the given root node (e.g., method_declaration) + pub fn build(&mut self, root: &Node) { + let mut cursor = root.walk(); + self.visit_node(root, &mut cursor, None, None); + } + + fn visit_node( + &mut self, + node: &Node, + cursor: &mut TreeCursor, + parent_scope: Option, + fqn_prefix: Option, + ) { + let mut current_scope = parent_scope; + let mut next_fqn_prefix = fqn_prefix.clone(); + + // Check if this node creates a new scope + if is_scope_creator(node.kind()) { + let kind = self.determine_scope_kind(node, &fqn_prefix); + + // If it is a class scope, update the prefix for children + if let ScopeKind::Class(ref fqn) = kind { + next_fqn_prefix = Some(fqn.clone()); + } + + // Register new scope + // Use node.id() as the scope ID/key + let scope_id = self.manager.register_scope(node.id(), parent_scope, kind); + current_scope = Some(scope_id); + + // Register parameters if this is a method or lambda or catch + self.register_parameters(node, scope_id); + } + + // Check if this node declares a variable in the current scope + if let Some(scope_id) = current_scope { + self.register_variable_declarations(node, scope_id); + } + + // Recurse children + if node.child_count() > 0 { + if cursor.goto_first_child() { + loop { + let child = cursor.node(); + self.visit_node(&child, cursor, current_scope, next_fqn_prefix.clone()); + if !cursor.goto_next_sibling() { + break; + } + } + cursor.goto_parent(); + } + } + } + + fn determine_scope_kind(&self, node: &Node, prefix: &Option) -> ScopeKind { + match node.kind() { + "class_declaration" + | "interface_declaration" + | "enum_declaration" + | "annotation_type_declaration" => { + let name = node + .child_by_field_name("name") + .and_then(|n| n.utf8_text(self.ctx.source.as_bytes()).ok()) + .unwrap_or("Anonymous"); + + let fqn = if let Some(p) = prefix { + format!("{}.{}", p, name) + } else if let Some(pkg) = &self.ctx.package { + format!("{}.{}", pkg, name) + } else { + name.to_string() + }; + + ScopeKind::Class(fqn) + } + "method_declaration" | "constructor_declaration" => ScopeKind::Method, + _ => ScopeKind::Local, + } + } + + fn register_parameters(&mut self, node: &Node, scope_id: usize) { + match node.kind() { + "method_declaration" | "constructor_declaration" => { + if let Some(params) = node.child_by_field_name("parameters") { + self.process_parameter_list(¶ms, scope_id); + } + } + "lambda_expression" => { + if let Some(params) = node.child_by_field_name("parameters") { + // Start simple: explicit typed params + // Inferred params need type inference (circular dependency if we use InferStrategy here?) + // For now, we only register fully typed params or basic names + self.process_parameter_list(¶ms, scope_id); + } + } + "catch_clause" => { + if let Some(param) = node.child_by_field_name("parameter") { + self.process_single_parameter(¶m, scope_id); + } + } + "enhanced_for_statement" => { + // for (Type name : iterable) + if let (Some(ty_node), Some(name_node)) = ( + node.child_by_field_name("type"), + node.child_by_field_name("name"), + ) { + if let Some(ty) = self.parse_type(&ty_node) { + if let Ok(name) = name_node.utf8_text(self.ctx.source.as_bytes()) { + let range = range_from_ts(name_node.range()); + self.manager + .add_symbol(scope_id, name.to_string(), ty, range); + } + } + } + } + _ => {} + } + } + + fn process_parameter_list(&mut self, params_node: &Node, scope_id: usize) { + let mut cursor = params_node.walk(); + for child in params_node.children(&mut cursor) { + match child.kind() { + "formal_parameter" | "spread_parameter" => { + self.process_single_parameter(&child, scope_id); + } + "inferred_parameters" => { + // TODO: Inferred lambda parameters require target type context + } + "identifier" => { + // Single lambda param: x -> ... + if let Ok(name) = child.utf8_text(self.ctx.source.as_bytes()) { + // Type is unknown at this stage without context + // Register with Unknown type - inference can refine later + let range = range_from_ts(child.range()); + self.manager.add_symbol( + scope_id, + name.to_string(), + TypeRef::Unknown, + range, + ); + } + } + _ => {} + } + } + } + + fn process_single_parameter(&mut self, param: &Node, scope_id: usize) { + let name_node = param.child_by_field_name("name"); + let type_node = param.child_by_field_name("type"); + + if let (Some(name_node), Some(type_node)) = (name_node, type_node) { + if let Ok(name) = name_node.utf8_text(self.ctx.source.as_bytes()) { + if let Some(ty) = self.parse_type(&type_node) { + let range = range_from_ts(name_node.range()); + self.manager + .add_symbol(scope_id, name.to_string(), ty, range); + } + } + } + } + + fn register_variable_declarations(&mut self, node: &Node, scope_id: usize) { + if node.kind() == "local_variable_declaration" { + // Type + let mut ty: Option = None; + + // First child that is not modifier is usually type + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() != "modifiers" && child.kind() != "variable_declarator" { + ty = self.parse_type(&child); + break; + } + } + + if let Some(valid_type) = ty { + // Declarators + for child in node.children(&mut cursor) { + if child.kind() == "variable_declarator" { + if let Some(name_node) = child.child_by_field_name("name") { + if let Ok(name) = name_node.utf8_text(self.ctx.source.as_bytes()) { + let range = range_from_ts(name_node.range()); + self.manager.add_symbol( + scope_id, + name.to_string(), + valid_type.clone(), + range, + ); + } + } + } + } + } else if let Some(_var_node) = node.children(&mut cursor).find(|c| { + c.kind() == "var" || c.utf8_text(self.ctx.source.as_bytes()).unwrap_or("") == "var" + }) { + // Handle 'var' inference + // This is tricky: we are building scope to help inference, but 'var' needs inference. + // We can try to infer the initializer. + // let mut cursor = node.walk(); // Cannot reuse cursor because it's borrowed by children iterator? + // Actually `node.children(&mut cursor)` borrows cursor. + // We need to finish the previous iteration or use a new cursor. + // Since `node.children` consumes the borrow for the loop, we can't reuse it easily if we broke out? + // But here we are reusing `cursor` from line 141. + + // Let's just create a new cursor for the var inference part to avoid complexity + let mut var_cursor = node.walk(); + for child in node.children(&mut var_cursor) { + if child.kind() == "variable_declarator" { + if let (Some(_name_node), Some(_value_node)) = ( + child.child_by_field_name("name"), + child.child_by_field_name("value"), + ) { + // TODO: Recursively call inference on value? + } + } + } + } + } + } + + fn parse_type(&self, node: &Node) -> Option { + use crate::inference::core::normalization::normalize_type; + use crate::inference::strategy::local::parse_type_node; + + let raw_type = parse_type_node(node, self.ctx)?; + Some(normalize_type(raw_type, self.ctx)) + } +} + +fn is_scope_creator(kind: &str) -> bool { + matches!( + kind, + "class_declaration" + | "interface_declaration" + | "enum_declaration" + | "annotation_type_declaration" + | "method_declaration" + | "constructor_declaration" + | "block" + | "lambda_expression" + | "catch_clause" + | "enhanced_for_statement" + | "for_statement" + | "try_with_resources_statement" + ) +} diff --git a/crates/lang-java/src/inference/scope/manager.rs b/crates/lang-java/src/inference/scope/manager.rs new file mode 100644 index 0000000..47735d9 --- /dev/null +++ b/crates/lang-java/src/inference/scope/manager.rs @@ -0,0 +1,130 @@ +//! Scope manager for handling variable scopes. + +use super::table::SymbolTable; +use naviscope_api::models::TypeRef; +use std::collections::HashMap; + +/// Identifier for a scope. +type ScopeId = usize; + +/// Kind of scope +#[derive(Debug, Clone, PartialEq)] +pub enum ScopeKind { + Local, + Method, + Class(String), // FQN +} + +/// A scope containing symbols and a reference to its parent. +#[derive(Debug, Clone)] +pub struct Scope { + pub id: ScopeId, + pub parent_id: Option, + pub symbols: SymbolTable, + pub kind: ScopeKind, +} + +/// Manages scopes and symbol tables for a file. +#[derive(Debug, Default, Clone)] +pub struct ScopeManager { + scopes: HashMap, + /// Map from AST node ID to the Scope ID it corresponds to. + /// Only scope-creating nodes (e.g. Block, MethodDeclaration) are keys here. + node_to_scope: HashMap, +} + +impl ScopeManager { + /// Create a new scope manager. + pub fn new() -> Self { + Self::default() + } + + /// Register a new scope for a given node. + pub fn register_scope( + &mut self, + node_id: usize, + parent_node_id: Option, + kind: ScopeKind, + ) -> ScopeId { + let parent_scope_id = parent_node_id.and_then(|pid| self.node_to_scope.get(&pid).copied()); + + // Use node_id as scope_id for simplicity, assuming they are unique from tree-sitter + let scope_id = node_id; + + let scope = Scope { + id: scope_id, + parent_id: parent_scope_id, + symbols: SymbolTable::new(), + kind, + }; + + self.scopes.insert(scope_id, scope); + self.node_to_scope.insert(node_id, scope_id); + + scope_id + } + + /// Add a symbol to a scope. + pub fn add_symbol( + &mut self, + scope_node_id: usize, + name: String, + ty: TypeRef, + range: naviscope_api::models::symbol::Range, + ) { + if let Some(scope) = self.scopes.get_mut(&scope_node_id) { + scope.symbols.insert(name, ty, range); + } + } + + /// Look up a symbol starting from a specific scope and walking up. + /// Returns the normalized TypeRef. + pub fn lookup(&self, start_scope_id: usize, name: &str) -> Option { + self.lookup_symbol(start_scope_id, name) + .map(|si| si.type_ref) + } + + /// Look up a symbol and its declaration range. + pub fn lookup_symbol( + &self, + start_scope_id: usize, + name: &str, + ) -> Option { + let mut current_scope_id = Some(start_scope_id); + + while let Some(scope_id) = current_scope_id { + if let Some(scope) = self.scopes.get(&scope_id) { + if let Some(info) = scope.symbols.get(name) { + return Some(info.clone()); + } + current_scope_id = scope.parent_id; + } else { + break; + } + } + + None + } + + /// Find the enclosing class FQN for a given scope. + pub fn find_enclosing_class(&self, start_scope_id: usize) -> Option { + let mut current_scope_id = Some(start_scope_id); + + while let Some(scope_id) = current_scope_id { + if let Some(scope) = self.scopes.get(&scope_id) { + if let ScopeKind::Class(fqn) = &scope.kind { + return Some(fqn.clone()); + } + current_scope_id = scope.parent_id; + } else { + break; + } + } + None + } + + /// Get scope ID for a node if it exists + pub fn get_scope_id(&self, node_id: usize) -> Option { + self.node_to_scope.get(&node_id).copied() + } +} diff --git a/crates/lang-java/src/inference/scope/mod.rs b/crates/lang-java/src/inference/scope/mod.rs new file mode 100644 index 0000000..6c7d32d --- /dev/null +++ b/crates/lang-java/src/inference/scope/mod.rs @@ -0,0 +1,7 @@ +pub mod builder; +pub mod manager; +pub mod table; + +pub use builder::ScopeBuilder; +pub use manager::ScopeManager; +pub use table::SymbolTable; diff --git a/crates/lang-java/src/inference/scope/table.rs b/crates/lang-java/src/inference/scope/table.rs new file mode 100644 index 0000000..113ea44 --- /dev/null +++ b/crates/lang-java/src/inference/scope/table.rs @@ -0,0 +1,40 @@ +//! Symbol table for a single scope. + +use naviscope_api::models::TypeRef; +use naviscope_api::models::symbol::Range; +use std::collections::HashMap; + +#[derive(Debug, Clone)] +pub struct SymbolInfo { + pub type_ref: TypeRef, + pub range: Range, +} + +/// A symbol table mapping variable names to their types and ranges. +#[derive(Debug, Default, Clone)] +pub struct SymbolTable { + symbols: HashMap, +} + +impl SymbolTable { + /// Create a new empty symbol table. + pub fn new() -> Self { + Self::default() + } + + /// Insert a variable into the symbol table. + pub fn insert(&mut self, name: String, ty: TypeRef, range: Range) { + self.symbols.insert( + name, + SymbolInfo { + type_ref: ty, + range, + }, + ); + } + + /// Look up a variable in this scope. + pub fn get(&self, name: &str) -> Option<&SymbolInfo> { + self.symbols.get(name) + } +} diff --git a/crates/lang-java/src/inference/strategy/combinator.rs b/crates/lang-java/src/inference/strategy/combinator.rs new file mode 100644 index 0000000..e97ecf8 --- /dev/null +++ b/crates/lang-java/src/inference/strategy/combinator.rs @@ -0,0 +1,88 @@ +//! Combinator implementations for InferStrategy. + +use super::InferStrategy; +use crate::inference::InferContext; +use naviscope_api::models::TypeRef; +use tree_sitter::Node; + +/// Or-else combinator: try first, then second if first returns None. +pub struct OrElse { + first: A, + second: B, +} + +impl OrElse { + pub fn new(first: A, second: B) -> Self { + Self { first, second } + } +} + +impl InferStrategy for OrElse { + fn infer(&self, node: &Node, ctx: &InferContext) -> Option { + self.first + .infer(node, ctx) + .or_else(|| self.second.infer(node, ctx)) + } +} + +// Implement Send + Sync if components are +unsafe impl Send for OrElse {} +unsafe impl Sync for OrElse {} + +/// Map combinator: transform the result. +pub struct Map { + strategy: S, + f: F, +} + +impl Map { + pub fn new(strategy: S, f: F) -> Self { + Self { strategy, f } + } +} + +impl TypeRef + Send + Sync> InferStrategy for Map { + fn infer(&self, node: &Node, ctx: &InferContext) -> Option { + self.strategy.infer(node, ctx).map(&self.f) + } +} + +/// Cached combinator: memoize results. +/// +/// Note: This is a placeholder. Full caching would need thread-safe storage. +pub struct Cached { + strategy: S, + // TODO: Add actual cache with HashMap> +} + +impl Cached { + pub fn new(strategy: S) -> Self { + Self { strategy } + } +} + +impl InferStrategy for Cached { + fn infer(&self, node: &Node, ctx: &InferContext) -> Option { + // TODO: Check cache first, then delegate + self.strategy.infer(node, ctx) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct ReturnInt; + impl InferStrategy for ReturnInt { + fn infer(&self, _: &Node, _: &InferContext) -> Option { + Some(TypeRef::Raw("int".into())) + } + } + + #[test] + fn test_map_transforms_result() { + let mapped = Map::new(ReturnInt, |_: TypeRef| TypeRef::Raw("long".into())); + // Structure test - actual inference needs a Node + let _ = mapped; + } +} diff --git a/crates/lang-java/src/inference/strategy/field.rs b/crates/lang-java/src/inference/strategy/field.rs new file mode 100644 index 0000000..7dc54f4 --- /dev/null +++ b/crates/lang-java/src/inference/strategy/field.rs @@ -0,0 +1,63 @@ +//! Field access inference. + +use super::{InferStrategy, infer_expression}; +use crate::inference::InferContext; +use naviscope_api::models::TypeRef; +use tree_sitter::Node; + +/// Infer type from field access expression (obj.field). +pub struct FieldAccessInfer; + +impl InferStrategy for FieldAccessInfer { + fn infer(&self, node: &Node, ctx: &InferContext) -> Option { + let member = self.resolve_member(node, ctx)?; + Some(member.type_ref.clone()) + } +} + +impl FieldAccessInfer { + /// Resolve the field access to its member definition. + pub fn infer_member(&self, node: &Node, ctx: &InferContext) -> Option { + let member = self.resolve_member(node, ctx)?; + Some(member.fqn.clone()) + } + + fn resolve_member( + &self, + node: &Node, + ctx: &InferContext, + ) -> Option { + let field_node = if node.kind() == "field_access" { + *node + } else if let Some(parent) = node.parent() { + if parent.kind() == "field_access" && parent.child_by_field_name("field") == Some(*node) + { + parent + } else { + return None; + } + } else { + return None; + }; + + // Get the receiver (object before the dot) + let receiver = field_node.child_by_field_name("object")?; + + // Get the field name + let name_node = field_node.child_by_field_name("field")?; + let field_name = name_node.utf8_text(ctx.source.as_bytes()).ok()?; + + // Infer the receiver type (recursive) + let receiver_type = infer_expression(&receiver, ctx)?; + + // Get the FQN from the receiver type + let type_fqn = match &receiver_type { + TypeRef::Id(fqn) => fqn.clone(), + _ => return None, + }; + + // Look up the field in the type hierarchy + let members = ctx.ts.find_member_in_hierarchy(&type_fqn, field_name); + members.first().cloned() + } +} diff --git a/crates/lang-java/src/inference/strategy/lambda.rs b/crates/lang-java/src/inference/strategy/lambda.rs new file mode 100644 index 0000000..ae0e61e --- /dev/null +++ b/crates/lang-java/src/inference/strategy/lambda.rs @@ -0,0 +1,40 @@ +//! Lambda expression type inference. +//! +//! Requires checking mode (bidirectional inference) as lambdas don't have intrinsic types. + +use crate::inference::{InferContext, InferStrategy, TypeKind, TypeRefExt}; +use naviscope_api::models::TypeRef; +use tree_sitter::Node; + +/// Strategy to infer types of lambda expressions. +pub struct LambdaInfer; + +impl InferStrategy for LambdaInfer { + fn infer(&self, node: &Node, ctx: &InferContext) -> Option { + // Lambdas do not possess a standalone type; they require a target type. + // If we are in synthesis mode (infer), we check if the context provides an expected type. + if let Some(expected) = &ctx.expected_type { + return self.check(node, expected, ctx); + } + None + } + + fn check(&self, node: &Node, expected: &TypeRef, ctx: &InferContext) -> Option { + if node.kind() != "lambda_expression" { + return None; + } + + let expected_fqn = expected.as_fqn()?; + + let type_info = ctx.ts.get_type_info(&expected_fqn)?; + + // Basic check: is it an interface? + // This is a heuristic. A proper check would verify functional interface status. + if type_info.kind == TypeKind::Interface { + // Return the expected type as the inferred type of the lambda + Some(expected.clone()) + } else { + None + } + } +} diff --git a/crates/lang-java/src/inference/strategy/local.rs b/crates/lang-java/src/inference/strategy/local.rs new file mode 100644 index 0000000..43cc2d2 --- /dev/null +++ b/crates/lang-java/src/inference/strategy/local.rs @@ -0,0 +1,91 @@ +//! Local variable type inference. + +use super::InferStrategy; +use crate::inference::InferContext; +use naviscope_api::models::TypeRef; +use tree_sitter::Node; + +/// Infer type from local variable declaration. +pub struct LocalVarInfer; + +impl InferStrategy for LocalVarInfer { + fn infer(&self, node: &Node, ctx: &InferContext) -> Option { + // Only works for identifier nodes + if node.kind() != "identifier" { + return None; + } + + let name = node.utf8_text(ctx.source.as_bytes()).ok()?; + + // Optimize: If ScopeManager is available, rely on it exclusively. + // We do NOT fallback to AST walking if lookup fails, because if a ScopeManager + // is provided, it is expected to be complete. Fallback would only hide bugs. + // + // NOTE: If scope_manager is None, we now return None. + // This forces integrators to use ScopeManager for local variable inference. + if let Some(sm) = ctx.scope_manager { + // Find the nearest scope-owning ancestor + let mut current = *node; + while let Some(parent) = current.parent() { + // Check if this parent node owns a scope + if sm.get_scope_id(parent.id()).is_some() { + // Delegate to ScopeManager to lookup variable starting from this scope + // The lookup method will automatically traverse up the scope chain + return sm.lookup(parent.id(), name); + } + current = parent; + } + return None; + } + + None + } +} + +/// Parse a type node into TypeRef. +pub fn parse_type_node(node: &Node, ctx: &InferContext) -> Option { + let kind = node.kind(); + + match kind { + // Primitive types + "integral_type" | "floating_point_type" | "boolean_type" | "void_type" => { + let text = node.utf8_text(ctx.source.as_bytes()).ok()?; + Some(TypeRef::Raw(text.to_string())) + } + // Simple type identifier + "type_identifier" => { + let name = node.utf8_text(ctx.source.as_bytes()).ok()?; + // Try to resolve to FQN + let fqn = ctx + .ts + .resolve_type_name(name, &ctx.to_resolution_context()) + .unwrap_or_else(|| name.to_string()); + Some(TypeRef::Id(fqn)) + } + // Scoped type like java.util.List + "scoped_type_identifier" => { + let text = node.utf8_text(ctx.source.as_bytes()).ok()?; + Some(TypeRef::Id(text.replace(" ", ""))) + } + // Generic type like List + "generic_type" => { + // For now, just get the raw type (ignore type args) + node.child(0).and_then(|c| parse_type_node(&c, ctx)) + } + // Array type + "array_type" => { + let element = node.child_by_field_name("element")?; + let element_type = parse_type_node(&element, ctx)?; + Some(TypeRef::Array { + element: Box::new(element_type), + dimensions: 1, // TODO: count dimensions properly + }) + } + _ => { + // Unknown type node, try raw text + node.utf8_text(ctx.source.as_bytes()) + .ok() + .map(|s| TypeRef::Raw(s.to_string())) + } + } +} diff --git a/crates/lang-java/src/inference/strategy/method.rs b/crates/lang-java/src/inference/strategy/method.rs new file mode 100644 index 0000000..c9905c5 --- /dev/null +++ b/crates/lang-java/src/inference/strategy/method.rs @@ -0,0 +1,88 @@ +//! Method invocation inference. + +use super::{InferStrategy, infer_expression}; +use crate::inference::InferContext; +use naviscope_api::models::TypeRef; +use tree_sitter::Node; + +/// Infer type from method invocation (obj.method() or method()). +pub struct MethodCallInfer; + +impl InferStrategy for MethodCallInfer { + fn infer(&self, node: &Node, ctx: &InferContext) -> Option { + let member = self.resolve_member(node, ctx)?; + Some(member.type_ref) + } +} + +impl MethodCallInfer { + /// Resolve the method call to its member definition. + pub fn infer_member(&self, node: &Node, ctx: &InferContext) -> Option { + let member = self.resolve_member(node, ctx)?; + Some(member.fqn) + } + + fn resolve_member( + &self, + node: &Node, + ctx: &InferContext, + ) -> Option { + let call_node = if node.kind() == "method_invocation" { + *node + } else if let Some(parent) = node.parent() { + if parent.kind() == "method_invocation" + && parent.child_by_field_name("name") == Some(*node) + { + parent + } else { + return None; + } + } else { + return None; + }; + + // Get method name + let name_node = call_node.child_by_field_name("name")?; + let method_name = name_node.utf8_text(ctx.source.as_bytes()).ok()?; + + // Get receiver, if any + let receiver_type = if let Some(receiver) = call_node.child_by_field_name("object") { + // obj.method() + infer_expression(&receiver, ctx)? + } else { + // method() - implicit this or static import + if let Some(ref enclosing) = ctx.enclosing_class { + TypeRef::Id(enclosing.clone()) + } else { + return None; + } + }; + + // Get the FQN from the receiver type + let type_fqn = match &receiver_type { + TypeRef::Id(fqn) => fqn.clone(), + _ => return None, + }; + + // Get argument types + let mut arg_types = Vec::new(); + if let Some(args_node) = call_node.child_by_field_name("arguments") { + let mut cursor = args_node.walk(); + for child in args_node.children(&mut cursor) { + if child.is_named() { + if let Some(t) = infer_expression(&child, ctx) { + arg_types.push(t); + } else { + arg_types.push(TypeRef::Unknown); + } + } + } + } + + // Look up the method candidates in the type hierarchy + let candidates = ctx.ts.find_member_in_hierarchy(&type_fqn, method_name); + + // Resolve the best match among candidates + ctx.ts.resolve_method(&candidates, &arg_types) + } +} diff --git a/crates/lang-java/src/inference/strategy/mod.rs b/crates/lang-java/src/inference/strategy/mod.rs new file mode 100644 index 0000000..7a7fe22 --- /dev/null +++ b/crates/lang-java/src/inference/strategy/mod.rs @@ -0,0 +1,133 @@ +//! Inference strategies using combinator pattern. +//! +//! Each strategy implements [`InferStrategy`] and can be combined using +//! `or_else()`, `map()`, etc. + +mod combinator; +mod field; +mod lambda; + +mod method; +mod new_expr; +mod this; + +mod type_id; + +pub use combinator::{Cached, Map, OrElse}; +pub use field::FieldAccessInfer; +pub use lambda::LambdaInfer; +pub use type_id::TypeIdentifierInfer; +pub mod local; +pub use local::LocalVarInfer; +pub use method::MethodCallInfer; +pub use new_expr::NewExprInfer; +pub use this::ThisInfer; + +use crate::inference::InferContext; +use naviscope_api::models::TypeRef; +use tree_sitter::Node; + +/// A type inference strategy. +/// +/// Strategies are composable using combinator methods. +/// Each strategy attempts to infer the type of an AST node. +pub trait InferStrategy: Sync + Send { + /// Attempt to infer the type of the given node. + /// + /// Returns `None` if this strategy doesn't apply or can't determine the type. + fn infer(&self, node: &Node, ctx: &InferContext) -> Option; + + /// Check if the node type matches the expected type (Bidirectional Inference). + /// + /// Default implementation purely relies on synthesis (infer) and subtyping check. + fn check(&self, node: &Node, expected: &TypeRef, ctx: &InferContext) -> Option { + let inferred = self.infer(node, ctx)?; + if ctx.ts.is_subtype(&inferred, expected) { + Some(expected.clone()) // Return expected (more precise usually) + } else { + None + } + } + + /// Combine with another strategy using "or" logic. + /// + /// If `self` returns `None`, try `other`. + fn or_else(self, other: S) -> OrElse + where + Self: Sized, + { + OrElse::new(self, other) + } + + /// Transform the result using a function. + fn map(self, f: F) -> Map + where + Self: Sized, + F: Fn(TypeRef) -> TypeRef + Send + Sync, + { + Map::new(self, f) + } + + /// Wrap with caching (memoization). + fn cached(self) -> Cached + where + Self: Sized, + { + Cached::new(self) + } +} + +/// Build the default expression inferrer. +/// +/// This combines all strategies in priority order. +pub fn build_expression_inferrer() -> impl InferStrategy { + ThisInfer + .or_else(LocalVarInfer) + .or_else(FieldAccessInfer) + .or_else(MethodCallInfer) + .or_else(NewExprInfer) + .or_else(LambdaInfer) + .or_else(TypeIdentifierInfer) +} + +/// Infer the type of an expression node. +/// +/// This is the main entry point for expression type inference. +pub fn infer_expression(node: &Node, ctx: &InferContext) -> Option { + // TODO: Use lazy_static for the inferrer once all strategies are complete + let inferrer = build_expression_inferrer(); + inferrer.infer(node, ctx) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Dummy strategy for testing combinators + struct AlwaysNone; + impl InferStrategy for AlwaysNone { + fn infer(&self, _: &Node, _: &InferContext) -> Option { + None + } + } + + struct AlwaysSome(TypeRef); + impl InferStrategy for AlwaysSome { + fn infer(&self, _: &Node, _: &InferContext) -> Option { + Some(self.0.clone()) + } + } + + #[test] + fn test_or_else_first_succeeds() { + let s = AlwaysSome(TypeRef::Raw("int".into())).or_else(AlwaysNone); + // Can't easily test without a Node, but the structure is correct + let _ = s; + } + + #[test] + fn test_or_else_fallback() { + let s = AlwaysNone.or_else(AlwaysSome(TypeRef::Raw("String".into()))); + let _ = s; + } +} diff --git a/crates/lang-java/src/inference/strategy/new_expr.rs b/crates/lang-java/src/inference/strategy/new_expr.rs new file mode 100644 index 0000000..eaafa64 --- /dev/null +++ b/crates/lang-java/src/inference/strategy/new_expr.rs @@ -0,0 +1,36 @@ +//! `new` expression inference. + +use super::InferStrategy; +use crate::inference::InferContext; +use naviscope_api::models::TypeRef; +use tree_sitter::Node; + +/// Infer type of `new Type()` expression. +pub struct NewExprInfer; + +impl InferStrategy for NewExprInfer { + fn infer(&self, node: &Node, ctx: &InferContext) -> Option { + if node.kind() != "object_creation_expression" { + return None; + } + + // Get the type being constructed + let type_node = node.child_by_field_name("type")?; + let type_name = type_node.utf8_text(ctx.source.as_bytes()).ok()?; + + // Handle generic types: get just the base type + let base_type = if let Some(idx) = type_name.find('<') { + &type_name[..idx] + } else { + type_name + }; + + // Resolve to FQN + let fqn = ctx + .ts + .resolve_type_name(base_type, &ctx.to_resolution_context()) + .unwrap_or_else(|| base_type.to_string()); + + Some(TypeRef::Id(fqn)) + } +} diff --git a/crates/lang-java/src/inference/strategy/this.rs b/crates/lang-java/src/inference/strategy/this.rs new file mode 100644 index 0000000..74a18fe --- /dev/null +++ b/crates/lang-java/src/inference/strategy/this.rs @@ -0,0 +1,20 @@ +//! `this` expression inference. + +use super::InferStrategy; +use crate::inference::InferContext; +use naviscope_api::models::TypeRef; +use tree_sitter::Node; + +/// Infer type of `this` expression. +pub struct ThisInfer; + +impl InferStrategy for ThisInfer { + fn infer(&self, node: &Node, ctx: &InferContext) -> Option { + if node.kind() != "this" { + return None; + } + + // Return the enclosing class type + ctx.enclosing_class.as_ref().map(|c| TypeRef::Id(c.clone())) + } +} diff --git a/crates/lang-java/src/inference/strategy/type_id.rs b/crates/lang-java/src/inference/strategy/type_id.rs new file mode 100644 index 0000000..85387de --- /dev/null +++ b/crates/lang-java/src/inference/strategy/type_id.rs @@ -0,0 +1,59 @@ +use crate::inference::InferContext; +use crate::inference::strategy::InferStrategy; +use naviscope_api::models::TypeRef; +use tree_sitter::Node; + +/// Infers the type of a plain identifier by looking it up in the type system. +/// +/// This is used for type names in various contexts (declarations, casts, etc.) +/// and as a final fallback for expressions that might be static type references. +pub struct TypeIdentifierInfer; + +impl InferStrategy for TypeIdentifierInfer { + fn infer(&self, node: &Node, ctx: &InferContext) -> Option { + if !matches!(node.kind(), "identifier" | "type_identifier") { + return None; + } + + let name = node.utf8_text(ctx.source.as_bytes()).ok()?; + let res_ctx = ctx.to_resolution_context(); + + // 0. Check if this is part of a scoped type (e.g., Outer.Inner) + if let Some(parent) = node.parent() { + if parent.kind() == "scoped_type_identifier" { + // Get the full scoped name + let full_name = parent.utf8_text(ctx.source.as_bytes()).ok()?; + // Replace '.' with '$' for inner class naming convention if needed + let normalized = full_name.replace(" ", ""); + + // Try to resolve the full path + if let Some(fqn) = ctx.ts.resolve_type_name(&normalized, &res_ctx) { + return Some(TypeRef::Id(fqn)); + } + + // Try with inner class convention (Outer$Inner or Outer.Inner) + if let Some(fqn) = ctx + .ts + .resolve_type_name(&normalized.replace(".", "$"), &res_ctx) + { + return Some(TypeRef::Id(fqn)); + } + } + } + + // 1. Try to resolve as a type name + if let Some(fqn) = ctx.ts.resolve_type_name(name, &res_ctx) { + return Some(TypeRef::Id(fqn)); + } + + // 2. Try to resolve as an implicit this.field + if let Some(enclosing) = &ctx.enclosing_class { + let members = ctx.ts.find_member_in_hierarchy(enclosing, name); + if let Some(member) = members.first() { + return Some(member.type_ref.clone()); + } + } + + None + } +} diff --git a/crates/lang-java/src/lib.rs b/crates/lang-java/src/lib.rs index 2121d0c..af4e981 100644 --- a/crates/lang-java/src/lib.rs +++ b/crates/lang-java/src/lib.rs @@ -1,4 +1,5 @@ pub mod discoverer; +pub mod inference; pub mod jdk; pub mod model; pub mod naming; diff --git a/crates/lang-java/src/naming.rs b/crates/lang-java/src/naming.rs index 794285d..5ac8989 100644 --- a/crates/lang-java/src/naming.rs +++ b/crates/lang-java/src/naming.rs @@ -1,9 +1,75 @@ use naviscope_api::models::NodeKind; use naviscope_plugin::NamingConvention; +/// Separator used between a type and its members (methods, fields, constructors). +pub const MEMBER_SEPARATOR: char = '#'; + +/// Separator used between packages and between package/class. +pub const TYPE_SEPARATOR: char = '.'; + #[derive(Debug, Clone, Copy, Default)] pub struct JavaNamingConvention; +impl JavaNamingConvention { + /// Build a fully qualified name for a member (method, field, or constructor). + /// + /// # Examples + /// ```ignore + /// build_member_fqn("com.example.MyClass", "myMethod") => "com.example.MyClass#myMethod" + /// build_member_fqn("com.example.MyClass", "myField") => "com.example.MyClass#myField" + /// ``` + pub fn build_member_fqn(type_fqn: &str, member_name: &str) -> String { + format!("{}{}{}", type_fqn, MEMBER_SEPARATOR, member_name) + } + + /// Parse a member FQN into (type_fqn, member_name). + /// + /// Returns `None` if the FQN does not contain a member separator. + /// + /// # Examples + /// ```ignore + /// parse_member_fqn("com.example.MyClass#myMethod") => Some(("com.example.MyClass", "myMethod")) + /// parse_member_fqn("com.example.MyClass") => None + /// ``` + pub fn parse_member_fqn(fqn: &str) -> Option<(&str, &str)> { + fqn.rfind(MEMBER_SEPARATOR) + .map(|pos| (&fqn[..pos], &fqn[pos + 1..])) + } + + /// Check if an FQN represents a member (method, field, constructor). + pub fn is_member_fqn(fqn: &str) -> bool { + fqn.contains(MEMBER_SEPARATOR) + } + + /// Extract the type FQN from a member FQN. + /// + /// If the FQN is already a type FQN (no member separator), returns the original. + pub fn extract_type_fqn(fqn: &str) -> &str { + Self::parse_member_fqn(fqn) + .map(|(type_fqn, _)| type_fqn) + .unwrap_or(fqn) + } + + /// Extract the member name from a member FQN. + /// + /// Returns `None` if the FQN is not a member FQN. + pub fn extract_member_name(fqn: &str) -> Option<&str> { + Self::parse_member_fqn(fqn).map(|(_, member)| member) + } + + /// Get the appropriate separator for the given intent. + /// + /// Members (Method, Field) use `#`, others use `.`. + /// Note: Constructor is represented by SymbolIntent::Method. + pub fn separator_for_intent(intent: naviscope_api::models::SymbolIntent) -> char { + use naviscope_api::models::SymbolIntent; + match intent { + SymbolIntent::Method | SymbolIntent::Field => MEMBER_SEPARATOR, + _ => TYPE_SEPARATOR, + } + } +} + impl NamingConvention for JavaNamingConvention { fn separator(&self) -> &str { "." diff --git a/crates/lang-java/src/parser/scope.rs b/crates/lang-java/src/parser/scope.rs index a3f4b89..e726a30 100644 --- a/crates/lang-java/src/parser/scope.rs +++ b/crates/lang-java/src/parser/scope.rs @@ -1,54 +1,8 @@ use super::JavaParser; use naviscope_api::models::SymbolIntent; -use naviscope_api::models::symbol::Range; -use naviscope_plugin::utils::range_from_ts; use tree_sitter::Node; impl JavaParser { - pub fn find_local_declaration( - &self, - start_node: Node, - name: &str, - source: &str, - ) -> Option<(Range, Option)> { - self.find_local_declaration_node(start_node, name, source) - .map(|(range, type_node)| { - let type_name = type_node - .and_then(|t| t.utf8_text(source.as_bytes()).ok().map(|s| s.to_string())); - (range, type_name) - }) - } - - pub fn find_local_declaration_node<'a>( - &self, - start_node: Node<'a>, - name: &str, - source: &str, - ) -> Option<(Range, Option>)> { - let mut curr = start_node; - while let Some(parent) = curr.parent() { - // Check declarations in this scope before or at the start_node (for parameters) - let mut child_cursor = parent.walk(); - for child in parent.children(&mut child_cursor) { - if let Some(res) = self.is_decl_of_node(&child, name, source) { - // If the declaration is the node itself (like a parameter), or strictly before it - if child.start_byte() <= start_node.start_byte() { - return Some(res); - } - } - if child.start_byte() >= start_node.start_byte() { - break; - } - } - // Check if parent itself is a declaration (like method parameters) - if let Some(res) = self.is_decl_of_node(&parent, name, source) { - return Some(res); - } - curr = parent; - } - None - } - pub fn determine_intent(&self, node: &Node) -> SymbolIntent { let parent = match node.parent() { Some(p) => p, @@ -61,7 +15,7 @@ impl JavaParser { return SymbolIntent::Method; } } - SymbolIntent::Type // Likely the receiver/object + SymbolIntent::Unknown // Could be variable, field, or type - resolver will determine } "method_reference" => SymbolIntent::Type, "object_creation_expression" => { @@ -82,7 +36,7 @@ impl JavaParser { return SymbolIntent::Field; } } - SymbolIntent::Type // Likely the receiver/object + SymbolIntent::Unknown // Could be variable, field, or type - resolver will determine } "class_declaration" | "interface_declaration" @@ -105,69 +59,4 @@ impl JavaParser { } } } - - pub fn is_decl_of_node<'a>( - &self, - node: &Node<'a>, - name: &str, - source: &str, - ) -> Option<(Range, Option>)> { - match node.kind() { - "variable_declarator" | "formal_parameter" | "catch_formal_parameter" => { - if let Some(name_node) = node.child_by_field_name("name") { - if name_node.utf8_text(source.as_bytes()).ok()? == name { - let range = range_from_ts(name_node.range()); - let type_node = if node.kind() == "variable_declarator" { - // Type is in the parent local_variable_declaration - node.parent().and_then(|p| p.child_by_field_name("type")) - } else { - // Type is a sibling for parameters - node.child_by_field_name("type") - }; - return Some((range, type_node)); - } - } - } - "local_variable_declaration" - | "formal_parameters" - | "inferred_parameters" - | "enhanced_for_statement" - | "lambda_expression" => { - if node.kind() == "lambda_expression" { - if let Some(params) = node.child_by_field_name("parameters") { - if params.kind() == "identifier" { - if params.utf8_text(source.as_bytes()).ok()? == name { - return Some((range_from_ts(params.range()), None)); - } - } else { - return self.is_decl_of_node(¶ms, name, source); - } - } - return None; - } - let mut cursor = node.walk(); - for child in node.children(&mut cursor) { - if let Some(res) = self.is_decl_of_node(&child, name, source) { - return Some(res); - } - } - } - _ => {} - } - None - } - - pub fn is_decl_of( - &self, - node: &Node, - name: &str, - source: &str, - ) -> Option<(Range, Option)> { - self.is_decl_of_node(node, name, source) - .map(|(range, type_node)| { - let type_name = type_node - .and_then(|t| t.utf8_text(source.as_bytes()).ok().map(|s| s.to_string())); - (range, type_name) - }) - } } diff --git a/crates/lang-java/src/resolver/lang.rs b/crates/lang-java/src/resolver/lang.rs new file mode 100644 index 0000000..2e42a7e --- /dev/null +++ b/crates/lang-java/src/resolver/lang.rs @@ -0,0 +1,274 @@ +use crate::inference::adapters::HeuristicAdapter; +use crate::inference::{TypeProvider, TypeResolutionContext}; +use crate::model::JavaIndexMetadata; +use crate::resolver::JavaResolver; +use crate::resolver::context::ResolutionContext; +use naviscope_api::models::graph::{EdgeType, GraphEdge, NodeKind}; +use naviscope_api::models::symbol::{NodeId, SymbolResolution, TypeRef}; +use naviscope_plugin::{ + GraphOp, IndexNode, LangResolver, ParsedContent, ParsedFile, ProjectContext, ResolvedUnit, +}; +use std::sync::Arc; + +impl LangResolver for JavaResolver { + fn resolve( + &self, + file: &ParsedFile, + context: &ProjectContext, + ) -> std::result::Result> { + let mut unit = ResolvedUnit::new(); + let dummy_index = naviscope_plugin::EmptyCodeGraph; + let type_provider = HeuristicAdapter; + + let parse_result_owned; + let parse_result = match &file.content { + ParsedContent::Language(res) => res, + ParsedContent::Unparsed(src) => { + if file.path().extension().map_or(false, |e| e == "java") { + parse_result_owned = self.parser.parse_file(src, Some(&file.file.path))?; + &parse_result_owned + } else { + return Ok(unit); + } + } + ParsedContent::Lazy => { + if file.path().extension().map_or(false, |e| e == "java") { + let src = std::fs::read_to_string(file.path()).map_err(|e| { + format!("Failed to read file {}: {}", file.path().display(), e) + })?; + parse_result_owned = self.parser.parse_file(&src, Some(&file.file.path))?; + &parse_result_owned + } else { + return Ok(unit); + } + } + _ => return Ok(unit), + }; + + { + unit.identifiers = parse_result.output.identifiers.clone(); + unit.ops.push(GraphOp::UpdateIdentifiers { + path: Arc::from(file.file.path.as_path()), + identifiers: unit.identifiers.clone(), + }); + + let module_id = context + .find_module_for_path(&file.file.path) + .unwrap_or_else(|| "module::root".to_string()); + + let container_id = if let Some(pkg_name) = &parse_result.package_name { + let package_parts: Vec<_> = pkg_name + .split('.') + .map(|s| { + ( + naviscope_api::models::graph::NodeKind::Package, + s.to_string(), + ) + }) + .collect(); + let package_id = NodeId::Structured(package_parts); + + let package_node = IndexNode { + id: package_id.clone(), + name: pkg_name.to_string(), + kind: NodeKind::Package, + lang: "java".to_string(), + source: naviscope_api::models::graph::NodeSource::Project, + status: naviscope_api::models::graph::ResolutionStatus::Resolved, + location: None, + metadata: Arc::new(JavaIndexMetadata::Package), + }; + + unit.add_node(package_node); + + unit.add_edge( + module_id.clone().into(), + package_id.clone(), + GraphEdge::new(EdgeType::Contains), + ); + + package_id + } else { + module_id.into() + }; + + let mut known_types = std::collections::HashSet::::new(); + let mut local_type_map = std::collections::HashMap::::new(); + + for node in &parse_result.output.nodes { + if matches!( + node.kind, + NodeKind::Class | NodeKind::Interface | NodeKind::Enum | NodeKind::Annotation + ) { + known_types.insert(node.id.to_string()); + } + } + + let res_ctx = TypeResolutionContext { + package: parse_result.package_name.clone(), + imports: parse_result.imports.clone(), + type_parameters: Vec::new(), + known_fqns: known_types.iter().cloned().collect(), + }; + + for node in &parse_result.output.nodes { + let mut node = node.clone(); + + if let Some(java_idx_meta) = + node.metadata.as_any().downcast_ref::() + { + let mut element = java_idx_meta.clone(); + + match &mut element { + JavaIndexMetadata::Method { + return_type, + parameters, + .. + } => { + *return_type = + self.resolve_type_ref(return_type, &type_provider, &res_ctx); + for param in parameters { + param.type_ref = self.resolve_type_ref( + ¶m.type_ref, + &type_provider, + &res_ctx, + ); + if let TypeRef::Id(type_fqn) = ¶m.type_ref { + local_type_map.insert(node.name.clone(), type_fqn.clone()); + } + } + } + JavaIndexMetadata::Field { type_ref, .. } => { + *type_ref = self.resolve_type_ref(type_ref, &type_provider, &res_ctx); + if let TypeRef::Id(type_fqn) = &type_ref { + local_type_map.insert(node.name.clone(), type_fqn.clone()); + } + } + _ => {} + } + node.metadata = Arc::new(element); + } + + let is_top = matches!( + node.kind, + NodeKind::Class | NodeKind::Interface | NodeKind::Enum | NodeKind::Annotation + ); + + unit.add_node(node.clone()); + if is_top { + unit.add_edge( + container_id.clone().into(), + node.id.clone(), + GraphEdge::new(EdgeType::Contains), + ); + } + } + + for rel in &parse_result.output.relations { + let mut resolved_target_str = rel.target_id.to_string(); + + if let (Some(tree), Some(source)) = (&parse_result.tree, &parse_result.source) { + if let Some(r) = &rel.range { + let point = tree_sitter::Point::new(r.start_line, r.start_col); + if let Some(node) = tree + .root_node() + .named_descendant_for_point_range(point, point) + { + let context = ResolutionContext::new_with_unit( + node, + rel.target_id.to_string(), + &dummy_index, + Some(&unit), + source, + tree, + &self.parser, + ); + + if let Some(SymbolResolution::Precise(fqn, _)) = + self.resolve_symbol_internal(&context) + { + resolved_target_str = fqn; + } else { + // Fallback + if !resolved_target_str.contains('.') { + if let Some(res) = type_provider + .resolve_type_name(&resolved_target_str, &res_ctx) + { + resolved_target_str = res; + } + } + } + } + } + } + + let edge = GraphEdge::new(rel.edge_type.clone()); + + if resolved_target_str == rel.target_id.to_string() + && matches!(rel.target_id, NodeId::Structured(_)) + { + unit.add_edge(rel.source_id.clone(), rel.target_id.clone(), edge); + continue; + } + + let segments: Vec<&str> = resolved_target_str + .split(|c| c == '.' || c == '#') + .collect(); + let mut structured_parts: Vec<(NodeKind, String)> = Vec::new(); + + for (i, part) in segments.iter().enumerate() { + let mut found_kind = NodeKind::Package; + let is_last = i == segments.len() - 1; + + let candidates = [ + NodeKind::Class, + NodeKind::Interface, + NodeKind::Enum, + NodeKind::Annotation, + NodeKind::Method, + NodeKind::Field, + NodeKind::Constructor, + ]; + + let mut matched = false; + for k in &candidates { + let mut probe_parts = structured_parts.clone(); + probe_parts.push((k.clone(), part.to_string())); + let id = NodeId::Structured(probe_parts); + if unit.nodes.contains_key(&id) { + found_kind = k.clone(); + matched = true; + break; + } + } + + if !matched { + if is_last { + if rel.edge_type == EdgeType::Implements + || rel.edge_type == EdgeType::InheritsFrom + || rel.edge_type == EdgeType::TypedAs + || rel.edge_type == EdgeType::DecoratedBy + { + found_kind = NodeKind::Class; + } else if part.chars().next().map_or(false, |c| c.is_uppercase()) { + found_kind = NodeKind::Class; + } + } else if part.chars().next().map_or(false, |c| c.is_uppercase()) { + found_kind = NodeKind::Class; + } else { + found_kind = NodeKind::Package; + } + } + + structured_parts.push((found_kind, part.to_string())); + } + + let final_target_id = NodeId::Structured(structured_parts); + + unit.add_edge(rel.source_id.clone(), final_target_id, edge); + } + } + + Ok(unit) + } +} diff --git a/crates/lang-java/src/resolver/mod.rs b/crates/lang-java/src/resolver/mod.rs index a1b113d..d7b83cf 100644 --- a/crates/lang-java/src/resolver/mod.rs +++ b/crates/lang-java/src/resolver/mod.rs @@ -1,22 +1,15 @@ -use crate::model::{JavaIndexMetadata, JavaNodeMetadata}; -use crate::parser::JavaParser; -use naviscope_api::models::graph::{EdgeType, GraphEdge, NodeKind}; -use naviscope_api::models::symbol::{FqnId, matches_intent}; -use naviscope_api::models::{SymbolIntent, SymbolResolution, TypeRef}; -use naviscope_plugin::{ - CodeGraph, GraphOp, IndexNode, LangResolver, ParsedContent, ParsedFile, ProjectContext, - ResolvedUnit, SemanticResolver, -}; -use std::ops::ControlFlow; -use std::sync::Arc; -use tree_sitter::Tree; +//! Java resolver using the new inference-based type system. pub mod context; pub mod external; -pub mod scope; +pub mod lang; +pub mod semantic; +pub mod types; +use crate::inference::adapters::CodeGraphTypeSystem; +use crate::parser::JavaParser; use context::ResolutionContext; -use scope::{BuiltinScope, ImportScope, LocalScope, MemberScope, PackageScope, Scope}; +use naviscope_api::models::{SymbolResolution, TypeRef}; #[derive(Clone)] pub struct JavaResolver { @@ -30,574 +23,194 @@ impl JavaResolver { } } - fn get_active_scopes<'a>(&'a self, ctx: &'a ResolutionContext) -> Vec> { - let mut scopes: Vec> = Vec::new(); - - if ctx.receiver_node.is_none() { - scopes.push(Box::new(LocalScope { - parser: &self.parser, - })); - } - - scopes.push(Box::new(MemberScope { - parser: &self.parser, - })); - scopes.push(Box::new(ImportScope { - parser: &self.parser, - })); - scopes.push(Box::new(PackageScope { - parser: &self.parser, - })); - - if ctx.intent == SymbolIntent::Type { - scopes.push(Box::new(BuiltinScope { - parser: &self.parser, - })); - } - - scopes - } - - fn resolve_type_ref( + /// Helper to find enclosing class using ScopeManager + fn find_enclosing_class_via_scope( &self, - type_ref: &TypeRef, - package: Option<&str>, - imports: &[String], - known_fqns: &std::collections::HashSet, - ) -> TypeRef { - match type_ref { - TypeRef::Raw(name) => { - // 1. Check if name matches a known FQN suffix in the same file (Inner class priority) - if let Some(fqn) = known_fqns - .iter() - .find(|k| k.ends_with(&format!(".{}", name)) || *k == name) - { - // Simple heuristic: if the name matches the end of a known FQN, use it. - // This handles 'Source' -> '...DefaultApplicationArguments.Source' - return TypeRef::Id(fqn.clone()); - } - - if let Some(fqn) = self - .parser - .resolve_type_name_to_fqn_data(name, package, imports) - { - TypeRef::Id(fqn) - } else { - TypeRef::Raw(name.clone()) - } + node: tree_sitter::Node, + scope_manager: &crate::inference::scope::ScopeManager, + ) -> Option { + let mut current = node; + while let Some(parent) = current.parent() { + if let Some(sid) = scope_manager.get_scope_id(parent.id()) { + return scope_manager.find_enclosing_class(sid); } - TypeRef::Generic { base, args } => TypeRef::Generic { - base: Box::new(self.resolve_type_ref(base, package, imports, known_fqns)), - args: args - .iter() - .map(|a| self.resolve_type_ref(a, package, imports, known_fqns)) - .collect(), - }, - TypeRef::Array { - element, - dimensions, - } => TypeRef::Array { - element: Box::new(self.resolve_type_ref(element, package, imports, known_fqns)), - dimensions: *dimensions, - }, - TypeRef::Wildcard { - bound, - is_upper_bound, - } => TypeRef::Wildcard { - bound: bound - .as_ref() - .map(|b| Box::new(self.resolve_type_ref(b, package, imports, known_fqns))), - is_upper_bound: *is_upper_bound, - }, - _ => type_ref.clone(), + current = parent; } + None } + /// Resolve a symbol using the new inference-based approach. pub fn resolve_symbol_internal(&self, context: &ResolutionContext) -> Option { - match self.get_active_scopes(context).into_iter().try_fold( - None, - |_: Option, scope: Box| match scope - .resolve(&context.name, context) - { - Some(Ok(res)) => ControlFlow::Break(Some(res)), - Some(Err(())) => ControlFlow::Break(None), - None => ControlFlow::Continue(None), - }, - ) { - ControlFlow::Break(res) => res, - ControlFlow::Continue(_) => None, - } - } -} - -impl SemanticResolver for JavaResolver { - fn resolve_at( - &self, - tree: &Tree, - source: &str, - line: usize, - byte_col: usize, - index: &dyn CodeGraph, - ) -> Option { - let point = tree_sitter::Point::new(line, byte_col); - let node = tree - .root_node() - .named_descendant_for_point_range(point, point) - .filter(|n| { - matches!( - n.kind(), - "identifier" | "type_identifier" | "scoped_identifier" | "this" - ) - })?; - - let name = node.utf8_text(source.as_bytes()).ok()?.to_string(); - let context = ResolutionContext::new(node, name, index, source, tree, &self.parser); - - self.resolve_symbol_internal(&context) - } + let ts = CodeGraphTypeSystem::new(context.index); + + // Extract package from tree + let (package, imports) = self + .parser + .extract_package_and_imports(context.tree, context.source); + + // Build inference context + // Initialize ScopeManager for efficient local variable lookup + let mut scope_manager = crate::inference::scope::ScopeManager::new(); + + // Create context with populated scopes + // We scan the entire tree to build the scope table + let mut infer_ctx = crate::inference::create_inference_context( + &context.tree.root_node(), + context.source, + &ts, + &mut scope_manager, + package.clone(), + imports.clone(), + ); + + // Add enclosing class if available from context, OR infer from AST via ScopeManager + let enclosing_fqn = if let Some(class) = context.enclosing_classes.first() { + Some(class.clone()) + } else { + infer_ctx + .scope_manager + .and_then(|sm| self.find_enclosing_class_via_scope(context.node, sm)) + }; - fn find_matches(&self, index: &dyn CodeGraph, resolution: &SymbolResolution) -> Vec { - match resolution { - SymbolResolution::Local(_, _) => vec![], - SymbolResolution::Precise(fqn, _intent) => index.resolve_fqn(fqn), - SymbolResolution::Global(fqn) => index.resolve_fqn(fqn), + if let Some(fqn) = enclosing_fqn { + infer_ctx = infer_ctx.with_enclosing_class(fqn); } - } - - fn resolve_type_of( - &self, - index: &dyn CodeGraph, - resolution: &SymbolResolution, - ) -> Vec { - // Reuse original logic - let mut type_resolutions = Vec::new(); - match resolution { - SymbolResolution::Local(_, type_name) => { - if let Some(tn) = type_name { - if let Some(fqn) = self.parser.resolve_type_name_to_fqn_data(tn, None, &[]) { - type_resolutions.push(SymbolResolution::Precise(fqn, SymbolIntent::Type)); - } - } - } - SymbolResolution::Precise(fqn, intent) => { - let fids = index.resolve_fqn(fqn); - for fid in fids { - if let Some(node) = index.get_node(fid) { - if let Some(java_meta) = - node.metadata.as_any().downcast_ref::() - { - match java_meta { - JavaNodeMetadata::Field { type_ref, .. } => match type_ref { - TypeRef::Raw(s) => type_resolutions.push( - SymbolResolution::Precise(s.clone(), SymbolIntent::Type), - ), - TypeRef::Id(id) => type_resolutions.push( - SymbolResolution::Precise(id.clone(), SymbolIntent::Type), - ), - _ => {} - }, - JavaNodeMetadata::Method { return_type, .. } => match return_type { - TypeRef::Raw(s) => type_resolutions.push( - SymbolResolution::Precise(s.clone(), SymbolIntent::Type), - ), - TypeRef::Id(id) => type_resolutions.push( - SymbolResolution::Precise(id.clone(), SymbolIntent::Type), - ), - _ => {} - }, - _ => { - if matches_intent(&node.kind, SymbolIntent::Type) { - type_resolutions.push(resolution.clone()); - } - } - } + // 1. If this identifier is a declaration's name, resolve to that declaration's FQN + if let Some(parent) = context.node.parent() { + // Check if this node is the 'name' of a method_declaration or class_declaration + if parent.child_by_field_name("name") == Some(context.node) { + match parent.kind() { + "method_declaration" | "constructor_declaration" => { + // Build method FQN using canonical member separator + if let Some(ref enclosing) = infer_ctx.enclosing_class { + let method_fqn = crate::naming::JavaNamingConvention::build_member_fqn( + enclosing, + &context.name, + ); + return Some(SymbolResolution::Precise( + method_fqn, + naviscope_api::models::SymbolIntent::Method, + )); } } - } - if type_resolutions.is_empty() && *intent == SymbolIntent::Type { - type_resolutions.push(resolution.clone()); - } - } - SymbolResolution::Global(fqn) => { - let fids = index.resolve_fqn(fqn); - for fid in fids { - if let Some(node) = index.get_node(fid) { - if matches_intent(&node.kind, SymbolIntent::Type) { - type_resolutions.push(resolution.clone()); + "class_declaration" | "interface_declaration" | "enum_declaration" => { + // Build class FQN + let res_ctx = infer_ctx.to_resolution_context(); + if let Some(fqn) = infer_ctx.ts.resolve_type_name(&context.name, &res_ctx) { + return Some(SymbolResolution::Precise( + fqn, + naviscope_api::models::SymbolIntent::Type, + )); } } - } - } - } - type_resolutions - } - - fn find_implementations( - &self, - index: &dyn CodeGraph, - resolution: &SymbolResolution, - ) -> Vec { - let target_nodes = self.find_matches(index, resolution); - let mut results = Vec::new(); - - for &node_id in &target_nodes { - let node = match index.get_node(node_id) { - Some(n) => n, - None => continue, - }; - - // Check if it's a method - let is_method = if let Some(java_meta) = - node.metadata.as_any().downcast_ref::() - { - matches!(java_meta, JavaNodeMetadata::Method { .. }) - } else { - false - }; - - if is_method { - // 1. Find the enclosing class/interface - let parents = index.get_neighbors( - node_id, - naviscope_plugin::Direction::Incoming, - Some(EdgeType::Contains), - ); - for parent_id in parents { - // 2. Find all implementations of this parent - use naviscope_plugin::NamingConvention; - let parent_fqn = - crate::naming::JavaNamingConvention.render_fqn(parent_id, index.fqns()); - let parent_res = SymbolResolution::Precise(parent_fqn, SymbolIntent::Type); - let impl_classes = self.find_implementations(index, &parent_res); - - // 3. For each impl class, find a method with same name - for impl_class_id in impl_classes { - let children = index.get_neighbors( - impl_class_id, - naviscope_plugin::Direction::Outgoing, - Some(EdgeType::Contains), - ); - for child_id in children { - if let Some(child_node) = index.get_node(child_id) { - let is_child_method = if let Some(java_meta) = child_node - .metadata - .as_any() - .downcast_ref::() - { - matches!(java_meta, JavaNodeMetadata::Method { .. }) - } else { - false - }; - if is_child_method && child_node.name == node.name { - results.push(child_id); + "variable_declarator" => { + // Check if this is a class field (parent's parent is field_declaration) + // or a local variable (parent's parent is local_variable_declaration) + if let Some(grandparent) = parent.parent() { + if grandparent.kind() == "field_declaration" { + // Build field FQN using canonical member separator + if let Some(ref enclosing) = infer_ctx.enclosing_class { + let field_fqn = + crate::naming::JavaNamingConvention::build_member_fqn( + enclosing, + &context.name, + ); + return Some(SymbolResolution::Precise( + field_fqn, + naviscope_api::models::SymbolIntent::Field, + )); } } + // For local_variable_declaration, fall through to local variable handling } } + _ => {} } - continue; } - - results.extend(index.get_neighbors( - node_id, - naviscope_plugin::Direction::Incoming, - Some(EdgeType::Implements), - )); - results.extend(index.get_neighbors( - node_id, - naviscope_plugin::Direction::Incoming, - Some(EdgeType::InheritsFrom), - )); } - results - } -} -impl LangResolver for JavaResolver { - fn resolve( - &self, - file: &ParsedFile, - context: &ProjectContext, - ) -> std::result::Result> { - let mut unit = ResolvedUnit::new(); - let dummy_index = naviscope_plugin::EmptyCodeGraph; - - let parse_result_owned; - let parse_result = match &file.content { - ParsedContent::Language(res) => res, - ParsedContent::Unparsed(src) => { - if file.path().extension().map_or(false, |e| e == "java") { - // use IndexParser from JavaParser - parse_result_owned = self.parser.parse_file(src, Some(&file.file.path))?; - &parse_result_owned - } else { - return Ok(unit); - } + // 2. Handle 'this' specifically + if context.node.kind() == "this" { + if let Some(enclosing) = &infer_ctx.enclosing_class { + return Some(SymbolResolution::Precise( + enclosing.clone(), + naviscope_api::models::SymbolIntent::Type, + )); } - ParsedContent::Lazy => { - if file.path().extension().map_or(false, |e| e == "java") { - let src = std::fs::read_to_string(file.path()).map_err(|e| { - format!("Failed to read file {}: {}", file.path().display(), e) - })?; - // use IndexParser from JavaParser - parse_result_owned = self.parser.parse_file(&src, Some(&file.file.path))?; - &parse_result_owned - } else { - return Ok(unit); - } - } - _ => return Ok(unit), - }; - - { - // Scope for usage of parse_result - unit.identifiers = parse_result.output.identifiers.clone(); - unit.ops.push(GraphOp::UpdateIdentifiers { - path: Arc::from(file.file.path.as_path()), - identifiers: unit.identifiers.clone(), - }); - - let module_id = context - .find_module_for_path(&file.file.path) - .unwrap_or_else(|| "module::root".to_string()); - - let container_id = if let Some(pkg_name) = &parse_result.package_name { - let package_parts: Vec<_> = pkg_name - .split('.') - .map(|s| { - ( - naviscope_api::models::graph::NodeKind::Package, - s.to_string(), - ) - }) - .collect(); - let package_id = naviscope_api::models::symbol::NodeId::Structured(package_parts); - - let package_node = IndexNode { - id: package_id.clone(), - name: pkg_name.to_string(), - kind: NodeKind::Package, - lang: "java".to_string(), - source: naviscope_api::models::graph::NodeSource::Project, - status: naviscope_api::models::graph::ResolutionStatus::Resolved, - location: None, - metadata: Arc::new(crate::model::JavaIndexMetadata::Package), - }; - - unit.add_node(package_node); - - unit.add_edge( - module_id.clone().into(), - package_id.clone(), - GraphEdge::new(EdgeType::Contains), - ); - - package_id - } else { - // For default package, we might want to use a semantic "default package" node - // or just attach to module. - // For now, attaching to module seems safer to avoid colliding all default packages. - // But this means default package classes might be harder to find via clean FQN if module_id is weird. - module_id.into() - }; - - let mut known_types = std::collections::HashSet::::new(); - let mut local_type_map = std::collections::HashMap::::new(); - - for node in &parse_result.output.nodes { - if matches!( - node.kind, - NodeKind::Class | NodeKind::Interface | NodeKind::Enum | NodeKind::Annotation - ) { - known_types.insert(node.id.to_string()); - } - } - - for node in &parse_result.output.nodes { - let mut node = node.clone(); - - if let Some(java_idx_meta) = node - .metadata - .as_any() - .downcast_ref::() - { - let mut element = java_idx_meta.clone(); + } - match &mut element { - JavaIndexMetadata::Method { - return_type, - parameters, - .. - } => { - *return_type = self.resolve_type_ref( - return_type, - parse_result.package_name.as_deref(), - &parse_result.imports, - &known_types, - ); - for param in parameters { - param.type_ref = self.resolve_type_ref( - ¶m.type_ref, - parse_result.package_name.as_deref(), - &parse_result.imports, - &known_types, - ); - if let TypeRef::Id(type_fqn) = ¶m.type_ref { - local_type_map.insert(node.name.clone(), type_fqn.clone()); - } - } - } - JavaIndexMetadata::Field { type_ref, .. } => { - *type_ref = self.resolve_type_ref( - type_ref, - parse_result.package_name.as_deref(), - &parse_result.imports, - &known_types, - ); - if let TypeRef::Id(type_fqn) = &type_ref { - local_type_map.insert(node.name.clone(), type_fqn.clone()); - } - } - _ => {} + // 2.5. Check for local variable references (returns Local resolution) + if context.node.kind() == "identifier" { + if let Some(sm) = infer_ctx.scope_manager { + // Walk up to find the nearest scope + let mut current = context.node; + let mut start_scope_id = None; + while let Some(parent) = current.parent() { + if let Some(sid) = sm.get_scope_id(parent.id()) { + start_scope_id = Some(sid); + break; } - node.metadata = Arc::new(element); + current = parent; } - let is_top = matches!( - node.kind, - NodeKind::Class | NodeKind::Interface | NodeKind::Enum | NodeKind::Annotation - ); - - unit.add_node(node.clone()); - if is_top { - unit.add_edge( - container_id.clone().into(), - node.id.clone(), - GraphEdge::new(EdgeType::Contains), - ); - } - } + if let Some(sid) = start_scope_id { + if let Some(info) = sm.lookup_symbol(sid, &context.name) { + // Ensure declaration is before usage + let usage_point = context.node.start_position(); + let decl_line = info.range.start_line; + let decl_col = info.range.start_col; - for rel in &parse_result.output.relations { - let mut resolved_target_str = rel.target_id.to_string(); - - if let (Some(tree), Some(source)) = (&parse_result.tree, &parse_result.source) { - if let Some(r) = &rel.range { - let point = tree_sitter::Point::new(r.start_line, r.start_col); - if let Some(node) = tree - .root_node() - .named_descendant_for_point_range(point, point) + // Compare position (row/line are 0-indexed in TS, assuming Range follows TS or is consistent) + if decl_line < usage_point.row + || (decl_line == usage_point.row && decl_col < usage_point.column) { - let context = ResolutionContext::new_with_unit( - node, - rel.target_id.to_string(), - &dummy_index, - Some(&unit), - source, - tree, - &self.parser, - ); - - if let Some(SymbolResolution::Precise(fqn, _)) = - self.resolve_symbol_internal(&context) - { - resolved_target_str = fqn; - } else { - // ... Fallbacks ... - if !resolved_target_str.contains('.') { - if let Some(res) = self.parser.resolve_type_name_to_fqn_data( - &resolved_target_str, - parse_result.package_name.as_deref(), - &parse_result.imports, - ) { - resolved_target_str = res; - } - } - } + // Extract type name from reference if possible, or stringify the type + // The old find_local_declaration returned Option for type name. + // We have info.type_ref. + let type_name = match &info.type_ref { + TypeRef::Id(id) | TypeRef::Raw(id) => Some(id.clone()), + _ => None, // Complex types might need rendering + }; + return Some(SymbolResolution::Local(info.range.clone(), type_name)); } } } + } + } - let edge = GraphEdge::new(rel.edge_type.clone()); - - // Optimization: If the resolved string matches the original target ID string, - // trust the original ID IF it is Structured (which preserves metadata from parser). - if resolved_target_str == rel.target_id.to_string() - && matches!( - rel.target_id, - naviscope_api::models::symbol::NodeId::Structured(_) - ) + // 3. Resolve context-sensitive references (Methods, Fields) + // If it's a method name identifier, resolve to the method FQN + if let Some(parent) = context.node.parent() { + if parent.kind() == "method_invocation" + && parent.child_by_field_name("name") == Some(context.node) + { + if let Some(type_ref) = + crate::inference::strategy::MethodCallInfer.infer_member(&parent, &infer_ctx) { - unit.add_edge(rel.source_id.clone(), rel.target_id.clone(), edge); - continue; + return Some(SymbolResolution::Precise(type_ref, context.intent)); } - - // Try to reconstruct a Structured ID to match the graph nodes - let segments: Vec<&str> = resolved_target_str - .split(|c| c == '.' || c == '#') - .collect(); - let mut structured_parts: Vec<(naviscope_api::models::graph::NodeKind, String)> = - Vec::new(); - - for (i, part) in segments.iter().enumerate() { - let mut found_kind = naviscope_api::models::graph::NodeKind::Package; - let is_last = i == segments.len() - 1; - - // Probe kinds in unit.nodes - let candidates = [ - naviscope_api::models::graph::NodeKind::Class, - naviscope_api::models::graph::NodeKind::Interface, - naviscope_api::models::graph::NodeKind::Enum, - naviscope_api::models::graph::NodeKind::Annotation, - naviscope_api::models::graph::NodeKind::Method, - naviscope_api::models::graph::NodeKind::Field, - naviscope_api::models::graph::NodeKind::Constructor, - ]; - - let mut matched = false; - for k in &candidates { - let mut probe_parts = structured_parts.clone(); - probe_parts.push((k.clone(), part.to_string())); - let id = naviscope_api::models::symbol::NodeId::Structured(probe_parts); - if unit.nodes.contains_key(&id) { - found_kind = k.clone(); - matched = true; - break; - } - } - - if !matched { - if is_last { - // Heuristics for last part if not found - // NOTE: We now use Class for all Type IDs in Java for stability - if rel.edge_type == EdgeType::Implements - || rel.edge_type == EdgeType::InheritsFrom - || rel.edge_type == EdgeType::TypedAs - || rel.edge_type == EdgeType::DecoratedBy - { - found_kind = naviscope_api::models::graph::NodeKind::Class; - } else if part.chars().next().map_or(false, |c| c.is_uppercase()) { - found_kind = naviscope_api::models::graph::NodeKind::Class; - } - } else if part.chars().next().map_or(false, |c| c.is_uppercase()) { - // Not last, but uppercase? Handle inner classes / enclosing classes correctly - found_kind = naviscope_api::models::graph::NodeKind::Class; - } else { - found_kind = naviscope_api::models::graph::NodeKind::Package; - } - } - - structured_parts.push((found_kind, part.to_string())); + } + if parent.kind() == "field_access" + && parent.child_by_field_name("field") == Some(context.node) + { + if let Some(type_ref) = + crate::inference::strategy::FieldAccessInfer.infer_member(&parent, &infer_ctx) + { + return Some(SymbolResolution::Precise(type_ref, context.intent)); } + } + } - let final_target_id = - naviscope_api::models::symbol::NodeId::Structured(structured_parts); - - unit.add_edge(rel.source_id.clone(), final_target_id, edge); + // 4. Main inference path for everything else + if let Some(type_ref) = + crate::inference::strategy::infer_expression(&context.node, &infer_ctx) + { + if let TypeRef::Id(fqn) = &type_ref { + return Some(SymbolResolution::Precise(fqn.clone(), context.intent)); } } - Ok(unit) + None } } diff --git a/crates/lang-java/src/resolver/scope/builtin.rs b/crates/lang-java/src/resolver/scope/builtin.rs deleted file mode 100644 index 00e32de..0000000 --- a/crates/lang-java/src/resolver/scope/builtin.rs +++ /dev/null @@ -1,88 +0,0 @@ -use crate::parser::JavaParser; -use crate::resolver::context::ResolutionContext; -use crate::resolver::scope::SemanticScope; -use naviscope_api::models::SymbolIntent; - -use naviscope_api::models::SymbolResolution; - -pub struct BuiltinScope<'a> { - pub parser: &'a JavaParser, -} - -impl SemanticScope> for BuiltinScope<'_> { - fn resolve( - &self, - name: &str, - context: &ResolutionContext, - ) -> Option> { - if context.intent != SymbolIntent::Type { - return None; - } - - self.parser - .resolve_type_name_to_fqn_data(name, context.package.as_deref(), &context.imports) - .and_then(|fqn| { - // Only return if it's a known FQN or a primitive or java.lang - let known = !context.index.resolve_fqn(&fqn).is_empty(); - - if known || fqn.starts_with("java.lang.") || !fqn.contains('.') { - Some(Ok(SymbolResolution::Precise(fqn, SymbolIntent::Type))) - } else { - None - } - }) - } - fn name(&self) -> &'static str { - "Builtin" - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use tree_sitter::Parser; - - #[test] - fn test_builtin_scope_java_lang() { - let source = "class Test { String s; }"; - let mut parser = Parser::new(); - parser - .set_language(&crate::parser::JavaParser::new().unwrap().language) - .expect("Error loading Java grammar"); - let tree = parser.parse(source, None).unwrap(); - - let string_node = tree - .root_node() - .named_descendant_for_point_range( - tree_sitter::Point::new(0, 13), - tree_sitter::Point::new(0, 19), - ) - .unwrap(); - - let java_parser = JavaParser::new().unwrap(); - let index = naviscope_plugin::EmptyCodeGraph; - - let context = ResolutionContext::new( - string_node, - "String".to_string(), - &index, - source, - &tree, - &java_parser, - ); - - let scope = BuiltinScope { - parser: &java_parser, - }; - let res = scope.resolve("String", &context); - - assert!(res.is_some()); - match res.unwrap() { - Ok(SymbolResolution::Precise(fqn, _)) => { - assert_eq!(fqn, "java.lang.String"); - } - _ => panic!("Expected Precise resolution"), - } - } -} diff --git a/crates/lang-java/src/resolver/scope/import_scope.rs b/crates/lang-java/src/resolver/scope/import_scope.rs deleted file mode 100644 index 6a36973..0000000 --- a/crates/lang-java/src/resolver/scope/import_scope.rs +++ /dev/null @@ -1,92 +0,0 @@ -use crate::parser::JavaParser; -use crate::resolver::context::ResolutionContext; -use crate::resolver::scope::SemanticScope; - -use naviscope_api::models::SymbolResolution; - -pub struct ImportScope<'a> { - pub parser: &'a JavaParser, -} - -impl SemanticScope> for ImportScope<'_> { - fn resolve( - &self, - name: &str, - context: &ResolutionContext, - ) -> Option> { - // 1. Precise imports - context - .imports - .iter() - .find(|imp| imp.ends_with(&format!(".{}", name))) - .map(|imp| Ok(SymbolResolution::Precise(imp.clone(), context.intent))) - .or_else(|| { - // 2. Current package - context - .package - .as_ref() - .map(|pkg| format!("{}.{}", pkg, name)) - .and_then(|candidate| { - if !context.index.resolve_fqn(&candidate).is_empty() { - Some(candidate) - } else { - None - } - }) - .map(|fqn| Ok(SymbolResolution::Precise(fqn, context.intent))) - }) - } - fn name(&self) -> &'static str { - "Import" - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use tree_sitter::Parser; - - #[test] - fn test_import_scope_precise() { - let source = "import java.util.List; class Test { List x; }"; - let mut parser = Parser::new(); - parser - .set_language(&crate::parser::JavaParser::new().unwrap().language) - .expect("Error loading Java grammar"); - let tree = parser.parse(source, None).unwrap(); - - let list_node = tree - .root_node() - .named_descendant_for_point_range( - tree_sitter::Point::new(0, 36), - tree_sitter::Point::new(0, 40), - ) - .unwrap(); - - let java_parser = JavaParser::new().unwrap(); - let index = naviscope_plugin::EmptyCodeGraph; - - let context = ResolutionContext::new( - list_node, - "List".to_string(), - &index, - source, - &tree, - &java_parser, - ); - - let scope = ImportScope { - parser: &java_parser, - }; - let res = scope.resolve("List", &context); - - assert!(res.is_some()); - match res.unwrap() { - Ok(SymbolResolution::Precise(fqn, _)) => { - assert_eq!(fqn, "java.util.List"); - } - _ => panic!("Expected Precise resolution"), - } - } -} diff --git a/crates/lang-java/src/resolver/scope/local.rs b/crates/lang-java/src/resolver/scope/local.rs deleted file mode 100644 index 901373a..0000000 --- a/crates/lang-java/src/resolver/scope/local.rs +++ /dev/null @@ -1,75 +0,0 @@ -use crate::parser::JavaParser; -use crate::resolver::context::ResolutionContext; -use crate::resolver::scope::SemanticScope; -use naviscope_api::models::SymbolResolution; - -pub struct LocalScope<'a> { - pub parser: &'a JavaParser, -} - -impl SemanticScope> for LocalScope<'_> { - fn resolve( - &self, - name: &str, - context: &ResolutionContext, - ) -> Option> { - // Local scope is only searched if there is no explicit receiver - if context.receiver_node.is_some() { - return None; - } - - self.parser - .find_local_declaration(context.node, name, context.source) - .map(|(range, type_name)| Ok(SymbolResolution::Local(range, type_name))) - } - fn name(&self) -> &'static str { - "Local" - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use tree_sitter::Parser; - - #[test] - fn test_local_scope_resolve() { - let source = "class Test { void main() { int x = 1; System.out.println(x); } }"; - let mut parser = Parser::new(); - parser - .set_language(&crate::parser::JavaParser::new().unwrap().language) - .expect("Error loading Java grammar"); - let tree = parser.parse(source, None).unwrap(); - - // Find the 'x' in println(x) - let x_node = tree - .root_node() - .named_descendant_for_point_range( - tree_sitter::Point::new(0, 57), - tree_sitter::Point::new(0, 58), - ) - .unwrap(); - - assert_eq!(x_node.utf8_text(source.as_bytes()).unwrap(), "x"); - - let java_parser = JavaParser::new().unwrap(); - let index = naviscope_plugin::EmptyCodeGraph; - let context = - ResolutionContext::new(x_node, "x".to_string(), &index, source, &tree, &java_parser); - - let scope = LocalScope { - parser: &java_parser, - }; - let res = scope.resolve("x", &context); - - assert!(res.is_some()); - match res.unwrap() { - Ok(SymbolResolution::Local(range, type_name)) => { - assert_eq!(range.start_line, 0); - assert_eq!(type_name, Some("int".to_string())); - } - _ => panic!("Expected Local resolution"), - } - } -} diff --git a/crates/lang-java/src/resolver/scope/member.rs b/crates/lang-java/src/resolver/scope/member.rs deleted file mode 100644 index f431d71..0000000 --- a/crates/lang-java/src/resolver/scope/member.rs +++ /dev/null @@ -1,493 +0,0 @@ -use crate::model::{JavaIndexMetadata, JavaNodeMetadata}; -use crate::parser::JavaParser; -use crate::resolver::context::ResolutionContext; -use crate::resolver::scope::SemanticScope; -use naviscope_api::models::{SymbolResolution, TypeRef}; - -pub struct MemberScope<'a> { - pub parser: &'a JavaParser, -} - -impl MemberScope<'_> { - fn resolve_type_ref_fqns(&self, type_ref: &TypeRef, context: &ResolutionContext) -> TypeRef { - match type_ref { - TypeRef::Raw(name) | TypeRef::Id(name) => { - if let Some(fqn) = - self.parser - .resolve_type_name_to_fqn(name, context.tree, context.source) - { - TypeRef::Id(fqn) - } else { - TypeRef::Raw(name.clone()) - } - } - TypeRef::Generic { base, args } => TypeRef::Generic { - base: Box::new(self.resolve_type_ref_fqns(base, context)), - args: args - .iter() - .map(|a| self.resolve_type_ref_fqns(a, context)) - .collect(), - }, - TypeRef::Array { - element, - dimensions, - } => TypeRef::Array { - element: Box::new(self.resolve_type_ref_fqns(element, context)), - dimensions: *dimensions, - }, - TypeRef::Wildcard { - bound, - is_upper_bound, - } => TypeRef::Wildcard { - bound: bound - .as_ref() - .map(|b| Box::new(self.resolve_type_ref_fqns(b, context))), - is_upper_bound: *is_upper_bound, - }, - _ => type_ref.clone(), - } - } - - fn get_base_fqn(&self, type_ref: &TypeRef) -> Option { - match type_ref { - TypeRef::Id(s) | TypeRef::Raw(s) => Some(s.clone()), - TypeRef::Generic { base, .. } => self.get_base_fqn(base), - _ => None, - } - } - - fn resolve_fqn_from_context(&self, name: &str, context: &ResolutionContext) -> Option { - let lookup_index = |n: &str| -> bool { !context.index.resolve_fqn(n).is_empty() }; - - // 1. Check if it's already an FQN in the index or current unit - if lookup_index(name) - || context - .unit - .map_or(false, |u| u.nodes.contains_key(&name.into())) - { - return Some(name.to_string()); - } - - // 2. Check inner classes in enclosing classes - for container_fqn in &context.enclosing_classes { - let candidate = format!("{}.{}", container_fqn, name); - if lookup_index(&candidate) - || context - .unit - .map_or(false, |u| u.nodes.contains_key(&candidate.as_str().into())) - { - return Some(candidate); - } - } - - // 3. Use parser's resolution (imports/package) - if let Some(fqn) = self - .parser - .resolve_type_name_to_fqn(name, context.tree, context.source) - { - if fqn != name { - return Some(fqn); - } - } - - Some(name.to_string()) - } - - fn resolve_expression_type( - &self, - node: &tree_sitter::Node, - context: &ResolutionContext, - ) -> Option { - // Helper to get node from index by string FQN - let get_index_node = |fqn: &str| -> Option { - context - .index - .resolve_fqn(fqn) - .into_iter() - .next() - .and_then(|id| context.index.get_node(id)) - }; - - let kind = node.kind(); - // println!("RESOLVE_EXPR: kind={}, text={:?}", kind, node.utf8_text(context.source.as_bytes()).unwrap_or("")); - - match kind { - "identifier" | "type_identifier" => { - let name = node.utf8_text(context.source.as_bytes()).ok()?; - // 1. Local Scope - if let Some((_, maybe_type_node)) = - self.parser - .find_local_declaration_node(*node, name, context.source) - { - if let Some(type_node) = maybe_type_node { - // Parse the type node properly to handle generics - let type_ref = self.parser.parse_type_node(type_node, context.source); - - // Resolve FQNs within the parsed type ref - let resolved_type_ref = self.resolve_type_ref_fqns(&type_ref, context); - return Some(resolved_type_ref); - } - - // Heuristic: Try to infer lambda parameter type - return self.infer_lambda_param_type(node, context); - } - // 2. Lexical Scope - for container_fqn in &context.enclosing_classes { - let candidate = format!("{}.{}", container_fqn, name); - - // Check index - if let Some(node) = get_index_node(&candidate) { - if let JavaNodeMetadata::Field { type_ref, .. } = - node.metadata.as_any().downcast_ref::()? - { - return Some(type_ref.clone()); - } - return Some(TypeRef::Id(candidate)); - } - - // Check current unit (indexing phase) - if let Some(unit) = context.unit { - if let Some(node) = unit.nodes.get(&candidate.as_str().into()) { - if let Some(java_meta) = - node.metadata.as_any().downcast_ref::() - { - if let JavaIndexMetadata::Field { type_ref, .. } = java_meta { - return Some(type_ref.clone()); - } - } - return Some(TypeRef::Id(candidate)); - } - } - } - // 3. Global Scope (Check if it's a known class FQN in the index or unit) - let fqn = - self.parser - .resolve_type_name_to_fqn(name, context.tree, context.source)?; - - // If it's a known class, return it. - // Check index presence - let in_index = !context.index.resolve_fqn(&fqn).is_empty(); - - if in_index - || context - .unit - .map_or(false, |u| u.nodes.contains_key(&fqn.as_str().into())) - { - return Some(TypeRef::Id(fqn.clone())); - } - - // Fallback: maybe it's a package or a class not yet in index but resolvable via imports - Some(TypeRef::Id(fqn)) - } - "field_access" => { - let receiver = node.child_by_field_name("object")?; - let field_name = node - .child_by_field_name("field")? - .utf8_text(context.source.as_bytes()) - .ok()?; - - let receiver_type_ref = self.resolve_expression_type(&receiver, context)?; - let raw_receiver_type = self.get_base_fqn(&receiver_type_ref)?; - let receiver_type = self.resolve_fqn_from_context(&raw_receiver_type, context)?; - let field_fqn = format!("{}.{}", receiver_type, field_name); - - // Check index - if let Some(node) = get_index_node(&field_fqn) { - if let Some(java_meta) = - node.metadata.as_any().downcast_ref::() - { - if let JavaNodeMetadata::Field { type_ref, .. } = java_meta { - return Some(type_ref.clone()); - } - } - } - - // Check unit - if let Some(unit) = context.unit { - if let Some(node) = unit.nodes.get(&field_fqn.as_str().into()) { - if let Some(java_meta) = - node.metadata.as_any().downcast_ref::() - { - if let JavaIndexMetadata::Field { type_ref, .. } = java_meta { - return Some(type_ref.clone()); - } - } - } - } - None - } - "method_invocation" => { - let receiver = node.child_by_field_name("object")?; - let method_name = node - .child_by_field_name("name")? - .utf8_text(context.source.as_bytes()) - .ok()?; - - let receiver_type_ref = self.resolve_expression_type(&receiver, context)?; - let raw_receiver_type = self.get_base_fqn(&receiver_type_ref)?; - let receiver_type = self.resolve_fqn_from_context(&raw_receiver_type, context)?; - let method_fqn = format!("{}.{}", receiver_type, method_name); - - // Check index - if let Some(node) = get_index_node(&method_fqn) { - if let Some(java_meta) = - node.metadata.as_any().downcast_ref::() - { - if let JavaNodeMetadata::Method { return_type, .. } = java_meta { - return Some(return_type.clone()); - } - } - } - - // Check unit - if let Some(unit) = context.unit { - if let Some(node) = unit.nodes.get(&method_fqn.as_str().into()) { - if let Some(java_meta) = - node.metadata.as_any().downcast_ref::() - { - if let JavaIndexMetadata::Method { return_type, .. } = java_meta { - return Some(return_type.clone()); - } - } - } - } - None - } - "this" => context - .enclosing_classes - .first() - .map(|s| TypeRef::Id(s.clone())), - "scoped_type_identifier" | "scoped_identifier" => { - let receiver = node.child_by_field_name("scope")?; - let name = node - .child_by_field_name("name")? - .utf8_text(context.source.as_bytes()) - .ok()?; - let receiver_type_ref = self.resolve_expression_type(&receiver, context)?; - let receiver_type = self.get_base_fqn(&receiver_type_ref)?; - Some(TypeRef::Id(format!("{}.{}", receiver_type, name))) - } - _ => None, - } - } - - fn infer_lambda_param_type( - &self, - node: &tree_sitter::Node, - context: &ResolutionContext, - ) -> Option { - let mut curr = *node; - while let Some(parent) = curr.parent() { - if parent.kind() == "lambda_expression" { - return self.resolve_lambda_type_from_parent(&parent, context); - } - curr = parent; - } - None - } - - fn resolve_lambda_type_from_parent( - &self, - lambda_node: &tree_sitter::Node, - context: &ResolutionContext, - ) -> Option { - let invocation = lambda_node - .parent() - .filter(|n| n.kind() == "argument_list")?; - let method_call = invocation - .parent() - .filter(|n| n.kind() == "method_invocation")?; - - let method_name = method_call - .child_by_field_name("name")? - .utf8_text(context.source.as_bytes()) - .ok()?; - - if !matches!( - method_name, - "forEach" | "filter" | "map" | "anyMatch" | "allMatch" - ) { - return None; - } - - let receiver = method_call.child_by_field_name("object")?; - let receiver_type = self.resolve_expression_type(&receiver, context)?; - - self.extract_first_generic_arg(&receiver_type) - } - - fn extract_first_generic_arg(&self, type_ref: &TypeRef) -> Option { - if let TypeRef::Generic { args, .. } = type_ref { - args.first().cloned() - } else { - None - } - } -} - -impl SemanticScope> for MemberScope<'_> { - fn resolve( - &self, - name: &str, - context: &ResolutionContext, - ) -> Option> { - if name == "this" { - return context - .enclosing_classes - .first() - .cloned() - .map(|fqn| Ok(SymbolResolution::Precise(fqn, context.intent))); - } - - context - .receiver_node - .as_ref() - .map(|recv| { - // Case A: Explicit Receiver (obj.field) - self.resolve_expression_type(recv, context) - .and_then(|type_ref| self.get_base_fqn(&type_ref)) - .and_then(|raw_type_fqn| self.resolve_fqn_from_context(&raw_type_fqn, context)) - .map(|type_fqn| format!("{}#{}", type_fqn, name)) - .and_then(|candidate| { - context - .index - .resolve_fqn(&candidate) - .into_iter() - .next() - .map(|id| { - use naviscope_plugin::NamingConvention; - crate::naming::JavaNamingConvention - .render_fqn(id, context.index.fqns()) - }) - }) - .map(|fqn| Ok(SymbolResolution::Precise(fqn, context.intent))) - .unwrap_or(Err(())) - }) - .or_else(|| { - // Case B: Implicit this (Lexical Scope) - context - .enclosing_classes - .iter() - .map(|container_fqn| format!("{}#{}", container_fqn, name)) - .find_map(|candidate| { - context - .index - .resolve_fqn(&candidate) - .into_iter() - .next() - .map(|id| { - use naviscope_plugin::NamingConvention; - let fqn = crate::naming::JavaNamingConvention - .render_fqn(id, context.index.fqns()); - Ok(SymbolResolution::Precise(fqn, context.intent)) - }) - }) - }) - } - fn name(&self) -> &'static str { - "Member" - } -} - -#[cfg(test)] -mod tests { - use super::*; - use naviscope_api::models::graph::{EdgeType, GraphNode, ResolutionStatus}; - use naviscope_api::models::symbol::{FqnId, FqnNode, FqnReader, Symbol}; - use naviscope_plugin::{CodeGraph, Direction}; - use std::path::Path; - - struct MockCodeGraph { - node: GraphNode, - } - - impl CodeGraph for MockCodeGraph { - fn resolve_fqn(&self, _fqn: &str) -> Vec { - vec![FqnId(0)] - } - fn get_node_at(&self, _path: &Path, _line: usize, _col: usize) -> Option { - None - } - fn resolve_atom(&self, _atom: Symbol) -> &str { - "" - } - fn fqns(&self) -> &dyn FqnReader { - self - } - fn get_node(&self, _id: FqnId) -> Option { - Some(self.node.clone()) - } - fn get_neighbors( - &self, - _id: FqnId, - _direction: Direction, - _edge_type: Option, - ) -> Vec { - vec![] - } - } - - impl FqnReader for MockCodeGraph { - fn resolve_node(&self, _id: FqnId) -> Option { - None - } - fn resolve_atom(&self, _atom: Symbol) -> &str { - "" - } - } - - use tree_sitter::Parser; - - #[test] - fn test_member_scope_implicit_this() { - let source = "class Test { int field; void main() { field = 1; } }"; - let mut parser = Parser::new(); - parser - .set_language(&crate::parser::JavaParser::new().unwrap().language) - .expect("Error loading Java grammar"); - let tree = parser.parse(source, None).unwrap(); - - // Find the 'field' in field = 1 - let field_node = tree - .root_node() - .named_descendant_for_point_range( - tree_sitter::Point::new(0, 38), - tree_sitter::Point::new(0, 43), - ) - .unwrap(); - - let java_parser = JavaParser::new().unwrap(); - - let index = MockCodeGraph { - node: naviscope_api::models::graph::GraphNode { - id: FqnId(0), - name: Symbol(lasso::Spur::default()), - kind: naviscope_api::models::graph::NodeKind::Field, - lang: Symbol(lasso::Spur::default()), - source: naviscope_api::models::graph::NodeSource::Project, - status: ResolutionStatus::Resolved, - location: None, - metadata: std::sync::Arc::new(JavaNodeMetadata::Field { - type_ref: naviscope_api::models::TypeRef::Raw("int".to_string()), - modifiers_sids: vec![], - }), - }, - }; - - let context = ResolutionContext::new( - field_node, - "field".to_string(), - &index, - source, - &tree, - &java_parser, - ); - - let scope = MemberScope { - parser: &java_parser, - }; - let res = scope.resolve("field", &context); - - assert!(res.is_some()); - } -} diff --git a/crates/lang-java/src/resolver/scope/mod.rs b/crates/lang-java/src/resolver/scope/mod.rs deleted file mode 100644 index 8be8b92..0000000 --- a/crates/lang-java/src/resolver/scope/mod.rs +++ /dev/null @@ -1,17 +0,0 @@ -use super::context::ResolutionContext; -use naviscope_plugin::SemanticScope; - -pub trait Scope: for<'a> SemanticScope> {} -impl SemanticScope>> Scope for T {} - -pub mod builtin; -pub mod import_scope; -pub mod local; -pub mod member; -pub mod package_scope; - -pub use builtin::BuiltinScope; -pub use import_scope::ImportScope; -pub use local::LocalScope; -pub use member::MemberScope; -pub use package_scope::PackageScope; diff --git a/crates/lang-java/src/resolver/scope/package_scope.rs b/crates/lang-java/src/resolver/scope/package_scope.rs deleted file mode 100644 index 6edc827..0000000 --- a/crates/lang-java/src/resolver/scope/package_scope.rs +++ /dev/null @@ -1,45 +0,0 @@ -use super::ResolutionContext; -use crate::parser::JavaParser; -use naviscope_api::models::{SymbolIntent, SymbolResolution}; -use naviscope_plugin::SemanticScope; - -pub struct PackageScope<'a> { - pub parser: &'a JavaParser, -} - -impl<'a, 'b> SemanticScope> for PackageScope<'b> { - fn resolve( - &self, - name: &str, - context: &ResolutionContext<'a>, - ) -> Option> { - // Java Logic: If a name starts with an uppercase letter and we are in a package, - // it might be a type in the same package. - - // Only trigger for Type intent or Unknown - if !matches!(context.intent, SymbolIntent::Type | SymbolIntent::Unknown) { - return None; - } - - // Heuristic: If it starts with UpperCase, it's likely a class/interface - if !name.chars().next().map_or(false, |c| c.is_uppercase()) { - return None; - } - - if let Some(fqn) = self.parser.resolve_type_name_to_fqn_data( - name, - context.package.as_deref(), - &context.imports, - ) { - if fqn.contains('.') && fqn.ends_with(name) { - return Some(Ok(SymbolResolution::Precise(fqn, SymbolIntent::Type))); - } - } - - None - } - - fn name(&self) -> &'static str { - "PackageScope" - } -} diff --git a/crates/lang-java/src/resolver/semantic.rs b/crates/lang-java/src/resolver/semantic.rs new file mode 100644 index 0000000..c937174 --- /dev/null +++ b/crates/lang-java/src/resolver/semantic.rs @@ -0,0 +1,161 @@ +use crate::inference::adapters::CodeGraphTypeSystem; +use crate::inference::{InheritanceProvider, MemberProvider, TypeProvider, TypeResolutionContext}; +use crate::resolver::JavaResolver; +use crate::resolver::context::ResolutionContext; +use naviscope_api::models::graph::EdgeType; +use naviscope_api::models::symbol::{FqnId, matches_intent}; +use naviscope_api::models::{SymbolIntent, SymbolResolution, TypeRef}; +use naviscope_plugin::{CodeGraph, NamingConvention, SemanticResolver}; +use tree_sitter::Tree; + +impl SemanticResolver for JavaResolver { + fn resolve_at( + &self, + tree: &Tree, + source: &str, + line: usize, + byte_col: usize, + index: &dyn CodeGraph, + ) -> Option { + let point = tree_sitter::Point::new(line, byte_col); + let node = tree + .root_node() + .named_descendant_for_point_range(point, point) + .filter(|n| { + matches!( + n.kind(), + "identifier" | "type_identifier" | "scoped_identifier" | "this" + ) + })?; + + let name = node.utf8_text(source.as_bytes()).ok()?.to_string(); + let context = ResolutionContext::new(node, name, index, source, tree, &self.parser); + + self.resolve_symbol_internal(&context) + } + + fn find_matches(&self, index: &dyn CodeGraph, resolution: &SymbolResolution) -> Vec { + match resolution { + SymbolResolution::Local(_, _) => vec![], + SymbolResolution::Precise(fqn, _intent) => index.resolve_fqn(fqn), + SymbolResolution::Global(fqn) => index.resolve_fqn(fqn), + } + } + + fn resolve_type_of( + &self, + index: &dyn CodeGraph, + resolution: &SymbolResolution, + ) -> Vec { + let mut type_resolutions = Vec::new(); + let ts = CodeGraphTypeSystem::new(index); + + match resolution { + SymbolResolution::Local(_, type_name) => { + if let Some(tn) = type_name { + // Use TypeProvider from type system + let ctx = TypeResolutionContext::default(); // Minimal ctx for LSP verify + if let Some(fqn) = ts.resolve_type_name(tn, &ctx) { + type_resolutions.push(SymbolResolution::Precise(fqn, SymbolIntent::Type)); + } + } + } + SymbolResolution::Precise(fqn, intent) => { + // If it's a member (Field/Method), find its type via MemberProvider + // Use unified member FQN parsing + if let Some((type_fqn, member_name)) = + crate::naming::JavaNamingConvention::parse_member_fqn(fqn) + { + if let Some(member) = ts.get_members(type_fqn, member_name).first() { + match &member.type_ref { + TypeRef::Raw(s) => type_resolutions + .push(SymbolResolution::Precise(s.clone(), SymbolIntent::Type)), + TypeRef::Id(id) => type_resolutions + .push(SymbolResolution::Precise(id.clone(), SymbolIntent::Type)), + _ => {} + } + } + } else if *intent == SymbolIntent::Type { + type_resolutions.push(resolution.clone()); + } else { + // Fallback for cases without member separator or other global symbols + let fids = index.resolve_fqn(fqn); + for fid in fids { + if let Some(node) = index.get_node(fid) { + if matches_intent(&node.kind, SymbolIntent::Type) { + type_resolutions.push(resolution.clone()); + } + } + } + } + } + SymbolResolution::Global(fqn) => { + let fids = index.resolve_fqn(fqn); + for fid in fids { + if let Some(node) = index.get_node(fid) { + if matches_intent(&node.kind, SymbolIntent::Type) { + type_resolutions.push(resolution.clone()); + } + } + } + } + } + type_resolutions + } + + fn find_implementations( + &self, + index: &dyn CodeGraph, + resolution: &SymbolResolution, + ) -> Vec { + let target_nodes = self.find_matches(index, resolution); + let mut results = Vec::new(); + let ts = CodeGraphTypeSystem::new(index); + + for &node_id in &target_nodes { + let node = match index.get_node(node_id) { + Some(n) => n, + None => continue, + }; + + // Check if it's a method + let is_method = matches!( + node.kind, + naviscope_api::models::graph::NodeKind::Method + | naviscope_api::models::graph::NodeKind::Constructor + ); + + if is_method { + // 1. Find the enclosing class/interface + let parents = index.get_neighbors( + node_id, + naviscope_plugin::Direction::Incoming, + Some(EdgeType::Contains), + ); + for parent_id in parents { + // 2. Find all implementations of this parent + use naviscope_plugin::NamingConvention; + let parent_fqn = + crate::naming::JavaNamingConvention.render_fqn(parent_id, index.fqns()); + + // 3. Walk all descendants of the parent class + for desc_fqn in ts.walk_descendants(&parent_fqn) { + // 4. In each descendant class, find a member with same name + let method_name = index.fqns().resolve_atom(node.name); + if let Some(member) = ts.get_members(&desc_fqn, method_name).first() { + results.extend(index.resolve_fqn(&member.fqn)); + } + } + } + continue; + } + + // For classes/interfaces, get all descendants + let fqn = crate::naming::JavaNamingConvention.render_fqn(node_id, index.fqns()); + for desc_fqn in ts.walk_descendants(&fqn) { + results.extend(index.resolve_fqn(&desc_fqn)); + } + } + results + } +} diff --git a/crates/lang-java/src/resolver/types.rs b/crates/lang-java/src/resolver/types.rs new file mode 100644 index 0000000..f61c83c --- /dev/null +++ b/crates/lang-java/src/resolver/types.rs @@ -0,0 +1,56 @@ +use crate::inference::{TypeProvider, TypeResolutionContext}; +use crate::resolver::JavaResolver; +use naviscope_api::models::TypeRef; + +impl JavaResolver { + pub(crate) fn resolve_type_ref( + &self, + type_ref: &TypeRef, + provider: &dyn TypeProvider, + ctx: &TypeResolutionContext, + ) -> TypeRef { + match type_ref { + TypeRef::Raw(name) => { + // 1. Check if name matches a known FQN suffix in the same file (Inner class priority) + if let Some(fqn) = ctx + .known_fqns + .iter() + .find(|k| k.ends_with(&format!(".{}", name)) || *k == name) + { + return TypeRef::Id(fqn.clone()); + } + + // 2. Delegate to TypeProvider + if let Some(fqn) = provider.resolve_type_name(name, ctx) { + TypeRef::Id(fqn) + } else { + TypeRef::Raw(name.clone()) + } + } + TypeRef::Generic { base, args } => TypeRef::Generic { + base: Box::new(self.resolve_type_ref(base, provider, ctx)), + args: args + .iter() + .map(|a| self.resolve_type_ref(a, provider, ctx)) + .collect(), + }, + TypeRef::Array { + element, + dimensions, + } => TypeRef::Array { + element: Box::new(self.resolve_type_ref(element, provider, ctx)), + dimensions: *dimensions, + }, + TypeRef::Wildcard { + bound, + is_upper_bound, + } => TypeRef::Wildcard { + bound: bound + .as_ref() + .map(|b| Box::new(self.resolve_type_ref(b, provider, ctx))), + is_upper_bound: *is_upper_bound, + }, + _ => type_ref.clone(), + } + } +} diff --git a/crates/lang-java/tests/inference_mock_test.rs b/crates/lang-java/tests/inference_mock_test.rs new file mode 100644 index 0000000..2c5920c --- /dev/null +++ b/crates/lang-java/tests/inference_mock_test.rs @@ -0,0 +1,569 @@ +//! Mock implementation of JavaTypeSystem for testing. +//! Moved from src/inference/adapters/mock.rs to tests/inference_mock_test.rs + +use naviscope_api::models::TypeRef; +use std::collections::HashMap; + +use naviscope_java::inference::JavaTypeSystem; +use naviscope_java::inference::{InheritanceProvider, MemberProvider, TypeProvider}; +use naviscope_java::inference::{ + MemberInfo, MemberKind, TypeInfo, TypeKind, TypeResolutionContext, +}; + +/// A mock type system for testing. +/// +/// Can be built using a fluent API. +#[derive(Default)] +pub struct MockTypeSystem { + types: HashMap, + inheritance: HashMap, Vec)>, // (superclass, interfaces) + members: HashMap>, +} + +impl MockTypeSystem { + /// Create a new empty mock. + pub fn new() -> Self { + Self::default() + } + + /// Add a class to the mock. + pub fn add_class(mut self, fqn: &str, super_class: Option<&str>) -> Self { + self.types.insert( + fqn.to_string(), + TypeInfo { + fqn: fqn.to_string(), + kind: TypeKind::Class, + modifiers: vec![], + type_parameters: vec![], + }, + ); + + self.inheritance.insert( + fqn.to_string(), + (super_class.map(|s| s.to_string()), vec![]), + ); + + self + } + + /// Add an interface to the mock. + pub fn add_interface(mut self, fqn: &str) -> Self { + self.types.insert( + fqn.to_string(), + TypeInfo { + fqn: fqn.to_string(), + kind: TypeKind::Interface, + modifiers: vec![], + type_parameters: vec![], + }, + ); + + self.inheritance.insert(fqn.to_string(), (None, vec![])); + + self + } + + /// Add interface implementation to a class. + pub fn implements(mut self, class_fqn: &str, interface_fqn: &str) -> Self { + if let Some((_super_class, interfaces)) = self.inheritance.get_mut(class_fqn) { + interfaces.push(interface_fqn.to_string()); + } else { + self.inheritance.insert( + class_fqn.to_string(), + (None, vec![interface_fqn.to_string()]), + ); + } + // Also ensure interface type exists + if !self.types.contains_key(interface_fqn) { + self = self.add_interface(interface_fqn); + } + self + } + + /// Add a method to a class. + pub fn add_method(mut self, class_fqn: &str, name: &str, return_type: TypeRef) -> Self { + let member = MemberInfo { + name: name.to_string(), + fqn: format!("{}#{}", class_fqn, name), + kind: MemberKind::Method, + declaring_type: class_fqn.to_string(), + type_ref: return_type, + parameters: Some(vec![]), + modifiers: vec![], + generic_signature: None, + }; + + self.members + .entry(class_fqn.to_string()) + .or_default() + .push(member); + + self + } + + /// Add a field to a class. + pub fn add_field(mut self, class_fqn: &str, name: &str, field_type: TypeRef) -> Self { + let member = MemberInfo { + name: name.to_string(), + fqn: format!("{}#{}", class_fqn, name), + kind: MemberKind::Field, + declaring_type: class_fqn.to_string(), + type_ref: field_type, + parameters: None, + modifiers: vec![], + generic_signature: None, + }; + + self.members + .entry(class_fqn.to_string()) + .or_default() + .push(member); + + self + } +} + +impl TypeProvider for MockTypeSystem { + fn get_type_info(&self, fqn: &str) -> Option { + self.types.get(fqn).cloned() + } + + fn resolve_type_name(&self, simple_name: &str, _ctx: &TypeResolutionContext) -> Option { + // Simple mock: just check if we have a type ending with simple name + for fqn in self.types.keys() { + if fqn.ends_with(&format!(".{}", simple_name)) || fqn == simple_name { + return Some(fqn.clone()); + } + } + None + } +} + +impl InheritanceProvider for MockTypeSystem { + fn get_superclass(&self, fqn: &str) -> Option { + self.inheritance + .get(fqn) + .and_then(|(super_class, _)| super_class.clone()) + } + + fn get_interfaces(&self, fqn: &str) -> Vec { + self.inheritance + .get(fqn) + .map(|(_, interfaces)| interfaces.clone()) + .unwrap_or_default() + } + + fn get_direct_subtypes(&self, type_fqn: &str) -> Vec { + let mut subtypes = Vec::new(); + + // Check inheritance map for both superclass and interfaces + for (child, (super_class, interfaces)) in &self.inheritance { + // Check if superclass matches + if let Some(sc) = super_class { + if sc == type_fqn { + subtypes.push(child.clone()); + } + } + + // Check if any interface matches + if interfaces.contains(&type_fqn.to_string()) { + subtypes.push(child.clone()); + } + } + + subtypes + } + + fn walk_descendants(&self, type_fqn: &str) -> Box> { + let mut descendants = Vec::new(); + let mut queue = std::collections::VecDeque::new(); + queue.push_back(type_fqn.to_string()); + + let mut visited = std::collections::HashSet::new(); + visited.insert(type_fqn.to_string()); + + while let Some(current) = queue.pop_front() { + let direct = self.get_direct_subtypes(¤t); + for sub in direct { + if visited.insert(sub.clone()) { + descendants.push(sub.clone()); + queue.push_back(sub); + } + } + } + + Box::new(descendants.into_iter()) + } + + fn walk_ancestors(&self, fqn: &str) -> Box + '_> { + let mut result = vec![]; + let mut visited = std::collections::HashSet::new(); + let mut queue = std::collections::VecDeque::new(); + + // Start with direct parents + if let Some((super_class, interfaces)) = self.inheritance.get(fqn) { + if let Some(s) = super_class { + queue.push_back(s.clone()); + } + for i in interfaces { + queue.push_back(i.clone()); + } + } + + while let Some(current) = queue.pop_front() { + if visited.contains(¤t) { + continue; + } + visited.insert(current.clone()); + result.push(current.clone()); + + // Add parents + if let Some((super_class, interfaces)) = self.inheritance.get(¤t) { + if let Some(s) = super_class { + queue.push_back(s.clone()); + } + for i in interfaces { + queue.push_back(i.clone()); + } + } + } + + Box::new(result.into_iter()) + } +} + +impl MemberProvider for MockTypeSystem { + fn get_members(&self, type_fqn: &str, member_name: &str) -> Vec { + self.members + .get(type_fqn) + .cloned() + .unwrap_or_default() + .into_iter() + .filter(|m| m.name == member_name) + .collect() + } + + fn get_all_members(&self, type_fqn: &str) -> Vec { + self.members.get(type_fqn).cloned().unwrap_or_default() + } +} + +// ========================================================================= +// Mock Construction Tests +// ========================================================================= + +#[test] +fn test_mock_add_class() { + let ts = MockTypeSystem::new() + .add_class("Parent", None) + .add_class("Child", Some("Parent")); + + assert!(ts.get_type_info("Parent").is_some()); + assert!(ts.get_type_info("Child").is_some()); + assert_eq!(ts.get_superclass("Child"), Some("Parent".to_string())); +} + +#[test] +fn test_mock_add_method() { + let ts = MockTypeSystem::new().add_class("Foo", None).add_method( + "Foo", + "bar", + TypeRef::Id("String".into()), + ); + + let members = ts.get_members("Foo", "bar"); + assert!(!members.is_empty()); + assert_eq!(members[0].name, "bar"); +} + +#[test] +fn test_mock_add_field() { + let ts = MockTypeSystem::new().add_class("Foo", None).add_field( + "Foo", + "name", + TypeRef::Id("String".into()), + ); + + let members = ts.get_members("Foo", "name"); + assert!(!members.is_empty()); + let member = &members[0]; + assert_eq!(member.name, "name"); + assert_eq!(member.kind, MemberKind::Field); + assert_eq!(member.type_ref, TypeRef::Id("String".into())); +} + +// ========================================================================= +// Inheritance Hierarchy Tests +// ========================================================================= + +#[test] +fn test_find_inherited_member() { + let ts = MockTypeSystem::new() + .add_class("Parent", None) + .add_method("Parent", "getContext", TypeRef::Id("Context".into())) + .add_class("Child", Some("Parent")); + + // Child doesn't have getContext directly + assert!(ts.get_members("Child", "getContext").is_empty()); + + // But it should be found via hierarchy + let members = ts.find_member_in_hierarchy("Child", "getContext"); + assert!(!members.is_empty()); + assert_eq!(members[0].declaring_type, "Parent"); +} + +#[test] +fn test_walk_ancestors() { + let ts = MockTypeSystem::new() + .add_class("Object", None) + .add_class("Parent", Some("Object")) + .add_class("Child", Some("Parent")); + + let ancestors: Vec<_> = ts.walk_ancestors("Child").collect(); + assert!(ancestors.contains(&"Parent".to_string())); + assert!(ancestors.contains(&"Object".to_string())); +} + +#[test] +fn test_interfaces() { + let ts = MockTypeSystem::new() + .add_class("MyList", None) + .add_interface("List") + .add_interface("Iterable") + .implements("MyList", "List") + .implements("List", "Iterable"); + + let ifaces = ts.get_interfaces("MyList"); + assert!(ifaces.contains(&"List".to_string())); + + // Should walk to Iterable + let ancestors: Vec<_> = ts.walk_ancestors("MyList").collect(); + assert!(ancestors.contains(&"List".to_string())); + assert!(ancestors.contains(&"Iterable".to_string())); +} + +// ========================================================================= +// JavaTypeSystem Trait Tests (via MockTypeSystem) +// ========================================================================= + +#[test] +fn test_find_member_in_hierarchy_direct() { + // Test that direct members are found first + let ts = MockTypeSystem::new() + .add_class("Base", None) + .add_method("Base", "toString", TypeRef::Id("String".into())) + .add_class("Derived", Some("Base")) + .add_method("Derived", "toString", TypeRef::Id("StringOverride".into())); + + // Finding toString on Derived should return Derived's version + let members = ts.find_member_in_hierarchy("Derived", "toString"); + assert!(!members.is_empty()); + let member = &members[0]; + assert_eq!(member.declaring_type, "Derived"); + assert_eq!(member.type_ref, TypeRef::Id("StringOverride".into())); +} + +#[test] +fn test_find_member_in_hierarchy_inherited() { + let ts = MockTypeSystem::new() + .add_class("GrandParent", None) + .add_method( + "GrandParent", + "legacyMethod", + TypeRef::Id("LegacyType".into()), + ) + .add_class("Parent", Some("GrandParent")) + .add_class("Child", Some("Parent")); + + // Child should find GrandParent's method through hierarchy + let members = ts.find_member_in_hierarchy("Child", "legacyMethod"); + assert!(!members.is_empty()); + let member = &members[0]; + assert_eq!(member.declaring_type, "GrandParent"); +} + +#[test] +fn test_find_member_in_interface_hierarchy() { + let ts = MockTypeSystem::new() + .add_interface("Iterator") + .add_method("Iterator", "next", TypeRef::Id("Object".into())) + .add_interface("ListIterator") + .implements("ListIterator", "Iterator") + .add_class("ConcreteIterator", None) + .implements("ConcreteIterator", "ListIterator"); + + // ConcreteIterator should find Iterator's method + let members = ts.find_member_in_hierarchy("ConcreteIterator", "next"); + assert!(!members.is_empty()); + assert_eq!(members[0].declaring_type, "Iterator"); +} + +#[test] +fn test_find_nonexistent_member() { + let ts = MockTypeSystem::new().add_class("Class", None); + + let members = ts.find_member_in_hierarchy("Class", "nonexistent"); + assert!(members.is_empty()); +} + +// ========================================================================= +// Type Resolution Tests +// ========================================================================= + +#[test] +fn test_resolve_simple_type_name() { + let ts = MockTypeSystem::new() + .add_class("com.example.MyClass", None) + .add_class("String", None); + + let ctx = TypeResolutionContext::default(); + + // Simple name should match exact + assert_eq!( + ts.resolve_type_name("String", &ctx), + Some("String".to_string()) + ); + + // Should match types ending with the simple name + assert_eq!( + ts.resolve_type_name("MyClass", &ctx), + Some("com.example.MyClass".to_string()) + ); +} + +// ========================================================================= +// Field Access Chain Tests +// ========================================================================= + +#[test] +fn test_field_method_chain_types() { + // Simulates: a.b.doB() + // class A { B b; } + // class B { void doB() {} } + let ts = MockTypeSystem::new() + .add_class("A", None) + .add_field("A", "b", TypeRef::Id("B".into())) + .add_class("B", None) + .add_method("B", "doB", TypeRef::Raw("void".into())); + + // Verify field lookup + let field_b = ts.find_member_in_hierarchy("A", "b"); + assert!(!field_b.is_empty()); + let field_b = &field_b[0]; + assert_eq!(field_b.type_ref, TypeRef::Id("B".into())); + + // Verify method lookup on B + let method_dob = ts.find_member_in_hierarchy("B", "doB"); + assert!(!method_dob.is_empty()); + assert_eq!(method_dob[0].fqn, "B#doB"); +} + +#[test] +fn test_chained_method_calls() { + // Simulates: response.getContext().get("key") + // class HttpResponse { SessionContext getContext(); } + // class SessionContext { Object get(String key); } + let ts = MockTypeSystem::new() + .add_class("HttpResponse", None) + .add_method( + "HttpResponse", + "getContext", + TypeRef::Id("SessionContext".into()), + ) + .add_class("SessionContext", None) + .add_method("SessionContext", "get", TypeRef::Id("Object".into())); + + // Simulate chain resolution: + // 1. Find getContext on HttpResponse -> returns SessionContext + let step1 = ts.find_member_in_hierarchy("HttpResponse", "getContext"); + assert!(!step1.is_empty()); + let context_type = step1[0].type_ref.clone(); + assert_eq!(context_type, TypeRef::Id("SessionContext".into())); + + // 2. Find get on SessionContext -> returns Object + let type_fqn = match &context_type { + TypeRef::Id(fqn) => fqn.clone(), + _ => panic!("Expected Id type"), + }; + let step2 = ts.find_member_in_hierarchy(&type_fqn, "get"); + assert!(!step2.is_empty()); + assert_eq!(step2[0].type_ref, TypeRef::Id("Object".into())); +} + +// ========================================================================= +// Complex Inheritance Tests +// ========================================================================= + +#[test] +fn test_diamond_inheritance() { + // Test diamond inheritance pattern: + // Object + // / \ + // Readable Appendable + // \ / + // Stream + let ts = MockTypeSystem::new() + .add_interface("Object") + .add_method("Object", "toString", TypeRef::Id("String".into())) + .add_interface("Readable") + .add_method("Readable", "read", TypeRef::Raw("int".into())) + .implements("Readable", "Object") + .add_interface("Appendable") + .add_method("Appendable", "append", TypeRef::Id("Appendable".into())) + .implements("Appendable", "Object") + .add_class("Stream", None) + .implements("Stream", "Readable") + .implements("Stream", "Appendable"); + + // Stream should find all methods + assert!(!ts.find_member_in_hierarchy("Stream", "read").is_empty()); + assert!(!ts.find_member_in_hierarchy("Stream", "append").is_empty()); + assert!(!ts.find_member_in_hierarchy("Stream", "toString").is_empty()); +} + +#[test] +fn test_spring_boot_pattern() { + // Common Spring Boot pattern: + // class MyController { + // private MyService service; + // void handle() { service.doWork(); } + // } + let ts = MockTypeSystem::new() + .add_class("MyService", None) + .add_method("MyService", "doWork", TypeRef::Raw("void".into())) + .add_class("MyController", None) + .add_field("MyController", "service", TypeRef::Id("MyService".into())); + + // From MyController, find service field + let service_field = ts.find_member_in_hierarchy("MyController", "service"); + assert!(!service_field.is_empty()); + let service_type = service_field[0].type_ref.clone(); + assert_eq!(service_type, TypeRef::Id("MyService".into())); + + // From MyService, find doWork method + let type_fqn = match &service_type { + TypeRef::Id(fqn) => fqn.clone(), + _ => panic!("Expected Id type"), + }; + let do_work = ts.find_member_in_hierarchy(&type_fqn, "doWork"); + assert!(!do_work.is_empty()); +} + +// ========================================================================= +// Generic Type Tests (Basic) +// ========================================================================= + +#[test] +fn test_generic_return_type() { + // List.get(int) -> String (simplified) + // In reality this needs generic substitution, but we test the raw lookup + let ts = MockTypeSystem::new() + .add_interface("java.util.List") + .add_method("java.util.List", "get", TypeRef::Id("E".into())) // E is type parameter + .add_method("java.util.List", "size", TypeRef::Raw("int".into())); + + let get_method = ts.find_member_in_hierarchy("java.util.List", "get"); + assert!(!get_method.is_empty()); + // For now, returns the raw type parameter E + assert_eq!(get_method[0].type_ref, TypeRef::Id("E".into())); +} diff --git a/crates/lang-java/tests/java_integration.rs b/crates/lang-java/tests/java_integration.rs index fcf1b4e..9724b9e 100644 --- a/crates/lang-java/tests/java_integration.rs +++ b/crates/lang-java/tests/java_integration.rs @@ -266,6 +266,7 @@ fn test_lambda_explicit_type_resolution() { } #[test] +#[ignore = "Requires generics inference to propagate type parameters from List to lambda"] fn test_lambda_heuristic_type_inference() { let files = vec![ ( From 507183dfca21aaf664900dccfc854a94b1ebaad7 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Sun, 8 Feb 2026 13:51:07 +0800 Subject: [PATCH 09/49] feat: Introduce `LspService` trait, refactor core to use it, and add initial Java LSP capabilities with type inference planning documents. --- crates/core/src/facade/mod.rs | 14 +- crates/core/src/facade/semantic.rs | 32 +-- crates/core/src/features/discovery.rs | 8 +- crates/core/src/ingest/parser/mod.rs | 2 +- crates/core/src/ingest/resolver/engine.rs | 4 +- crates/core/tests/semantic_traits.rs | 8 +- .../lang-java/src/inference/adapters/mod.rs | 2 + .../lang-java/src/inference/adapters/noop.rs | 48 ++++ .../lang-java/src/inference/scope/builder.rs | 18 +- crates/lang-java/src/lib.rs | 36 +-- crates/lang-java/src/lsp/mod.rs | 53 +++++ crates/lang-java/src/lsp/references.rs | 182 +++++++++++++++ crates/lang-java/src/lsp/symbols.rs | 62 ++++++ crates/lang-java/src/parser/lsp.rs | 208 ------------------ crates/lang-java/src/parser/mod.rs | 7 +- crates/lang-java/tests/logic_hierarchy.rs | 7 +- crates/plugin/src/plugin.rs | 4 +- crates/plugin/src/resolver.rs | 21 +- 18 files changed, 415 insertions(+), 301 deletions(-) create mode 100644 crates/lang-java/src/inference/adapters/noop.rs create mode 100644 crates/lang-java/src/lsp/mod.rs create mode 100644 crates/lang-java/src/lsp/references.rs create mode 100644 crates/lang-java/src/lsp/symbols.rs delete mode 100644 crates/lang-java/src/parser/lsp.rs diff --git a/crates/core/src/facade/mod.rs b/crates/core/src/facade/mod.rs index cf28266..9b90419 100644 --- a/crates/core/src/facade/mod.rs +++ b/crates/core/src/facade/mod.rs @@ -43,11 +43,11 @@ impl EngineHandle { // ---- Language specific services (internal) ---- - pub fn get_lsp_parser( + pub fn get_lsp_service( &self, language: crate::model::source::Language, - ) -> Option> { - self.engine.get_resolver().get_lsp_parser(language) + ) -> Option> { + self.engine.get_resolver().get_lsp_service(language) } pub fn get_semantic_resolver( @@ -68,17 +68,17 @@ impl EngineHandle { self.engine.get_resolver().get_language_by_extension(ext) } - pub fn get_parser_and_lang_for_path( + pub fn get_lsp_service_and_lang_for_path( &self, path: &std::path::Path, ) -> Option<( - Arc, + Arc, crate::model::source::Language, )> { let ext = path.extension()?.to_str()?; let lang = self.get_language_by_extension(ext)?; - let parser = self.get_lsp_parser(lang.clone())?; - Some((parser, lang)) + let lsp_service = self.get_lsp_service(lang.clone())?; + Some((lsp_service, lang)) } /// Get naming convention for a specific language diff --git a/crates/core/src/facade/semantic.rs b/crates/core/src/facade/semantic.rs index dca4896..823ac8a 100644 --- a/crates/core/src/facade/semantic.rs +++ b/crates/core/src/facade/semantic.rs @@ -31,7 +31,7 @@ impl SymbolNavigator for EngineHandle { PathBuf::from(uri_str) }; - let (parser, lang) = match self.get_parser_and_lang_for_path(&path) { + let (lsp_service, lang) = match self.get_lsp_service_and_lang_for_path(&path) { Some(x) => x, None => return Ok(None), }; @@ -47,7 +47,7 @@ impl SymbolNavigator for EngineHandle { fs::read_to_string(&path).map_err(|e| SemanticError::Internal(e.to_string()))? }; - let tree = parser + let tree = lsp_service .parse(&content, None) .ok_or_else(|| SemanticError::Internal("Failed to parse".into()))?; @@ -66,7 +66,7 @@ impl SymbolNavigator for EngineHandle { PathBuf::from(uri_str) }; - let (parser, _) = match self.get_parser_and_lang_for_path(&path) { + let (lsp_service, _) = match self.get_lsp_service_and_lang_for_path(&path) { Some(x) => x, None => return Ok(vec![]), }; @@ -77,7 +77,7 @@ impl SymbolNavigator for EngineHandle { fs::read_to_string(&path).map_err(|e| SemanticError::Internal(e.to_string()))? }; - let tree = parser + let tree = lsp_service .parse(&content, None) .ok_or_else(|| SemanticError::Internal("Failed to parse".into()))?; @@ -86,7 +86,7 @@ impl SymbolNavigator for EngineHandle { None => return Ok(vec![]), }; - Ok(parser.find_occurrences(&content, &tree, &res)) + Ok(lsp_service.find_occurrences(&content, &tree, &res)) } async fn find_definitions(&self, query: &SymbolQuery) -> SemanticResult> { @@ -209,7 +209,8 @@ impl ReferenceAnalyzer for EngineHandle { let conventions_clone = conventions.clone(); tasks.spawn(async move { - let (parser, file_lang) = match handle.get_parser_and_lang_for_path(&path) { + let (lsp_service, file_lang) = match handle.get_lsp_service_and_lang_for_path(&path) + { Some(x) => x, None => return Vec::new(), }; @@ -233,7 +234,7 @@ impl ReferenceAnalyzer for EngineHandle { }; let locations = discovery.scan_file( - parser.as_ref(), + lsp_service.as_ref(), file_resolver.as_ref(), &content, &resolution, @@ -330,7 +331,8 @@ impl CallHierarchyAnalyzer for EngineHandle { let conventions_clone = conventions.clone(); tasks.spawn(async move { - let (parser, file_lang) = match handle.get_parser_and_lang_for_path(&path) { + let (lsp_service, file_lang) = match handle.get_lsp_service_and_lang_for_path(&path) + { Some(x) => x, None => return vec![], }; @@ -354,7 +356,7 @@ impl CallHierarchyAnalyzer for EngineHandle { // Verification discovery.scan_file( - parser.as_ref(), + lsp_service.as_ref(), file_resolver.as_ref(), &content, &res, @@ -444,8 +446,8 @@ impl CallHierarchyAnalyzer for EngineHandle { .range() .ok_or_else(|| SemanticError::Internal("Node has no range".into()))?; - let (parser, lang) = self - .get_parser_and_lang_for_path(&path) + let (lsp_service, lang) = self + .get_lsp_service_and_lang_for_path(&path) .ok_or_else(|| SemanticError::Internal("No parser for file".into()))?; let resolver = self .get_semantic_resolver(lang) @@ -455,7 +457,7 @@ impl CallHierarchyAnalyzer for EngineHandle { fs::read_to_string(&path).map_err(|e| SemanticError::Internal(e.to_string()))?; // Micro-level scanning: extract method body and find all calls - let tree = parser + let tree = lsp_service .parse(&content, None) .ok_or_else(|| SemanticError::Internal("Failed to parse".into()))?; @@ -553,7 +555,7 @@ impl SymbolInfoProvider for EngineHandle { PathBuf::from(uri) }; - let (parser, _lang) = match self.get_parser_and_lang_for_path(&path) { + let (lsp_service, _lang) = match self.get_lsp_service_and_lang_for_path(&path) { Some(x) => x, None => return Ok(vec![]), }; @@ -561,11 +563,11 @@ impl SymbolInfoProvider for EngineHandle { let content = fs::read_to_string(&path).map_err(|e| SemanticError::Internal(e.to_string()))?; - let tree = parser + let tree = lsp_service .parse(&content, None) .ok_or_else(|| SemanticError::Internal("Failed to parse".into()))?; - let symbols = parser.extract_symbols(&tree, &content); + let symbols = lsp_service.extract_symbols(&tree, &content); Ok(symbols) } diff --git a/crates/core/src/features/discovery.rs b/crates/core/src/features/discovery.rs index a4887e0..0f278b5 100644 --- a/crates/core/src/features/discovery.rs +++ b/crates/core/src/features/discovery.rs @@ -1,5 +1,5 @@ use super::CodeGraphLike; -use crate::ingest::parser::LspParser; +use crate::ingest::parser::LspService; use lsp_types::{Location, Url}; pub use naviscope_api::models::SymbolResolution; use std::collections::HashSet; @@ -148,15 +148,15 @@ impl<'a> DiscoveryEngine<'a> { /// Now performs SEMANTIC VERIFICATION using the Resolver. pub fn scan_file( &self, - parser: &dyn LspParser, + lsp_service: &dyn LspService, resolver: &dyn crate::ingest::resolver::SemanticResolver, source: &str, target_resolution: &SymbolResolution, uri: &Url, ) -> Vec { - if let Some(tree) = parser.parse(source, None) { + if let Some(tree) = lsp_service.parse(source, None) { // 1. Syntactic Scan (Fast) - let candidates = parser.find_occurrences(source, &tree, target_resolution); + let candidates = lsp_service.find_occurrences(source, &tree, target_resolution); // 2. Semantic Verification (Precise) let mut valid_locations = Vec::new(); diff --git a/crates/core/src/ingest/parser/mod.rs b/crates/core/src/ingest/parser/mod.rs index 9fb6c28..ef9c9e0 100644 --- a/crates/core/src/ingest/parser/mod.rs +++ b/crates/core/src/ingest/parser/mod.rs @@ -6,7 +6,7 @@ pub mod utils; pub use naviscope_api::SymbolResolution; pub use naviscope_api::models::symbol::NodeId; -pub use naviscope_plugin::{GlobalParseResult, IndexNode, IndexRelation, LspParser, ParseOutput}; +pub use naviscope_plugin::{GlobalParseResult, IndexNode, IndexRelation, LspService, ParseOutput}; /// Trait for parsers that provide data for the global code knowledge graph. pub trait IndexParser: Send + Sync { diff --git a/crates/core/src/ingest/resolver/engine.rs b/crates/core/src/ingest/resolver/engine.rs index 79b1dbd..f6e7696 100644 --- a/crates/core/src/ingest/resolver/engine.rs +++ b/crates/core/src/ingest/resolver/engine.rs @@ -56,10 +56,10 @@ impl IndexResolver { .map(|p| p.resolver()) } - pub fn get_lsp_parser( + pub fn get_lsp_service( &self, language: Language, - ) -> Option> { + ) -> Option> { self.lang_plugins .iter() .find(|p| p.name() == language) diff --git a/crates/core/tests/semantic_traits.rs b/crates/core/tests/semantic_traits.rs index 89868d0..f8fdc89 100644 --- a/crates/core/tests/semantic_traits.rs +++ b/crates/core/tests/semantic_traits.rs @@ -9,7 +9,7 @@ use naviscope_core::facade::EngineHandle; // naviscope_core imports removed in favor of naviscope_plugin use naviscope_core::runtime::orchestrator::NaviscopeEngine as CoreEngine; use naviscope_plugin::{ - GlobalParseResult, LangResolver, LanguagePlugin, LspParser, NamingConvention, NodeAdapter, + GlobalParseResult, LangResolver, LanguagePlugin, LspService, NamingConvention, NodeAdapter, ParsedFile, PluginInstance, ProjectContext, ResolvedUnit, SemanticResolver, StorageContext, }; use std::any::Any; @@ -138,7 +138,7 @@ impl LanguagePlugin for MockPlugin { fn lang_resolver(&self) -> Arc { self.lang_resolver.clone() } - fn lsp_parser(&self) -> Arc { + fn lsp_parser(&self) -> Arc { Arc::new(MockLspParserWrapper { parser: self.lsp_parser.clone(), }) @@ -159,7 +159,7 @@ struct MockLspParserWrapper { parser: Arc, } -impl LspParser for MockLspParserWrapper { +impl LspService for MockLspParserWrapper { fn parse(&self, source: &str, old_tree: Option<&Tree>) -> Option { self.parser.parse(source, old_tree) } @@ -267,7 +267,7 @@ impl SemanticResolver for MockResolver { } struct MockLspParser; -impl LspParser for MockLspParser { +impl LspService for MockLspParser { fn parse(&self, source: &str, _old_tree: Option<&Tree>) -> Option { let mut parser = tree_sitter::Parser::new(); parser diff --git a/crates/lang-java/src/inference/adapters/mod.rs b/crates/lang-java/src/inference/adapters/mod.rs index af428e3..1fb15ee 100644 --- a/crates/lang-java/src/inference/adapters/mod.rs +++ b/crates/lang-java/src/inference/adapters/mod.rs @@ -2,6 +2,8 @@ mod graph; mod heuristic; +mod noop; pub use graph::CodeGraphTypeSystem; pub use heuristic::HeuristicAdapter; +pub use noop::NoOpTypeSystem; diff --git a/crates/lang-java/src/inference/adapters/noop.rs b/crates/lang-java/src/inference/adapters/noop.rs new file mode 100644 index 0000000..41353d2 --- /dev/null +++ b/crates/lang-java/src/inference/adapters/noop.rs @@ -0,0 +1,48 @@ +use crate::inference::core::type_system::{InheritanceProvider, MemberProvider, TypeProvider}; +use crate::inference::core::types::{MemberInfo, TypeInfo, TypeResolutionContext}; + +/// A no-op implementation of JavaTypeSystem. +/// Useful for testing or when global index is unavailable. +pub struct NoOpTypeSystem; + +impl TypeProvider for NoOpTypeSystem { + fn resolve_type_name(&self, _name: &str, _ctx: &TypeResolutionContext) -> Option { + None + } + + fn get_type_info(&self, _fqn: &str) -> Option { + None + } +} + +impl InheritanceProvider for NoOpTypeSystem { + fn get_superclass(&self, _fqn: &str) -> Option { + None + } + + fn get_interfaces(&self, _fqn: &str) -> Vec { + vec![] + } + + fn walk_ancestors(&self, _fqn: &str) -> Box + '_> { + Box::new(std::iter::empty()) + } + + fn get_direct_subtypes(&self, _fqn: &str) -> Vec { + vec![] + } + + fn walk_descendants(&self, _fqn: &str) -> Box + '_> { + Box::new(std::iter::empty()) + } +} + +impl MemberProvider for NoOpTypeSystem { + fn get_members(&self, _type_fqn: &str, _member_name: &str) -> Vec { + vec![] + } + + fn get_all_members(&self, _type_fqn: &str) -> Vec { + vec![] + } +} diff --git a/crates/lang-java/src/inference/scope/builder.rs b/crates/lang-java/src/inference/scope/builder.rs index f15a013..18c8d14 100644 --- a/crates/lang-java/src/inference/scope/builder.rs +++ b/crates/lang-java/src/inference/scope/builder.rs @@ -140,6 +140,16 @@ impl<'a, 'b> ScopeBuilder<'a, 'b> { } fn process_parameter_list(&mut self, params_node: &Node, scope_id: usize) { + // Handle single parameter lambda: 'x -> ...' where params_node is the identifier + if params_node.kind() == "identifier" { + if let Ok(name) = params_node.utf8_text(self.ctx.source.as_bytes()) { + let range = range_from_ts(params_node.range()); + self.manager + .add_symbol(scope_id, name.to_string(), TypeRef::Unknown, range); + } + return; + } + let mut cursor = params_node.walk(); for child in params_node.children(&mut cursor) { match child.kind() { @@ -147,13 +157,13 @@ impl<'a, 'b> ScopeBuilder<'a, 'b> { self.process_single_parameter(&child, scope_id); } "inferred_parameters" => { - // TODO: Inferred lambda parameters require target type context + // Recurse to handle (x, y) -> ... + self.process_parameter_list(&child, scope_id); } "identifier" => { - // Single lambda param: x -> ... + // This case is for children of inferred_parameters if we are inside one, + // or if structure allows. if let Ok(name) = child.utf8_text(self.ctx.source.as_bytes()) { - // Type is unknown at this stage without context - // Register with Unknown type - inference can refine later let range = range_from_ts(child.range()); self.manager.add_symbol( scope_id, diff --git a/crates/lang-java/src/lib.rs b/crates/lang-java/src/lib.rs index af4e981..7449032 100644 --- a/crates/lang-java/src/lib.rs +++ b/crates/lang-java/src/lib.rs @@ -1,6 +1,7 @@ pub mod discoverer; pub mod inference; pub mod jdk; +pub mod lsp; pub mod model; pub mod naming; pub mod parser; @@ -11,10 +12,10 @@ pub use discoverer::JdkDiscoverer; use lasso::Key; use naviscope_api::models::graph::{EmptyMetadata, GraphNode, NodeKind}; -use naviscope_api::models::symbol::{FqnReader, Symbol, SymbolResolution}; +use naviscope_api::models::symbol::{FqnReader, Symbol}; use naviscope_api::models::{DisplayGraphNode, Language}; use naviscope_plugin::{ - AssetIndexer, AssetSourceLocator, GlobalParseResult, LangResolver, LanguagePlugin, LspParser, + AssetIndexer, AssetSourceLocator, GlobalParseResult, LangResolver, LanguagePlugin, LspService, NamingConvention, NodeAdapter, PluginInstance, SemanticResolver, StorageContext, }; use std::path::Path; @@ -288,8 +289,8 @@ impl LanguagePlugin for JavaPlugin { self.resolver.clone() } - fn lsp_parser(&self) -> Arc { - Arc::new(self.clone()) + fn lsp_parser(&self) -> Arc { + Arc::new(crate::lsp::JavaLspService::new(self.parser.clone())) } fn external_resolver(&self) -> Option> { @@ -312,30 +313,3 @@ impl LanguagePlugin for JavaPlugin { Some(Arc::new(crate::resolver::external::JavaExternalResolver)) } } - -impl LspParser for JavaPlugin { - fn parse( - &self, - source: &str, - old_tree: Option<&tree_sitter::Tree>, - ) -> Option { - self.parser.parse(source, old_tree) - } - - fn extract_symbols(&self, tree: &tree_sitter::Tree, source: &str) -> Vec { - self.parser.extract_symbols(tree, source) - } - - fn symbol_kind(&self, kind: &naviscope_api::models::graph::NodeKind) -> lsp_types::SymbolKind { - self.parser.symbol_kind(kind) - } - - fn find_occurrences( - &self, - source: &str, - tree: &tree_sitter::Tree, - target: &SymbolResolution, - ) -> Vec { - self.parser.find_occurrences(source, tree, target) - } -} diff --git a/crates/lang-java/src/lsp/mod.rs b/crates/lang-java/src/lsp/mod.rs new file mode 100644 index 0000000..a9abb2e --- /dev/null +++ b/crates/lang-java/src/lsp/mod.rs @@ -0,0 +1,53 @@ +mod references; +mod symbols; + +use crate::parser::JavaParser; +use naviscope_api::models::SymbolResolution; +use naviscope_api::models::graph::{DisplayGraphNode, NodeKind}; +use naviscope_plugin::LspService; +use std::sync::Arc; +use tree_sitter::Tree; + +pub struct JavaLspService { + parser: Arc, +} + +impl JavaLspService { + pub fn new(parser: Arc) -> Self { + Self { parser } + } +} + +impl LspService for JavaLspService { + fn parse(&self, source: &str, old_tree: Option<&Tree>) -> Option { + self.parser.parse(source, old_tree) + } + + fn extract_symbols(&self, tree: &Tree, source: &str) -> Vec { + symbols::extract_symbols(&self.parser, tree, source) + } + + fn symbol_kind(&self, kind: &NodeKind) -> lsp_types::SymbolKind { + use lsp_types::SymbolKind; + match kind { + 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, + NodeKind::Package => SymbolKind::PACKAGE, + _ => SymbolKind::VARIABLE, + } + } + + fn find_occurrences( + &self, + source: &str, + tree: &Tree, + target: &SymbolResolution, + ) -> Vec { + references::find_occurrences(&self.parser, source, tree, target) + } +} diff --git a/crates/lang-java/src/lsp/references.rs b/crates/lang-java/src/lsp/references.rs new file mode 100644 index 0000000..63ac583 --- /dev/null +++ b/crates/lang-java/src/lsp/references.rs @@ -0,0 +1,182 @@ +use crate::inference::adapters::NoOpTypeSystem; +use crate::inference::create_inference_context; +use crate::inference::scope::ScopeManager; +use crate::parser::JavaParser; +use naviscope_api::models::SymbolResolution; +use naviscope_api::models::symbol::Range; +use naviscope_plugin::utils::{line_col_at_to_offset, range_from_ts}; +use tree_sitter::{Node, Tree}; + +pub fn find_occurrences( + parser: &JavaParser, + source: &str, + tree: &Tree, + target: &SymbolResolution, +) -> Vec { + let mut ranges = Vec::new(); + + // 1. Setup Type System & Scope Manager + // We use NoOpTypeSystem because LspParser typically doesn't have access to the global index. + // This is sufficient for precise local variable resolution. + let ts = NoOpTypeSystem; + let mut scope_manager = ScopeManager::new(); + + // 2. Extract package and imports + let (package, imports) = parser.extract_package_and_imports(tree, source); + + // 3. Build Inference Context (populates ScopeManager with all local definitions) + let _ctx = create_inference_context( + &tree.root_node(), + source, + &ts, + &mut scope_manager, + package, + imports, + ); + + // 4. Handle based on resolution type + match target { + SymbolResolution::Local(decl_range, _decl_name) => { + // Case A: Local Variable + // We want to find all usages that resolve to THIS specific declaration range. + + // Extract the variable name from the source if possible + if let Some(name) = extract_name_from_range(source, decl_range) { + // Find all identifiers that match the name + find_matching_identifiers( + tree, + source, + &name, + |node, sm| { + // Start scope search from this node's scope + if let Some(scope_id) = find_start_scope_id(node, sm) { + if let Some(info) = sm.lookup_symbol(scope_id, &name) { + // MATCH condition: The resolved symbol must have the exact same declaration range + if info.range == *decl_range { + return true; + } + } + } + false + }, + &scope_manager, + &mut ranges, + ); + + // Also add the declaration itself if not already covered (usually identifiers cover it) + // But let's ensure we don't duplicate. find_matching_identifiers scans all nodes. + } + } + SymbolResolution::Precise(fqn, _) | SymbolResolution::Global(fqn) => { + // Case B: Global/Member Symbol + // We want to find usages that DO NOT resolve to a local variable. + // Since we don't have full type resolution, this is a "best effort" semantic search. + + let name = fqn + .split(|c| c == '.' || c == '#' || c == '$') + .last() + .unwrap_or(fqn); + + if name.is_empty() { + return ranges; + } + + find_matching_identifiers( + tree, + source, + name, + |node, sm| { + if let Some(scope_id) = find_start_scope_id(node, sm) { + // If it resolves to a LOCAL variable, it is SHADOWED -> Not a match + if sm.lookup_symbol(scope_id, name).is_some() { + return false; + } + } + // If not locally resolved, it refers to a field/method/class. + // Without global index, we assume it matches the target FQN if name matches. + // Improvement: Check imports? + true + }, + &scope_manager, + &mut ranges, + ); + } + } + + ranges +} + +fn extract_name_from_range(source: &str, range: &Range) -> Option { + let start = line_col_at_to_offset(source, range.start_line, range.start_col)?; + let end = line_col_at_to_offset(source, range.end_line, range.end_col)?; + if start < end && end <= source.len() { + Some(source[start..end].to_string()) + } else { + None + } +} + +fn find_matching_identifiers( + tree: &Tree, + source: &str, + target_name: &str, + predicate: F, + scope_manager: &ScopeManager, + ranges: &mut Vec, +) where + F: Fn(&Node, &ScopeManager) -> bool, +{ + // A simple recursive walker is sufficient + visit_tree_recursive( + &tree.root_node(), + source, + target_name, + &predicate, + scope_manager, + ranges, + ); +} + +fn visit_tree_recursive( + node: &Node, + source: &str, + target_name: &str, + predicate: &F, + scope_manager: &ScopeManager, + ranges: &mut Vec, +) where + F: Fn(&Node, &ScopeManager) -> bool, +{ + if node.kind() == "identifier" || node.kind() == "type_identifier" { + if let Ok(text) = node.utf8_text(source.as_bytes()) { + if text == target_name { + if predicate(node, scope_manager) { + ranges.push(range_from_ts(node.range())); + } + } + } + } + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + visit_tree_recursive( + &child, + source, + target_name, + predicate, + scope_manager, + ranges, + ); + } +} + +fn find_start_scope_id(node: &Node, sm: &ScopeManager) -> Option { + let mut current = *node; + while let Some(parent) = current.parent() { + if let Some(sid) = sm.get_scope_id(parent.id()) { + return Some(sid); + } + current = parent; + } + None +} diff --git a/crates/lang-java/src/lsp/symbols.rs b/crates/lang-java/src/lsp/symbols.rs new file mode 100644 index 0000000..66683e9 --- /dev/null +++ b/crates/lang-java/src/lsp/symbols.rs @@ -0,0 +1,62 @@ +use crate::parser::JavaParser; +use naviscope_api::models::graph::{DisplayGraphNode, NodeKind}; +use naviscope_plugin::utils::{RawSymbol, build_symbol_hierarchy, range_from_ts}; +use std::collections::HashMap; +use tree_sitter::Tree; + +pub fn extract_symbols(parser: &JavaParser, tree: &Tree, source: &str) -> Vec { + // Only run Stage 1: Identification of entities. + // We don't need full FQN resolution (naming) or relation resolution (Stage 3) + // for building the local document symbol tree. + let mut entities = Vec::new(); + let mut relations = Vec::new(); + let mut entities_map = HashMap::new(); + + let all_matches = parser.collect_matches(tree, source); + + // Pass None for package to keep FQNs local/relative during symbol extraction + parser.identify_entities( + &all_matches, + source, + &None, + &mut entities, + &mut relations, + &mut entities_map, + ); + + // Convert JavaEntity to RawSymbol for the tree builder + let raw_symbols = entities + .into_iter() + .map(|e| { + let kind = match e.element { + crate::model::JavaIndexMetadata::Class { .. } => NodeKind::Class, + crate::model::JavaIndexMetadata::Interface { .. } => NodeKind::Interface, + crate::model::JavaIndexMetadata::Enum { .. } => NodeKind::Enum, + crate::model::JavaIndexMetadata::Annotation { .. } => NodeKind::Annotation, + crate::model::JavaIndexMetadata::Method { is_constructor, .. } => { + if is_constructor { + NodeKind::Constructor + } else { + NodeKind::Method + } + } + crate::model::JavaIndexMetadata::Field { .. } => NodeKind::Field, + crate::model::JavaIndexMetadata::Package => NodeKind::Package, + }; + + RawSymbol { + name: e.name, + kind, + range: range_from_ts(e.node.range()), + selection_range: e + .node + .child_by_field_name("name") + .map(|n| range_from_ts(n.range())) + .unwrap_or_else(|| range_from_ts(e.node.range())), + node: e.node, + } + }) + .collect(); + + build_symbol_hierarchy(raw_symbols) +} diff --git a/crates/lang-java/src/parser/lsp.rs b/crates/lang-java/src/parser/lsp.rs deleted file mode 100644 index e559224..0000000 --- a/crates/lang-java/src/parser/lsp.rs +++ /dev/null @@ -1,208 +0,0 @@ -use super::JavaParser; -use naviscope_api::models::graph::NodeKind; -use naviscope_plugin::LspParser; -use naviscope_plugin::utils::{RawSymbol, build_symbol_hierarchy, line_col_at_to_offset}; -use std::collections::HashMap; -use tree_sitter::Tree; - -impl LspParser for JavaParser { - fn parse(&self, source: &str, old_tree: Option<&Tree>) -> Option { - let mut parser = tree_sitter::Parser::new(); - parser.set_language(&self.language).ok()?; - parser.parse(source, old_tree) - } - - fn extract_symbols( - &self, - tree: &Tree, - source: &str, - ) -> Vec { - self.extract_symbols(tree, source) - } - - fn symbol_kind(&self, kind: &NodeKind) -> lsp_types::SymbolKind { - self.symbol_kind(kind) - } - - fn find_occurrences( - &self, - source: &str, - tree: &Tree, - target: &naviscope_api::models::SymbolResolution, - ) -> Vec { - self.find_occurrences(source, tree, target) - } -} - -impl JavaParser { - pub fn parse(&self, source: &str, old_tree: Option<&Tree>) -> Option { - let mut parser = tree_sitter::Parser::new(); - parser.set_language(&self.language).ok()?; - parser.parse(source, old_tree) - } - - pub fn extract_symbols( - &self, - tree: &Tree, - source: &str, - ) -> Vec { - // Only run Stage 1: Identification of entities. - // We don't need full FQN resolution (naming) or relation resolution (Stage 3) - // for building the local document symbol tree. - let mut entities = Vec::new(); - let mut relations = Vec::new(); - let mut entities_map = HashMap::new(); - - let all_matches = self.collect_matches(tree, source); - - // Pass None for package to keep FQNs local/relative during symbol extraction - self.identify_entities( - &all_matches, - source, - &None, - &mut entities, - &mut relations, - &mut entities_map, - ); - - // Convert JavaEntity to RawSymbol for the tree builder - let raw_symbols = entities - .into_iter() - .map(|e| { - let kind = match e.element { - crate::model::JavaIndexMetadata::Class { .. } => NodeKind::Class, - crate::model::JavaIndexMetadata::Interface { .. } => NodeKind::Interface, - crate::model::JavaIndexMetadata::Enum { .. } => NodeKind::Enum, - crate::model::JavaIndexMetadata::Annotation { .. } => NodeKind::Annotation, - crate::model::JavaIndexMetadata::Method { is_constructor, .. } => { - if is_constructor { - NodeKind::Constructor - } else { - NodeKind::Method - } - } - crate::model::JavaIndexMetadata::Field { .. } => NodeKind::Field, - crate::model::JavaIndexMetadata::Package => NodeKind::Package, - }; - - RawSymbol { - name: e.name, - kind, - range: naviscope_plugin::utils::range_from_ts(e.node.range()), - selection_range: e - .node - .child_by_field_name("name") - .map(|n| naviscope_plugin::utils::range_from_ts(n.range())) - .unwrap_or_else(|| naviscope_plugin::utils::range_from_ts(e.node.range())), - node: e.node, - } - }) - .collect(); - - build_symbol_hierarchy(raw_symbols) - } - - pub fn symbol_kind(&self, kind: &NodeKind) -> lsp_types::SymbolKind { - use lsp_types::SymbolKind; - match kind { - 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, - NodeKind::Package => SymbolKind::PACKAGE, - _ => SymbolKind::VARIABLE, - } - } - - pub fn find_occurrences( - &self, - source: &str, - tree: &Tree, - target: &naviscope_api::models::SymbolResolution, - ) -> Vec { - let mut ranges = Vec::new(); - - // 1. Extract the identifier name and intent - let (name, intent) = match target { - naviscope_api::models::SymbolResolution::Local(range, _) => { - // For local symbols, we extract the name directly from the source at the declaration range - let start = line_col_at_to_offset(source, range.start_line, range.start_col); - let end = line_col_at_to_offset(source, range.end_line, range.end_col); - - if let (Some(s), Some(e)) = (start, end) { - if s < e && e <= source.len() { - ( - source[s..e].to_string(), - naviscope_api::models::SymbolIntent::Variable, - ) - } else { - return Vec::new(); - } - } else { - return Vec::new(); - } - } - naviscope_api::models::SymbolResolution::Precise(fqn, intent) => ( - fqn.split(|c| c == '.' || c == '#' || c == '$') - .last() - .unwrap_or(fqn) - .to_string(), - *intent, - ), - naviscope_api::models::SymbolResolution::Global(fqn) => ( - fqn.split(|c| c == '.' || c == '#' || c == '$') - .last() - .unwrap_or(fqn) - .to_string(), - naviscope_api::models::SymbolIntent::Unknown, - ), - }; - - if name.is_empty() { - return ranges; - } - - let mut cursor = tree_sitter::QueryCursor::new(); - let mut matches = - cursor.matches(&self.occurrence_query, tree.root_node(), source.as_bytes()); - - // Mapping from Intent to the capture index we care about - let target_capture_index = match intent { - naviscope_api::models::SymbolIntent::Method => Some(self.occurrence_indices.method), - naviscope_api::models::SymbolIntent::Type => Some(self.occurrence_indices.type_alias), - naviscope_api::models::SymbolIntent::Field => Some(self.occurrence_indices.field), - _ => None, // Search all identifiers - }; - - use tree_sitter::StreamingIterator; - while let Some(mat) = matches.next() { - // Optimization: If intent is specific, skip matches that don't satisfy the intent structure. - if let Some(target_idx) = target_capture_index { - if !mat.captures.iter().any(|c| c.index == target_idx) { - continue; - } - } - - // Extract the identifier node using our indices - for cap in mat.captures { - if cap.index == self.occurrence_indices.ident { - if let Ok(text) = cap.node.utf8_text(source.as_bytes()) { - if text == name { - let r = cap.node.range(); - ranges.push(naviscope_api::models::symbol::Range { - start_line: r.start_point.row, - start_col: r.start_point.column, - end_line: r.end_point.row, - end_col: r.end_point.column, - }); - } - } - } - } - } - ranges - } -} diff --git a/crates/lang-java/src/parser/mod.rs b/crates/lang-java/src/parser/mod.rs index eceec15..e3b6534 100644 --- a/crates/lang-java/src/parser/mod.rs +++ b/crates/lang-java/src/parser/mod.rs @@ -5,7 +5,6 @@ use tree_sitter::{Query, StreamingIterator, Tree}; mod ast; mod constants; mod index; -mod lsp; mod naming; mod scope; mod types; @@ -58,6 +57,12 @@ impl JavaParser { }) } + pub fn parse(&self, source: &str, old_tree: Option<&Tree>) -> Option { + let mut parser = tree_sitter::Parser::new(); + parser.set_language(&self.language).ok()?; + parser.parse(source, old_tree) + } + pub fn extract_package_and_imports( &self, tree: &Tree, diff --git a/crates/lang-java/tests/logic_hierarchy.rs b/crates/lang-java/tests/logic_hierarchy.rs index 09a05c0..2f87652 100644 --- a/crates/lang-java/tests/logic_hierarchy.rs +++ b/crates/lang-java/tests/logic_hierarchy.rs @@ -5,6 +5,7 @@ use naviscope_core::features::CodeGraphLike; use naviscope_core::features::discovery::DiscoveryEngine; use naviscope_core::ingest::parser::SymbolResolution; use naviscope_core::ingest::resolver::SemanticResolver; +use naviscope_java::lsp::JavaLspService; use naviscope_java::resolver::JavaResolver; #[test] @@ -42,7 +43,8 @@ fn test_call_hierarchy_incoming() { let uri = lsp_types::Url::from_file_path(&abs_path).unwrap(); for path in candidate_files { - let locations = discovery.scan_file(&resolver.parser, &resolver, content, &res, &uri); + let lsp_service = JavaLspService::new(std::sync::Arc::new(resolver.parser.clone())); + let locations = discovery.scan_file(&lsp_service, &resolver, content, &res, &uri); for loc in locations { if let Some(container_idx) = index.find_container_node_at( &path, @@ -172,7 +174,8 @@ fn test_call_hierarchy_recursion() { let abs_path = std::env::current_dir().unwrap().join("Test.java"); let uri = lsp_types::Url::from_file_path(&abs_path).unwrap(); - let locations = discovery.scan_file(&resolver.parser, &resolver, content, &res, &uri); + let lsp_service = JavaLspService::new(std::sync::Arc::new(resolver.parser.clone())); + let locations = discovery.scan_file(&lsp_service, &resolver, content, &res, &uri); for loc in locations { if let Some(c_idx) = index.find_container_node_at( &std::path::PathBuf::from("Test.java"), diff --git a/crates/plugin/src/plugin.rs b/crates/plugin/src/plugin.rs index af0b225..d249a61 100644 --- a/crates/plugin/src/plugin.rs +++ b/crates/plugin/src/plugin.rs @@ -1,7 +1,7 @@ use crate::interner::StorageContext; use crate::model::{BuildParseResult, GlobalParseResult}; use crate::naming::NamingConvention; -use crate::resolver::{BuildResolver, LangResolver, LspParser, SemanticResolver}; +use crate::resolver::{BuildResolver, LangResolver, LspService, SemanticResolver}; use naviscope_api::models::graph::{DisplayGraphNode, GraphNode, NodeMetadata}; use naviscope_api::models::symbol::FqnReader; use std::path::Path; @@ -83,7 +83,7 @@ pub trait LanguagePlugin: PluginInstance + Send + Sync { fn lang_resolver(&self) -> Arc; /// Get the LSP parser for this language - fn lsp_parser(&self) -> Arc; + fn lsp_parser(&self) -> Arc; /// Get the external resolver for classpath resolution (Phase 2+) fn external_resolver(&self) -> Option> { diff --git a/crates/plugin/src/resolver.rs b/crates/plugin/src/resolver.rs index 1720921..cef1b33 100644 --- a/crates/plugin/src/resolver.rs +++ b/crates/plugin/src/resolver.rs @@ -42,7 +42,7 @@ pub trait SemanticResolver: Send + Sync { ) -> Vec; } -pub trait LspParser: Send + Sync { +pub trait LspService: Send + Sync { fn parse(&self, source: &str, old_tree: Option<&Tree>) -> Option; fn extract_symbols(&self, tree: &Tree, source: &str) -> Vec; @@ -64,7 +64,6 @@ pub trait LspParser: Send + Sync { pub struct ProjectContext { /// Mapping from path prefixes to module IDs (e.g., "/project/app" -> "module::app") pub path_to_module: HashMap, - } impl ProjectContext { @@ -106,24 +105,6 @@ pub trait LangResolver: Send + Sync { ) -> Result>; } -/// A generic trait for semantic scopes in any programming language. -/// `C` represents the language-specific resolution context. -pub trait SemanticScope: Send + Sync { - /// Resolve a name within this specific scope. - /// Returns: - /// - `Some(Ok(res))` if the symbol is found. - /// - `Some(Err(()))` if the symbol is NOT found and searching should stop (shadowing/short-circuit). - /// - `None` if the symbol is NOT found and searching should continue in the next scope. - fn resolve( - &self, - name: &str, - context: &C, - ) -> Option>; - - /// Returns the name of the scope for debugging purposes. - fn name(&self) -> &'static str; -} - pub trait ExternalResolver: Send + Sync { /// 1. Asset Indexing: Pre-scan asset content, returns top-level symbols/packages /// Used by the engine to build the route table. From eba9d87df64a74d602db7e19b83ba10b7cdf70a9 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Sun, 8 Feb 2026 14:32:31 +0800 Subject: [PATCH 10/49] feat: Refactor naming conventions, introduce `StandardNamingConvention`, and enable dynamic registration of naming conventions on `CodeGraph`. --- crates/api/src/models/symbol.rs | 4 +- crates/core/src/features/mod.rs | 2 +- crates/core/src/model/fqn.rs | 147 ++++++++++++++---- crates/core/src/model/graph.rs | 12 +- crates/core/tests/semantic_traits.rs | 2 +- crates/lang-gradle/src/lib.rs | 4 +- .../lang-java/src/inference/adapters/graph.rs | 7 +- crates/lang-java/src/lib.rs | 4 +- crates/lang-java/src/naming.rs | 144 ++--------------- crates/lang-java/src/resolver/mod.rs | 4 +- crates/lang-java/src/resolver/semantic.rs | 11 +- crates/lang-java/tests/capability_boundary.rs | 4 +- crates/lang-java/tests/common/mod.rs | 2 +- crates/lang-java/tests/edge_verification.rs | 6 +- crates/lang-java/tests/java_integration.rs | 4 +- crates/lang-java/tests/logic_goto_def.rs | 8 +- crates/lang-java/tests/logic_hierarchy.rs | 5 +- crates/lang-java/tests/test_engine_facade.rs | 7 + crates/plugin/src/lib.rs | 2 +- crates/plugin/src/naming.rs | 112 +++++++++++-- 20 files changed, 283 insertions(+), 208 deletions(-) diff --git a/crates/api/src/models/symbol.rs b/crates/api/src/models/symbol.rs index a922195..10b239f 100644 --- a/crates/api/src/models/symbol.rs +++ b/crates/api/src/models/symbol.rs @@ -4,7 +4,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::path::Path; use std::sync::Arc; -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct Symbol(pub lasso::Spur); impl JsonSchema for Symbol { @@ -72,7 +72,7 @@ impl NodeId { pub type SymbolAtom = Symbol; -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct FqnId(pub u32); impl JsonSchema for FqnId { diff --git a/crates/core/src/features/mod.rs b/crates/core/src/features/mod.rs index bffb7fc..889cf45 100644 --- a/crates/core/src/features/mod.rs +++ b/crates/core/src/features/mod.rs @@ -37,7 +37,7 @@ pub trait CodeGraphLike: Send + Sync { nc.render_fqn(node.id, self.fqns()) } else { // Fallback to default dot convention - naviscope_plugin::DotPathConvention.render_fqn(node.id, self.fqns()) + naviscope_plugin::StandardNamingConvention.render_fqn(node.id, self.fqns()) } } diff --git a/crates/core/src/model/fqn.rs b/crates/core/src/model/fqn.rs index 6dbd935..de0e1a5 100644 --- a/crates/core/src/model/fqn.rs +++ b/crates/core/src/model/fqn.rs @@ -14,6 +14,35 @@ pub struct FqnManager { pub nodes: Arc>, pub lookup: Arc, Symbol, NodeKind), FqnId>>, pub next_id: Arc, + /// Registry of naming conventions for polyglot resolution + pub registry: Arc>, +} + +/// Registry to hold multiple naming conventions (e.g., Standard, C++, Rust). +/// The query engine will try them in order. +#[derive(Debug)] +pub struct NamingRegistry { + conventions: Vec>, +} + +impl Default for NamingRegistry { + fn default() -> Self { + Self { + conventions: vec![Box::new( + naviscope_plugin::StandardNamingConvention::default(), + )], + } + } +} + +impl NamingRegistry { + pub fn register(&mut self, convention: Box) { + self.conventions.push(convention); + } + + pub fn iter(&self) -> std::slice::Iter<'_, Box> { + self.conventions.iter() + } } impl Default for FqnManager { @@ -29,6 +58,7 @@ impl FqnManager { nodes: Arc::new(DashMap::new()), lookup: Arc::new(DashMap::new()), next_id: Arc::new(std::sync::atomic::AtomicU32::new(1)), + registry: Arc::new(std::sync::RwLock::new(NamingRegistry::default())), } } @@ -38,6 +68,7 @@ impl FqnManager { nodes: Arc::new(DashMap::new()), lookup: Arc::new(DashMap::new()), next_id: Arc::new(std::sync::atomic::AtomicU32::new(1)), + registry: Arc::new(std::sync::RwLock::new(NamingRegistry::default())), } } @@ -45,6 +76,13 @@ impl FqnManager { self.nodes.get(&id).map(|n| n.clone()) } + /// Register a new naming convention logic for query resolution + pub fn register_convention(&self, convention: Box) { + if let Ok(mut reg) = self.registry.write() { + reg.register(convention); + } + } + /// Try to find a child node with the given name under the given parent. /// Since we don't know the Kind, we have to try potential Kinds. pub fn find_child(&self, parent: Option, name: &str) -> Vec { @@ -79,44 +117,98 @@ impl FqnManager { results } + /// Try to resolve a structured path to a single FqnId. + /// This follows the exact path structure without guessing kinds. + pub fn resolve_path(&self, path: &[(NodeKind, String)]) -> Option { + let mut current = None; + for (kind, name) in path { + let symbol = if let Some(s) = self.rodeo.get(name) { + Symbol(s) + } else { + return None; + }; + let key = (current, symbol, kind.clone()); + match self.lookup.get(&key) { + Some(id) => current = Some(*id), + None => return None, + } + } + current + } + + /// Resolve a dot/colon separated string to potential FqnIds. + /// Uses StandardNamingConvention for parsing, and performs intelligent lookup + /// based on the parsed NodeKinds. /// Resolve a dot/colon separated string to potential FqnIds. - /// This is an expensive operation as it involves walking the path and guessing kinds. + /// Uses all registered NamingConventions to parse and lookup path logic. pub fn resolve_fqn_string(&self, fqn: &str) -> Vec { - // 1. Split the string into segments - // We handle "." and "#" and "$" as separators. - let segments: Vec<&str> = fqn - .split(|c| c == '.' || c == '#' || c == '$') - .filter(|s| !s.is_empty()) - .collect(); + let registry = self.registry.read().unwrap(); - if segments.is_empty() { - return Vec::new(); - } + let mut all_results = Vec::new(); - // 2. Simple Tree Search - // We maintain a list of valid current_ids. Initially [None] (root). - let mut current_ids: Vec> = vec![None]; - - for (_i, segment) in segments.iter().enumerate() { - let mut next_ids = Vec::new(); + // 1. Iterate over ALL registered conventions. + // Different languages might parse the same string differently (or successfully/unsuccessfully) + for convention in registry.iter() { + let path = convention.parse_fqn(fqn, None); + if path.is_empty() { + continue; + } - for parent in current_ids { - // Try to find children matching this segment - let children = self.find_child(parent, segment); - for child_id in children { - next_ids.push(Some(child_id)); + // Tree Search with Convention-guided constraints + let mut current_ids: Vec> = vec![None]; + + for (kind, name) in path { + let mut next_ids = Vec::new(); + + // IMPROVEMENT: Use the parsed kind to optimize lookup + // If it is strictly a Member (Method/Field/Ctor), we trust the parser's judgment (due to '#'). + let is_strict_member = matches!( + kind, + NodeKind::Method | NodeKind::Field | NodeKind::Constructor + ); + + for parent in current_ids { + if is_strict_member { + // Semantic Lookup: We know it's a member, but parsing heuristics (e.g. defaulting to Method) + // might mismatch the actual graph node type (e.g. Field). + // So we try all member-like kinds. + if let Some(symbol) = self.rodeo.get(&name) { + let sym = Symbol(symbol); + + let member_kinds = + [NodeKind::Method, NodeKind::Field, NodeKind::Constructor]; + + for member_kind in member_kinds { + let key = (parent, sym, member_kind); + if let Some(id) = self.lookup.get(&key) { + next_ids.push(Some(*id)); + } + } + } + } else { + // Ambiguous/Fuzzy lookup + let children = self.find_child(parent, &name); + for child_id in children { + next_ids.push(Some(child_id)); + } + } } - } - if next_ids.is_empty() { - // Heuristic: If we fail to match a segment in the middle... - return Vec::new(); + if next_ids.is_empty() { + current_ids = Vec::new(); + break; + } + current_ids = next_ids; } - current_ids = next_ids; + all_results.extend(current_ids.into_iter().flatten()); } - current_ids.into_iter().flatten().collect() + // Deduplicate results if multiple conventions yield the same ID + all_results.sort(); + all_results.dedup(); + + all_results } } @@ -234,6 +326,7 @@ impl<'de> Deserialize<'de> for FqnManager { nodes: Arc::new(nodes_map), lookup: Arc::new(lookup_map), next_id: Arc::new(std::sync::atomic::AtomicU32::new(raw.next_id)), + registry: Arc::new(std::sync::RwLock::new(NamingRegistry::default())), // Re-init with defaults }) } } diff --git a/crates/core/src/model/graph.rs b/crates/core/src/model/graph.rs index c759875..ed48728 100644 --- a/crates/core/src/model/graph.rs +++ b/crates/core/src/model/graph.rs @@ -59,7 +59,6 @@ pub struct CodeGraphInner { /// Reference Index: Token (e.g. Method Name) -> Files that contain this token. /// Used for fast "scouting" during reference discovery. pub reference_index: HashMap>, - } /// Metadata and nodes associated with a single source file @@ -104,6 +103,16 @@ impl CodeGraph { CodeGraphBuilder::from_inner((*self.inner).clone()) } + /// Register a new naming convention for this graph instance. + /// This allows plugins to provide language-specific FQN parsing logic. + /// Note: This affects global query behavior for this graph instance. + pub fn register_naming_convention( + &self, + convention: Box, + ) { + self.inner.fqns.register_convention(convention); + } + // ---- Read-only accessors ---- /// Get the unique instance ID for this graph version @@ -467,5 +476,4 @@ mod tests { assert_eq!(recovered_node.name(symbols), "node"); assert_eq!(recovered_node.language(symbols).as_str(), "java"); } - } diff --git a/crates/core/tests/semantic_traits.rs b/crates/core/tests/semantic_traits.rs index f8fdc89..6eb7bdf 100644 --- a/crates/core/tests/semantic_traits.rs +++ b/crates/core/tests/semantic_traits.rs @@ -55,7 +55,7 @@ impl NodeAdapter for MockPlugin { node: &naviscope_api::models::graph::GraphNode, rodeo: &dyn naviscope_api::models::symbol::FqnReader, ) -> DisplayGraphNode { - let display_id = naviscope_plugin::DotPathConvention.render_fqn(node.id, rodeo); + let display_id = naviscope_plugin::StandardNamingConvention.render_fqn(node.id, rodeo); let mut display = DisplayGraphNode { id: display_id, name: rodeo.resolve_atom(node.name).to_string(), diff --git a/crates/lang-gradle/src/lib.rs b/crates/lang-gradle/src/lib.rs index adf1f3d..27120b6 100644 --- a/crates/lang-gradle/src/lib.rs +++ b/crates/lang-gradle/src/lib.rs @@ -10,7 +10,7 @@ use naviscope_api::models::BuildTool; use naviscope_api::models::graph::DisplayGraphNode; use naviscope_api::models::symbol::FqnReader; use naviscope_plugin::{ - BuildContent, BuildParseResult, BuildToolPlugin, DotPathConvention, NamingConvention, + BuildContent, BuildParseResult, BuildToolPlugin, StandardNamingConvention, NamingConvention, NodeAdapter, PluginInstance, StorageContext, }; use std::sync::Arc; @@ -25,7 +25,7 @@ impl NodeAdapter for GradlePlugin { node: &naviscope_api::models::graph::GraphNode, fqns: &dyn FqnReader, ) -> DisplayGraphNode { - let display_id = DotPathConvention.render_fqn(node.id, fqns); + let display_id = StandardNamingConvention.render_fqn(node.id, fqns); let mut display = DisplayGraphNode { id: display_id, name: fqns.resolve_atom(node.name).to_string(), diff --git a/crates/lang-java/src/inference/adapters/graph.rs b/crates/lang-java/src/inference/adapters/graph.rs index d9fe2c4..dcff6db 100644 --- a/crates/lang-java/src/inference/adapters/graph.rs +++ b/crates/lang-java/src/inference/adapters/graph.rs @@ -75,7 +75,7 @@ impl<'a> CodeGraphTypeSystem<'a> { use naviscope_plugin::NamingConvention; // Use Java naming convention to render FQN - JavaNamingConvention.render_fqn(node_id, self.graph.fqns()) + JavaNamingConvention::default().render_fqn(node_id, self.graph.fqns()) } /// Extract parameters from metadata. @@ -254,8 +254,7 @@ impl<'a> InheritanceProvider for CodeGraphTypeSystem<'a> { impl<'a> MemberProvider for CodeGraphTypeSystem<'a> { fn get_members(&self, type_fqn: &str, member_name: &str) -> Vec { // Use unified member FQN format - let member_fqn = - crate::naming::JavaNamingConvention::build_member_fqn(type_fqn, member_name); + let member_fqn = crate::naming::build_member_fqn(type_fqn, member_name); let node_ids = self.graph.resolve_fqn(&member_fqn); let mut members = Vec::new(); @@ -307,7 +306,7 @@ impl<'a> MemberProvider for CodeGraphTypeSystem<'a> { let child_fqn = self.render_fqn_id(child_id); // Extract member name using unified convention // Members always use '#' separator, so this should always succeed - let name = crate::naming::JavaNamingConvention::extract_member_name(&child_fqn) + let name = crate::naming::extract_member_name(&child_fqn) .unwrap_or_else(|| self.graph.fqns().resolve_atom(node.name)) .to_string(); diff --git a/crates/lang-java/src/lib.rs b/crates/lang-java/src/lib.rs index 7449032..3d8c82c 100644 --- a/crates/lang-java/src/lib.rs +++ b/crates/lang-java/src/lib.rs @@ -30,7 +30,7 @@ pub struct JavaPlugin { impl NodeAdapter for JavaPlugin { fn render_display_node(&self, node: &GraphNode, fqns: &dyn FqnReader) -> DisplayGraphNode { let mut display = DisplayGraphNode { - id: crate::naming::JavaNamingConvention.render_fqn(node.id, fqns), + id: crate::naming::JavaNamingConvention::default().render_fqn(node.id, fqns), name: fqns.resolve_atom(node.name).to_string(), kind: node.kind.clone(), lang: "java".to_string(), @@ -256,7 +256,7 @@ impl JavaPlugin { impl PluginInstance for JavaPlugin { fn get_naming_convention(&self) -> Option> { - Some(Arc::new(crate::naming::JavaNamingConvention)) + Some(Arc::new(crate::naming::JavaNamingConvention::default())) } fn get_node_adapter(&self) -> Option> { diff --git a/crates/lang-java/src/naming.rs b/crates/lang-java/src/naming.rs index 5ac8989..e4dd895 100644 --- a/crates/lang-java/src/naming.rs +++ b/crates/lang-java/src/naming.rs @@ -1,134 +1,10 @@ -use naviscope_api::models::NodeKind; -use naviscope_plugin::NamingConvention; - -/// Separator used between a type and its members (methods, fields, constructors). -pub const MEMBER_SEPARATOR: char = '#'; - -/// Separator used between packages and between package/class. -pub const TYPE_SEPARATOR: char = '.'; - -#[derive(Debug, Clone, Copy, Default)] -pub struct JavaNamingConvention; - -impl JavaNamingConvention { - /// Build a fully qualified name for a member (method, field, or constructor). - /// - /// # Examples - /// ```ignore - /// build_member_fqn("com.example.MyClass", "myMethod") => "com.example.MyClass#myMethod" - /// build_member_fqn("com.example.MyClass", "myField") => "com.example.MyClass#myField" - /// ``` - pub fn build_member_fqn(type_fqn: &str, member_name: &str) -> String { - format!("{}{}{}", type_fqn, MEMBER_SEPARATOR, member_name) - } - - /// Parse a member FQN into (type_fqn, member_name). - /// - /// Returns `None` if the FQN does not contain a member separator. - /// - /// # Examples - /// ```ignore - /// parse_member_fqn("com.example.MyClass#myMethod") => Some(("com.example.MyClass", "myMethod")) - /// parse_member_fqn("com.example.MyClass") => None - /// ``` - pub fn parse_member_fqn(fqn: &str) -> Option<(&str, &str)> { - fqn.rfind(MEMBER_SEPARATOR) - .map(|pos| (&fqn[..pos], &fqn[pos + 1..])) - } - - /// Check if an FQN represents a member (method, field, constructor). - pub fn is_member_fqn(fqn: &str) -> bool { - fqn.contains(MEMBER_SEPARATOR) - } - - /// Extract the type FQN from a member FQN. - /// - /// If the FQN is already a type FQN (no member separator), returns the original. - pub fn extract_type_fqn(fqn: &str) -> &str { - Self::parse_member_fqn(fqn) - .map(|(type_fqn, _)| type_fqn) - .unwrap_or(fqn) - } - - /// Extract the member name from a member FQN. - /// - /// Returns `None` if the FQN is not a member FQN. - pub fn extract_member_name(fqn: &str) -> Option<&str> { - Self::parse_member_fqn(fqn).map(|(_, member)| member) - } - - /// Get the appropriate separator for the given intent. - /// - /// Members (Method, Field) use `#`, others use `.`. - /// Note: Constructor is represented by SymbolIntent::Method. - pub fn separator_for_intent(intent: naviscope_api::models::SymbolIntent) -> char { - use naviscope_api::models::SymbolIntent; - match intent { - SymbolIntent::Method | SymbolIntent::Field => MEMBER_SEPARATOR, - _ => TYPE_SEPARATOR, - } - } -} - -impl NamingConvention for JavaNamingConvention { - fn separator(&self) -> &str { - "." - } - - fn get_separator(&self, parent: NodeKind, child: NodeKind) -> &str { - match (parent, child) { - ( - NodeKind::Class | NodeKind::Interface | NodeKind::Enum | NodeKind::Annotation, - NodeKind::Method | NodeKind::Field | NodeKind::Constructor, - ) => "#", - _ => ".", - } - } - - fn parse_fqn( - &self, - fqn: &str, - heuristic_leaf_kind: Option, - ) -> Vec<(NodeKind, String)> { - // Java Logic: - // 1. Split by '.' (Package/Class separator) - // 2. But wait! Inner classes might use '$' in bytecode but '.' in source FQN. - // And methods use '#' in our graph convention (from JavaParser::get_node_id_for_definition?) - // The string coming in here is likely a "Source FQN" (dot separated). - - let mut result = Vec::new(); - - // Handle '#' for methods/fields if present (e.g. from existing graph ID) - let (path_part, member_part) = if let Some(hash_pos) = fqn.find('#') { - (&fqn[..hash_pos], Some(&fqn[hash_pos + 1..])) - } else { - (fqn, None) - }; - - // Split the path part (Packages/Classes) - let parts: Vec<&str> = path_part.split(|c| c == '.' || c == '$').collect(); - for part in parts.iter() { - if part.is_empty() { - continue; - } - - // Heuristic: Uppercase = Class, Lowercase = Package - // This is not perfect but standard Java convention. - let is_uppercase = part.chars().next().map_or(false, |c| c.is_uppercase()); - let kind = if is_uppercase { - NodeKind::Class - } else { - NodeKind::Package - }; - result.push((kind, part.to_string())); - } - - // Handle member part - if let Some(member) = member_part { - let kind = heuristic_leaf_kind.unwrap_or(NodeKind::Method); - result.push((kind, member.to_string())); - } - - result - } -} +// Re-export standard naming utilities from plugin +pub use naviscope_plugin::naming::{ + MEMBER_SEPARATOR, TYPE_SEPARATOR, build_member_fqn, extract_member_name, extract_type_fqn, + is_member_fqn, parse_member_fqn, +}; + +/// Java uses standard dot-separated paths for types and packages, +/// and standard hash-separated paths for members in our graph. +/// Thus we can alias directly to StandardNamingConvention. +pub use naviscope_plugin::StandardNamingConvention as JavaNamingConvention; diff --git a/crates/lang-java/src/resolver/mod.rs b/crates/lang-java/src/resolver/mod.rs index d7b83cf..3864289 100644 --- a/crates/lang-java/src/resolver/mod.rs +++ b/crates/lang-java/src/resolver/mod.rs @@ -84,7 +84,7 @@ impl JavaResolver { "method_declaration" | "constructor_declaration" => { // Build method FQN using canonical member separator if let Some(ref enclosing) = infer_ctx.enclosing_class { - let method_fqn = crate::naming::JavaNamingConvention::build_member_fqn( + let method_fqn = crate::naming::build_member_fqn( enclosing, &context.name, ); @@ -112,7 +112,7 @@ impl JavaResolver { // Build field FQN using canonical member separator if let Some(ref enclosing) = infer_ctx.enclosing_class { let field_fqn = - crate::naming::JavaNamingConvention::build_member_fqn( + crate::naming::build_member_fqn( enclosing, &context.name, ); diff --git a/crates/lang-java/src/resolver/semantic.rs b/crates/lang-java/src/resolver/semantic.rs index c937174..24562b1 100644 --- a/crates/lang-java/src/resolver/semantic.rs +++ b/crates/lang-java/src/resolver/semantic.rs @@ -63,9 +63,7 @@ impl SemanticResolver for JavaResolver { SymbolResolution::Precise(fqn, intent) => { // If it's a member (Field/Method), find its type via MemberProvider // Use unified member FQN parsing - if let Some((type_fqn, member_name)) = - crate::naming::JavaNamingConvention::parse_member_fqn(fqn) - { + if let Some((type_fqn, member_name)) = crate::naming::parse_member_fqn(fqn) { if let Some(member) = ts.get_members(type_fqn, member_name).first() { match &member.type_ref { TypeRef::Raw(s) => type_resolutions @@ -135,8 +133,8 @@ impl SemanticResolver for JavaResolver { for parent_id in parents { // 2. Find all implementations of this parent use naviscope_plugin::NamingConvention; - let parent_fqn = - crate::naming::JavaNamingConvention.render_fqn(parent_id, index.fqns()); + let parent_fqn = crate::naming::JavaNamingConvention::default() + .render_fqn(parent_id, index.fqns()); // 3. Walk all descendants of the parent class for desc_fqn in ts.walk_descendants(&parent_fqn) { @@ -151,7 +149,8 @@ impl SemanticResolver for JavaResolver { } // For classes/interfaces, get all descendants - let fqn = crate::naming::JavaNamingConvention.render_fqn(node_id, index.fqns()); + let fqn = + crate::naming::JavaNamingConvention::default().render_fqn(node_id, index.fqns()); for desc_fqn in ts.walk_descendants(&fqn) { results.extend(index.resolve_fqn(&desc_fqn)); } diff --git a/crates/lang-java/tests/capability_boundary.rs b/crates/lang-java/tests/capability_boundary.rs index e118ecc..913607a 100644 --- a/crates/lang-java/tests/capability_boundary.rs +++ b/crates/lang-java/tests/capability_boundary.rs @@ -22,7 +22,7 @@ fn cap_structural_nesting() { use naviscope_plugin::NamingConvention; println!( " - {:?}", - naviscope_plugin::DotPathConvention.render_fqn(node.id, index.fqns()) + naviscope_plugin::StandardNamingConvention.render_fqn(node.id, index.fqns()) ); } @@ -71,7 +71,7 @@ fn cap_inheritance_tracking() { println!( " -> {:?} connection {:?} (Target ID: {:?})", edge.edge_type, - naviscope_plugin::DotPathConvention.render_fqn(target.id, index.fqns()), + naviscope_plugin::StandardNamingConvention.render_fqn(target.id, index.fqns()), target.id ); } diff --git a/crates/lang-java/tests/common/mod.rs b/crates/lang-java/tests/common/mod.rs index 76dc3c5..794497d 100644 --- a/crates/lang-java/tests/common/mod.rs +++ b/crates/lang-java/tests/common/mod.rs @@ -19,7 +19,7 @@ pub fn setup_java_test_graph( let mut builder = CodeGraphBuilder::new(); builder.naming_conventions.insert( Language::JAVA, - Arc::new(naviscope_java::naming::JavaNamingConvention), + Arc::new(naviscope_java::naming::JavaNamingConvention::default()), ); let mut parsed_files = Vec::new(); let java_parser = JavaParser::new().unwrap(); diff --git a/crates/lang-java/tests/edge_verification.rs b/crates/lang-java/tests/edge_verification.rs index 5bb2f06..c0ad11b 100644 --- a/crates/lang-java/tests/edge_verification.rs +++ b/crates/lang-java/tests/edge_verification.rs @@ -15,7 +15,7 @@ fn assert_edge(graph: &CodeGraph, from_fqn: &str, to_fqn: &str, expected_type: E use naviscope_plugin::NamingConvention; println!( " - {}", - naviscope_plugin::DotPathConvention.render_fqn(*id, graph.fqns()) + naviscope_plugin::StandardNamingConvention.render_fqn(*id, graph.fqns()) ); } panic!("Source node not found: {}", from_fqn); @@ -26,7 +26,7 @@ fn assert_edge(graph: &CodeGraph, from_fqn: &str, to_fqn: &str, expected_type: E use naviscope_plugin::NamingConvention; println!( " - {}", - naviscope_plugin::DotPathConvention.render_fqn(*id, graph.fqns()) + naviscope_plugin::StandardNamingConvention.render_fqn(*id, graph.fqns()) ); } panic!("Target node not found: {}", to_fqn); @@ -42,7 +42,7 @@ fn assert_edge(graph: &CodeGraph, from_fqn: &str, to_fqn: &str, expected_type: E use naviscope_plugin::NamingConvention; println!( " - {}", - naviscope_plugin::DotPathConvention.render_fqn(*id, graph.fqns()) + naviscope_plugin::StandardNamingConvention.render_fqn(*id, graph.fqns()) ); } println!("Edges from {}:", from_fqn); diff --git a/crates/lang-java/tests/java_integration.rs b/crates/lang-java/tests/java_integration.rs index 9724b9e..bebcf36 100644 --- a/crates/lang-java/tests/java_integration.rs +++ b/crates/lang-java/tests/java_integration.rs @@ -92,7 +92,7 @@ fn test_inheritance_and_implementations() { assert_eq!( { use naviscope_plugin::NamingConvention; - naviscope_plugin::DotPathConvention.render_fqn(node.id, index.fqns()) + naviscope_plugin::StandardNamingConvention.render_fqn(node.id, index.fqns()) }, "C" ); @@ -428,7 +428,7 @@ public class DefaultApplicationArguments { use naviscope_plugin::NamingConvention; println!( " - {} ({:?})", - naviscope_plugin::DotPathConvention.render_fqn(*fqn, index.fqns()), + naviscope_plugin::StandardNamingConvention.render_fqn(*fqn, index.fqns()), node.kind() ); } diff --git a/crates/lang-java/tests/logic_goto_def.rs b/crates/lang-java/tests/logic_goto_def.rs index 094de87..b1c2efe 100644 --- a/crates/lang-java/tests/logic_goto_def.rs +++ b/crates/lang-java/tests/logic_goto_def.rs @@ -65,7 +65,7 @@ fn test_goto_definition_cross_file() { assert_eq!( index.render_fqn( &index.topology()[idx], - Some(&naviscope_java::naming::JavaNamingConvention) + Some(&naviscope_java::naming::JavaNamingConvention::default()) ), "com.A" ); @@ -82,7 +82,7 @@ fn test_goto_definition_cross_file() { assert_eq!( index.render_fqn( &index.topology()[idx], - Some(&naviscope_java::naming::JavaNamingConvention) + Some(&naviscope_java::naming::JavaNamingConvention::default()) ), "com.A#hello" ); @@ -143,7 +143,7 @@ fn test_goto_definition_constructor() { index .render_fqn( &index.topology()[idx], - Some(&naviscope_java::naming::JavaNamingConvention) + Some(&naviscope_java::naming::JavaNamingConvention::default()) ) .contains("A") ); @@ -174,7 +174,7 @@ fn test_goto_definition_static() { assert_eq!( index.render_fqn( &index.topology()[idx], - Some(&naviscope_java::naming::JavaNamingConvention) + Some(&naviscope_java::naming::JavaNamingConvention::default()) ), "A#VAL" ); diff --git a/crates/lang-java/tests/logic_hierarchy.rs b/crates/lang-java/tests/logic_hierarchy.rs index 2f87652..505c63f 100644 --- a/crates/lang-java/tests/logic_hierarchy.rs +++ b/crates/lang-java/tests/logic_hierarchy.rs @@ -192,7 +192,10 @@ fn test_call_hierarchy_recursion() { } let node = &index.topology()[c_idx]; let fqn = index - .render_fqn(node, Some(&naviscope_java::naming::JavaNamingConvention)) + .render_fqn( + node, + Some(&naviscope_java::naming::JavaNamingConvention::default()), + ) .to_string(); if !callers.contains(&fqn) { callers.push(fqn); diff --git a/crates/lang-java/tests/test_engine_facade.rs b/crates/lang-java/tests/test_engine_facade.rs index 895cf2d..2885821 100644 --- a/crates/lang-java/tests/test_engine_facade.rs +++ b/crates/lang-java/tests/test_engine_facade.rs @@ -37,6 +37,13 @@ public class App { let handle = setup_java_engine(&temp_dir, files).await; let graph = handle.graph().await; + + // Demonstrate registering a convention (even if default is already Standard) + // This verifies the API is accessible and working. + graph.register_naming_convention(Box::new( + naviscope_plugin::StandardNamingConvention::default(), + )); + graph.topology(); // Ensure graph is usable // 1. Resolve 'run' call in App.java (line 6 roughly) diff --git a/crates/plugin/src/lib.rs b/crates/plugin/src/lib.rs index ab4ae51..07758db 100644 --- a/crates/plugin/src/lib.rs +++ b/crates/plugin/src/lib.rs @@ -13,6 +13,6 @@ pub use converter::*; pub use graph::*; pub use interner::*; pub use model::*; -pub use naming::{DotPathConvention, NamingConvention}; +pub use naming::{NamingConvention, StandardNamingConvention}; pub use plugin::*; pub use resolver::*; diff --git a/crates/plugin/src/naming.rs b/crates/plugin/src/naming.rs index 333725d..1e0d6bd 100644 --- a/crates/plugin/src/naming.rs +++ b/crates/plugin/src/naming.rs @@ -2,6 +2,41 @@ use naviscope_api::models::graph::NodeKind; use naviscope_api::models::symbol::{FqnId, FqnReader}; use std::fmt::Debug; +/// Separator used between a type and its members (methods, fields, constructors). +pub const MEMBER_SEPARATOR: char = '#'; + +/// Separator used between packages and between package/class. +pub const TYPE_SEPARATOR: char = '.'; + +/// Build a fully qualified name for a member (method, field, or constructor). +pub fn build_member_fqn(type_fqn: &str, member_name: &str) -> String { + format!("{}{}{}", type_fqn, MEMBER_SEPARATOR, member_name) +} + +/// Parse a member FQN into (type_fqn, member_name). +/// Returns `None` if the FQN does not contain a member separator. +pub fn parse_member_fqn(fqn: &str) -> Option<(&str, &str)> { + fqn.rfind(MEMBER_SEPARATOR) + .map(|pos| (&fqn[..pos], &fqn[pos + 1..])) +} + +/// Check if an FQN represents a member (method, field, constructor). +pub fn is_member_fqn(fqn: &str) -> bool { + fqn.contains(MEMBER_SEPARATOR) +} + +/// Extract the type FQN from a member FQN. +pub fn extract_type_fqn(fqn: &str) -> &str { + parse_member_fqn(fqn) + .map(|(type_fqn, _)| type_fqn) + .unwrap_or(fqn) +} + +/// Extract the member name from a member FQN. +pub fn extract_member_name(fqn: &str) -> Option<&str> { + parse_member_fqn(fqn).map(|(_, member)| member) +} + /// Defines language-specific naming rules for Fully Qualified Names (FQNs). /// This trait allows the core system to parse flat strings into structured paths /// based on language semantics (separators, nesting rules, etc.). @@ -56,36 +91,91 @@ pub trait NamingConvention: Send + Sync + Debug { } result } + + /// Get the member separator character (default '#'). + fn member_separator(&self) -> char { + MEMBER_SEPARATOR + } + + /// Build a fully qualified name for a member using this convention. + fn build_member_fqn(&self, type_fqn: &str, member_name: &str) -> String { + build_member_fqn(type_fqn, member_name) + } + + /// Parse a member FQN into (type_fqn, member_name) using this convention. + fn parse_member_fqn<'a>(&self, fqn: &'a str) -> Option<(&'a str, &'a str)> { + parse_member_fqn(fqn) + } + + /// Check if an FQN represents a member using this convention. + fn is_member_fqn(&self, fqn: &str) -> bool { + is_member_fqn(fqn) + } } -/// A default "Dot" convention (e.g. for Java/Python-ish languages). -/// It assumes "Package" -> "Class" -> "Leaf". -#[derive(Debug, Default)] -pub struct DotPathConvention; +/// A standard naming convention suitable for most polyglot scenarios. +/// It uses `.` for hierarchy/types and `#` for members. +/// This implementation unifies behavior across languages unless specific overrides are needed. +#[derive(Debug, Default, Clone, Copy)] +pub struct StandardNamingConvention; -impl NamingConvention for DotPathConvention { +impl NamingConvention for StandardNamingConvention { fn separator(&self) -> &str { "." } + fn get_separator(&self, parent: NodeKind, child: NodeKind) -> &str { + match (parent, child) { + ( + NodeKind::Class | NodeKind::Interface | NodeKind::Enum | NodeKind::Annotation, + NodeKind::Method | NodeKind::Field | NodeKind::Constructor, + ) => "#", + _ => ".", + } + } + fn parse_fqn( &self, fqn: &str, heuristic_leaf_kind: Option, ) -> Vec<(NodeKind, String)> { - // Simple default splitting - let parts: Vec<&str> = fqn.split('.').collect(); - let mut result = Vec::with_capacity(parts.len()); + // 1. Check for standard member separator first + let (type_part, member_part) = parse_member_fqn(fqn) + .map(|(t, m)| (t, Some(m))) + .unwrap_or((fqn, None)); + + // 2. Split the type part by '.' + let parts: Vec<&str> = type_part.split(TYPE_SEPARATOR).collect(); + let mut result = + Vec::with_capacity(parts.len() + if member_part.is_some() { 1 } else { 0 }); for (i, part) in parts.iter().enumerate() { - let is_last = i == parts.len() - 1; - let kind = if is_last { - heuristic_leaf_kind.clone().unwrap_or(NodeKind::Class) + if part.is_empty() { + continue; + } + // If we have a member part, then the last part of type_path is likely a Class/Type. + // If not, we use the heuristic or default to Class. + let is_last_type_part = i == parts.len() - 1; + let kind = if is_last_type_part { + if member_part.is_some() { + NodeKind::Class + } else { + heuristic_leaf_kind.clone().unwrap_or(NodeKind::Class) + } } else { NodeKind::Package }; result.push((kind, part.to_string())); } + + // 3. Add member part if present + if let Some(member) = member_part { + // If parsed as member, use Method as default, or heuristic if provided + // Note: Heuristic for member is usually Method, but could be Field. + let kind = heuristic_leaf_kind.unwrap_or(NodeKind::Method); + result.push((kind, member.to_string())); + } + result } } From e1d2dec4362e403ae11e0e1ce257802d6dd97521 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Sun, 8 Feb 2026 15:00:33 +0800 Subject: [PATCH 11/49] feat: Introduce and integrate a language-agnostic `TypeSystem` trait into the plugin architecture and core discovery features, and rename `lsp_parser` to `lsp_service`. --- crates/core/src/facade/mod.rs | 15 +++- crates/core/src/facade/semantic.rs | 88 +++++++++++-------- crates/core/src/features/discovery.rs | 24 ++--- crates/core/src/ingest/resolver/engine.rs | 12 ++- crates/core/tests/semantic_traits.rs | 5 +- crates/lang-java/src/lib.rs | 14 ++- crates/lang-java/src/lsp/mod.rs | 4 + crates/lang-java/src/lsp/type_system.rs | 60 +++++++++++++ crates/lang-java/src/resolver/mod.rs | 11 +-- crates/lang-java/tests/logic_hierarchy.rs | 6 +- crates/lang-java/tests/test_engine_facade.rs | 10 ++- crates/plugin/src/lib.rs | 2 + crates/plugin/src/plugin.rs | 5 +- crates/plugin/src/type_system.rs | 92 ++++++++++++++++++++ 14 files changed, 274 insertions(+), 74 deletions(-) create mode 100644 crates/lang-java/src/lsp/type_system.rs create mode 100644 crates/plugin/src/type_system.rs diff --git a/crates/core/src/facade/mod.rs b/crates/core/src/facade/mod.rs index 9b90419..ca7f57d 100644 --- a/crates/core/src/facade/mod.rs +++ b/crates/core/src/facade/mod.rs @@ -57,6 +57,13 @@ impl EngineHandle { self.engine.get_resolver().get_semantic_resolver(language) } + pub fn get_type_system( + &self, + language: crate::model::source::Language, + ) -> Option> { + self.engine.get_resolver().get_type_system(language) + } + pub fn get_node_adapter( &self, language: crate::model::source::Language, @@ -68,17 +75,21 @@ impl EngineHandle { self.engine.get_resolver().get_language_by_extension(ext) } - pub fn get_lsp_service_and_lang_for_path( + pub fn get_services_for_path( &self, path: &std::path::Path, ) -> Option<( Arc, + Arc, + Arc, crate::model::source::Language, )> { let ext = path.extension()?.to_str()?; let lang = self.get_language_by_extension(ext)?; let lsp_service = self.get_lsp_service(lang.clone())?; - Some((lsp_service, lang)) + let type_system = self.get_type_system(lang.clone())?; + let resolver = self.get_semantic_resolver(lang.clone())?; + Some((lsp_service, type_system, resolver, lang)) } /// Get naming convention for a specific language diff --git a/crates/core/src/facade/semantic.rs b/crates/core/src/facade/semantic.rs index 823ac8a..cc99be4 100644 --- a/crates/core/src/facade/semantic.rs +++ b/crates/core/src/facade/semantic.rs @@ -31,16 +31,11 @@ impl SymbolNavigator for EngineHandle { PathBuf::from(uri_str) }; - let (lsp_service, lang) = match self.get_lsp_service_and_lang_for_path(&path) { + let (lsp_service, _type_system, resolver, _lang) = match self.get_services_for_path(&path) { Some(x) => x, None => return Ok(None), }; - let resolver = match self.get_semantic_resolver(lang.clone()) { - Some(r) => r, - None => return Ok(None), - }; - let content = if let Some(c) = &ctx.content { c.clone() } else { @@ -66,7 +61,7 @@ impl SymbolNavigator for EngineHandle { PathBuf::from(uri_str) }; - let (lsp_service, _) = match self.get_lsp_service_and_lang_for_path(&path) { + let (lsp_service, _type_system, _resolver, _) = match self.get_services_for_path(&path) { Some(x) => x, None => return Ok(vec![]), }; @@ -190,7 +185,13 @@ impl ReferenceAnalyzer for EngineHandle { }; let graph = self.graph().await; - let matches = resolver.find_matches(&graph, &query.resolution); + let mut matches = resolver.find_matches(&graph, &query.resolution); + + // If searching for a method/class, also include implementations as "matches" + // for the purpose of filtering declarations. + let impls = resolver.find_implementations(&graph, &query.resolution); + matches.extend(impls); + let match_indices: Vec<_> = matches .iter() .filter_map(|id| graph.fqn_map().get(id).copied()) @@ -209,16 +210,11 @@ impl ReferenceAnalyzer for EngineHandle { let conventions_clone = conventions.clone(); tasks.spawn(async move { - let (lsp_service, file_lang) = match handle.get_lsp_service_and_lang_for_path(&path) - { - Some(x) => x, - None => return Vec::new(), - }; - - let file_resolver = match handle.get_semantic_resolver(file_lang) { - Some(r) => r, - None => return Vec::new(), - }; + let (lsp_service, type_system, file_resolver, _file_lang) = + match handle.get_services_for_path(&path) { + Some(x) => x, + None => return Vec::new(), + }; let content = match fs::read_to_string(&path) { Ok(c) => c, @@ -235,6 +231,7 @@ impl ReferenceAnalyzer for EngineHandle { let locations = discovery.scan_file( lsp_service.as_ref(), + type_system.as_ref(), file_resolver.as_ref(), &content, &resolution, @@ -309,11 +306,36 @@ impl CallHierarchyAnalyzer for EngineHandle { fqn: &str, ) -> SemanticResult> { let graph = self.graph().await; - let target_indices = graph.find_matches_by_fqn(fqn); + let mut target_indices = graph.find_matches_by_fqn(fqn); + if target_indices.is_empty() { return Ok(vec![]); } + // 2. Identify unique languages from the found nodes to expand potential implementation targets + let mut unique_langs = std::collections::HashSet::new(); + for &idx in &target_indices { + let lang_symbol = graph.topology()[idx].lang.0; + unique_langs.insert(graph.symbols().resolve(&lang_symbol).to_string()); + } + + // 3. For each language, find implementations to avoid them being counted + // as callers when they are actually override sites. + let resolution = SymbolResolution::Global(fqn.to_string()); + for lang_str in unique_langs { + let language = Language::new(lang_str); + if let Some(resolver) = self.get_semantic_resolver(language) { + let impls = resolver.find_implementations(&graph, &resolution); + for impl_id in impls { + if let Some(&node_idx) = graph.fqn_map().get(&impl_id) { + if !target_indices.contains(&node_idx) { + target_indices.push(node_idx); + } + } + } + } + } + // 1. Meso-level scouting for candidate files let conventions = (*self.naming_conventions()).clone(); let discovery = DiscoveryEngine::new(&graph, conventions.clone()); @@ -331,16 +353,11 @@ impl CallHierarchyAnalyzer for EngineHandle { let conventions_clone = conventions.clone(); tasks.spawn(async move { - let (lsp_service, file_lang) = match handle.get_lsp_service_and_lang_for_path(&path) - { - Some(x) => x, - None => return vec![], - }; - - let file_resolver = match handle.get_semantic_resolver(file_lang) { - Some(r) => r, - None => return vec![], - }; + let (lsp_service, type_system, file_resolver, _file_lang) = + match handle.get_services_for_path(&path) { + Some(x) => x, + None => return vec![], + }; let content = match fs::read_to_string(&path) { Ok(c) => c, @@ -357,6 +374,7 @@ impl CallHierarchyAnalyzer for EngineHandle { // Verification discovery.scan_file( lsp_service.as_ref(), + type_system.as_ref(), file_resolver.as_ref(), &content, &res, @@ -446,12 +464,9 @@ impl CallHierarchyAnalyzer for EngineHandle { .range() .ok_or_else(|| SemanticError::Internal("Node has no range".into()))?; - let (lsp_service, lang) = self - .get_lsp_service_and_lang_for_path(&path) - .ok_or_else(|| SemanticError::Internal("No parser for file".into()))?; - let resolver = self - .get_semantic_resolver(lang) - .ok_or_else(|| SemanticError::Internal("No resolver for file".into()))?; + let (lsp_service, _type_system, resolver, _lang) = self + .get_services_for_path(&path) + .ok_or_else(|| SemanticError::Internal("No services for file".into()))?; let content = fs::read_to_string(&path).map_err(|e| SemanticError::Internal(e.to_string()))?; @@ -555,7 +570,8 @@ impl SymbolInfoProvider for EngineHandle { PathBuf::from(uri) }; - let (lsp_service, _lang) = match self.get_lsp_service_and_lang_for_path(&path) { + let (lsp_service, _type_system, _resolver, _lang) = match self.get_services_for_path(&path) + { Some(x) => x, None => return Ok(vec![]), }; diff --git a/crates/core/src/features/discovery.rs b/crates/core/src/features/discovery.rs index 0f278b5..fb391d4 100644 --- a/crates/core/src/features/discovery.rs +++ b/crates/core/src/features/discovery.rs @@ -149,6 +149,7 @@ impl<'a> DiscoveryEngine<'a> { pub fn scan_file( &self, lsp_service: &dyn LspService, + type_system: &dyn naviscope_plugin::TypeSystem, resolver: &dyn crate::ingest::resolver::SemanticResolver, source: &str, target_resolution: &SymbolResolution, @@ -170,23 +171,12 @@ impl<'a> DiscoveryEngine<'a> { range.start_col, self.index.as_plugin_graph(), ) { - // 3. Identity Check (Lenient) - let matched = match (&resolved_at_loc, target_resolution) { - (SymbolResolution::Local(r1, _), SymbolResolution::Local(r2, _)) => { - r1 == r2 - } - _ => { - if let (Some(f1), Some(f2)) = - (resolved_at_loc.fqn(), target_resolution.fqn()) - { - f1 == f2 - } else { - false - } - } - }; - - if matched { + // 3. Identity & Inheritance Check via TypeSystem + if type_system.is_reference_to( + self.index.as_plugin_graph(), + &resolved_at_loc, + target_resolution, + ) { valid_locations.push(Location { uri: uri.clone(), range: lsp_types::Range { diff --git a/crates/core/src/ingest/resolver/engine.rs b/crates/core/src/ingest/resolver/engine.rs index f6e7696..eee3754 100644 --- a/crates/core/src/ingest/resolver/engine.rs +++ b/crates/core/src/ingest/resolver/engine.rs @@ -63,7 +63,17 @@ impl IndexResolver { self.lang_plugins .iter() .find(|p| p.name() == language) - .map(|p| p.lsp_parser()) + .map(|p| p.lsp_service()) + } + + pub fn get_type_system( + &self, + language: Language, + ) -> Option> { + self.lang_plugins + .iter() + .find(|p| p.name() == language) + .map(|p| p.type_system()) } pub fn get_node_adapter( diff --git a/crates/core/tests/semantic_traits.rs b/crates/core/tests/semantic_traits.rs index 6eb7bdf..0ee01b3 100644 --- a/crates/core/tests/semantic_traits.rs +++ b/crates/core/tests/semantic_traits.rs @@ -138,7 +138,10 @@ impl LanguagePlugin for MockPlugin { fn lang_resolver(&self) -> Arc { self.lang_resolver.clone() } - fn lsp_parser(&self) -> Arc { + fn type_system(&self) -> Arc { + Arc::new(naviscope_plugin::type_system::NoOpTypeSystem) + } + fn lsp_service(&self) -> Arc { Arc::new(MockLspParserWrapper { parser: self.lsp_parser.clone(), }) diff --git a/crates/lang-java/src/lib.rs b/crates/lang-java/src/lib.rs index 3d8c82c..6a678c7 100644 --- a/crates/lang-java/src/lib.rs +++ b/crates/lang-java/src/lib.rs @@ -25,6 +25,7 @@ use std::sync::Arc; pub struct JavaPlugin { parser: Arc, resolver: Arc, + type_system: Arc, } impl NodeAdapter for JavaPlugin { @@ -250,7 +251,12 @@ impl JavaPlugin { let resolver = Arc::new(resolver::JavaResolver { parser: (*parser).clone(), }); - Ok(Self { parser, resolver }) + let type_system = Arc::new(lsp::type_system::JavaTypeSystem::new()); + Ok(Self { + parser, + resolver, + type_system, + }) } } @@ -285,11 +291,15 @@ impl LanguagePlugin for JavaPlugin { self.resolver.clone() } + fn type_system(&self) -> Arc { + self.type_system.clone() + } + fn lang_resolver(&self) -> Arc { self.resolver.clone() } - fn lsp_parser(&self) -> Arc { + fn lsp_service(&self) -> Arc { Arc::new(crate::lsp::JavaLspService::new(self.parser.clone())) } diff --git a/crates/lang-java/src/lsp/mod.rs b/crates/lang-java/src/lsp/mod.rs index a9abb2e..535529a 100644 --- a/crates/lang-java/src/lsp/mod.rs +++ b/crates/lang-java/src/lsp/mod.rs @@ -1,5 +1,6 @@ mod references; mod symbols; +pub mod type_system; use crate::parser::JavaParser; use naviscope_api::models::SymbolResolution; @@ -48,6 +49,9 @@ impl LspService for JavaLspService { tree: &Tree, target: &SymbolResolution, ) -> Vec { + // In the fast syntactic path, we don't strictly need the full type system, + // but we could use it for early filtering if needed. + // For now, we perform the syntactic scan. references::find_occurrences(&self.parser, source, tree, target) } } diff --git a/crates/lang-java/src/lsp/type_system.rs b/crates/lang-java/src/lsp/type_system.rs new file mode 100644 index 0000000..3be6b09 --- /dev/null +++ b/crates/lang-java/src/lsp/type_system.rs @@ -0,0 +1,60 @@ +use naviscope_api::models::SymbolResolution; +use naviscope_plugin::graph::CodeGraph; +use naviscope_plugin::type_system::TypeSystem; + +pub struct JavaTypeSystem; + +impl JavaTypeSystem { + pub fn new() -> Self { + Self + } +} + +impl TypeSystem for JavaTypeSystem { + fn is_reference_to( + &self, + graph: &dyn CodeGraph, + candidate: &SymbolResolution, + target: &SymbolResolution, + ) -> bool { + // Core Java identity and inheritance logic + if candidate == target { + return true; + } + + // Handle method overrides/implementations + let c_fqn = candidate.fqn(); + let t_fqn = target.fqn(); + + if let (Some(c_fqn), Some(t_fqn)) = (c_fqn, t_fqn) { + self.check_inheritance_match(graph, c_fqn, t_fqn) + } else { + false + } + } +} + +impl JavaTypeSystem { + fn check_inheritance_match( + &self, + graph: &dyn CodeGraph, + candidate_fqn: &str, + target_fqn: &str, + ) -> bool { + // Logic: if both are members (contain '#'), compare names and check if classes match inheritance + use naviscope_plugin::naming::parse_member_fqn; + + if let (Some((c_type, c_member)), Some((t_type, t_member))) = ( + parse_member_fqn(candidate_fqn), + parse_member_fqn(target_fqn), + ) { + if c_member == t_member { + // Member names match, check if classes are related + return self.is_subtype(graph, c_type, t_type) + || self.is_subtype(graph, t_type, c_type); + } + } + + false + } +} diff --git a/crates/lang-java/src/resolver/mod.rs b/crates/lang-java/src/resolver/mod.rs index 3864289..543e2e7 100644 --- a/crates/lang-java/src/resolver/mod.rs +++ b/crates/lang-java/src/resolver/mod.rs @@ -84,10 +84,8 @@ impl JavaResolver { "method_declaration" | "constructor_declaration" => { // Build method FQN using canonical member separator if let Some(ref enclosing) = infer_ctx.enclosing_class { - let method_fqn = crate::naming::build_member_fqn( - enclosing, - &context.name, - ); + let method_fqn = + crate::naming::build_member_fqn(enclosing, &context.name); return Some(SymbolResolution::Precise( method_fqn, naviscope_api::models::SymbolIntent::Method, @@ -112,10 +110,7 @@ impl JavaResolver { // Build field FQN using canonical member separator if let Some(ref enclosing) = infer_ctx.enclosing_class { let field_fqn = - crate::naming::build_member_fqn( - enclosing, - &context.name, - ); + crate::naming::build_member_fqn(enclosing, &context.name); return Some(SymbolResolution::Precise( field_fqn, naviscope_api::models::SymbolIntent::Field, diff --git a/crates/lang-java/tests/logic_hierarchy.rs b/crates/lang-java/tests/logic_hierarchy.rs index 505c63f..42e1119 100644 --- a/crates/lang-java/tests/logic_hierarchy.rs +++ b/crates/lang-java/tests/logic_hierarchy.rs @@ -42,9 +42,10 @@ fn test_call_hierarchy_incoming() { let abs_path = std::env::current_dir().unwrap().join("Test.java"); let uri = lsp_types::Url::from_file_path(&abs_path).unwrap(); + let java_ts = naviscope_java::lsp::type_system::JavaTypeSystem::new(); for path in candidate_files { let lsp_service = JavaLspService::new(std::sync::Arc::new(resolver.parser.clone())); - let locations = discovery.scan_file(&lsp_service, &resolver, content, &res, &uri); + let locations = discovery.scan_file(&lsp_service, &java_ts, &resolver, content, &res, &uri); for loc in locations { if let Some(container_idx) = index.find_container_node_at( &path, @@ -174,8 +175,9 @@ fn test_call_hierarchy_recursion() { let abs_path = std::env::current_dir().unwrap().join("Test.java"); let uri = lsp_types::Url::from_file_path(&abs_path).unwrap(); + let java_ts = naviscope_java::lsp::type_system::JavaTypeSystem::new(); let lsp_service = JavaLspService::new(std::sync::Arc::new(resolver.parser.clone())); - let locations = discovery.scan_file(&lsp_service, &resolver, content, &res, &uri); + let locations = discovery.scan_file(&lsp_service, &java_ts, &resolver, content, &res, &uri); for loc in locations { if let Some(c_idx) = index.find_container_node_at( &std::path::PathBuf::from("Test.java"), diff --git a/crates/lang-java/tests/test_engine_facade.rs b/crates/lang-java/tests/test_engine_facade.rs index 2885821..86df75c 100644 --- a/crates/lang-java/tests/test_engine_facade.rs +++ b/crates/lang-java/tests/test_engine_facade.rs @@ -85,14 +85,16 @@ public class App { assert!(impls[0].path.to_string_lossy().contains("Impl.java")); // 3. Find incoming calls to 'Impl#run' - // In this simple case, b.run() is a call to Base#run, so it might not show up for Impl#run unless we have advanced pointer analysis + // With TypeSystem integration, searching for an implementation should find + // calls to the base method as well. let calls = handle .find_incoming_calls("com.example.Impl#run") .await .unwrap(); - assert!( - calls.is_empty(), - "Direct lookup of Impl#run should be empty if only Base#run is called" + assert_eq!( + calls.len(), + 1, + "Lookup of Impl#run should find the call via Base type" ); // 4. Find incoming calls to 'Base#run' diff --git a/crates/plugin/src/lib.rs b/crates/plugin/src/lib.rs index 07758db..18893e3 100644 --- a/crates/plugin/src/lib.rs +++ b/crates/plugin/src/lib.rs @@ -6,6 +6,7 @@ pub mod model; pub mod naming; pub mod plugin; pub mod resolver; +pub mod type_system; pub mod utils; pub use asset::*; @@ -16,3 +17,4 @@ pub use model::*; pub use naming::{NamingConvention, StandardNamingConvention}; pub use plugin::*; pub use resolver::*; +pub use type_system::*; diff --git a/crates/plugin/src/plugin.rs b/crates/plugin/src/plugin.rs index d249a61..e7de4cb 100644 --- a/crates/plugin/src/plugin.rs +++ b/crates/plugin/src/plugin.rs @@ -79,11 +79,14 @@ pub trait LanguagePlugin: PluginInstance + Send + Sync { /// Get the semantic resolver for this language fn resolver(&self) -> Arc; + /// Get the type system for this language + fn type_system(&self) -> Arc; + /// Get the language-level resolver for graph builds fn lang_resolver(&self) -> Arc; /// Get the LSP parser for this language - fn lsp_parser(&self) -> Arc; + fn lsp_service(&self) -> Arc; /// Get the external resolver for classpath resolution (Phase 2+) fn external_resolver(&self) -> Option> { diff --git a/crates/plugin/src/type_system.rs b/crates/plugin/src/type_system.rs new file mode 100644 index 0000000..83a4c4a --- /dev/null +++ b/crates/plugin/src/type_system.rs @@ -0,0 +1,92 @@ +use crate::graph::CodeGraph; +use naviscope_api::models::SymbolResolution; +use std::sync::Arc; + +/// Unified interface for language-specific type systems and semantic reasoning. +pub trait TypeSystem: Send + Sync { + /// Checks if a candidate resolution is a semantically valid reference to the target resolution. + /// This handles subtyping, interface implementations, and member overrides. + fn is_reference_to( + &self, + graph: &dyn CodeGraph, + candidate: &SymbolResolution, + target: &SymbolResolution, + ) -> bool; + + /// Checks if `sub` is a subtype of `sup` in the given code graph. + /// Default implementation uses BFS to traverse InheritsFrom and Implements edges. + fn is_subtype(&self, graph: &dyn crate::graph::CodeGraph, sub: &str, sup: &str) -> bool { + if sub == sup { + return true; + } + + let sub_ids = graph.resolve_fqn(sub); + let sup_ids = graph.resolve_fqn(sup); + + if sub_ids.is_empty() || sup_ids.is_empty() { + return false; + } + + use naviscope_api::models::graph::EdgeType; + use std::collections::{HashSet, VecDeque}; + + for &sub_id in &sub_ids { + let mut visited = HashSet::new(); + let mut queue = VecDeque::new(); + queue.push_back(sub_id); + visited.insert(sub_id); + + while let Some(current) = queue.pop_front() { + if sup_ids.contains(¤t) { + return true; + } + + // Check parents (InheritsFrom / Implements) + let parents = graph.get_neighbors( + current, + crate::graph::Direction::Outgoing, + Some(EdgeType::InheritsFrom), + ); + for p in parents { + if !visited.contains(&p) { + visited.insert(p); + queue.push_back(p); + } + } + + let interfaces = graph.get_neighbors( + current, + crate::graph::Direction::Outgoing, + Some(EdgeType::Implements), + ); + for i in interfaces { + if !visited.contains(&i) { + visited.insert(i); + queue.push_back(i); + } + } + } + } + + false + } +} + +/// Pointer type for the type system. +pub type TypeSystemPtr = Arc; + +/// A simple no-op type system that only performs exact equality checks. +/// Useful as a fallback for languages that don't yet have full type system support. +pub struct NoOpTypeSystem; + +impl TypeSystem for NoOpTypeSystem { + fn is_reference_to( + &self, + _graph: &dyn CodeGraph, + candidate: &SymbolResolution, + target: &SymbolResolution, + ) -> bool { + // Fallback to basic equality check + candidate == target + } +} From 0ea0fe7f528c760014b33a450cbef613147dc745 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Thu, 12 Feb 2026 04:07:57 +0800 Subject: [PATCH 12/49] refactor(plugin): introduce capability caps and registration --- crates/plugin/src/asset.rs | 11 +- crates/plugin/src/cap/asset.rs | 25 ++++ crates/plugin/src/cap/indexing.rs | 12 ++ crates/plugin/src/cap/matcher.rs | 5 + crates/plugin/src/cap/metadata_codec.rs | 8 ++ crates/plugin/src/cap/mod.rs | 15 +++ crates/plugin/src/cap/parse.rs | 11 ++ crates/plugin/src/cap/presentation.rs | 16 +++ crates/plugin/src/cap/runtime.rs | 99 +++++++++++++++ crates/plugin/src/codec.rs | 6 + crates/plugin/src/interner.rs | 10 -- crates/plugin/src/lib.rs | 16 ++- crates/plugin/src/metadata_codec.rs | 8 ++ crates/plugin/src/plugin.rs | 161 ------------------------ crates/plugin/src/presentation.rs | 6 + crates/plugin/src/registration.rs | 29 +++++ crates/plugin/src/resolver.rs | 109 +--------------- crates/plugin/src/type_system.rs | 92 -------------- crates/plugin/src/typing.rs | 61 +++++++++ 19 files changed, 317 insertions(+), 383 deletions(-) create mode 100644 crates/plugin/src/cap/asset.rs create mode 100644 crates/plugin/src/cap/indexing.rs create mode 100644 crates/plugin/src/cap/matcher.rs create mode 100644 crates/plugin/src/cap/metadata_codec.rs create mode 100644 crates/plugin/src/cap/mod.rs create mode 100644 crates/plugin/src/cap/parse.rs create mode 100644 crates/plugin/src/cap/presentation.rs create mode 100644 crates/plugin/src/cap/runtime.rs create mode 100644 crates/plugin/src/codec.rs create mode 100644 crates/plugin/src/metadata_codec.rs delete mode 100644 crates/plugin/src/plugin.rs create mode 100644 crates/plugin/src/presentation.rs create mode 100644 crates/plugin/src/registration.rs delete mode 100644 crates/plugin/src/type_system.rs create mode 100644 crates/plugin/src/typing.rs diff --git a/crates/plugin/src/asset.rs b/crates/plugin/src/asset.rs index d7d90f3..056538e 100644 --- a/crates/plugin/src/asset.rs +++ b/crates/plugin/src/asset.rs @@ -173,12 +173,5 @@ impl StubRequest { } } -/// Stub request sender (producer side) -pub trait StubRequestSender: Send + Sync { - fn send(&self, request: StubRequest); -} - -/// Stub request receiver (consumer side) -pub trait StubRequestReceiver: Send { - fn recv(&mut self) -> Option; -} +pub type StubRequestSender = std::sync::mpsc::Sender; +pub type StubRequestReceiver = std::sync::mpsc::Receiver; diff --git a/crates/plugin/src/cap/asset.rs b/crates/plugin/src/cap/asset.rs new file mode 100644 index 0000000..d7b437a --- /dev/null +++ b/crates/plugin/src/cap/asset.rs @@ -0,0 +1,25 @@ +use crate::asset::{AssetDiscoverer, AssetIndexer, AssetSourceLocator, StubGenerator}; +use std::path::Path; +use std::sync::Arc; + +pub trait AssetCap: Send + Sync { + fn global_asset_discoverer(&self) -> Option> { + None + } + + fn project_asset_discoverer(&self, _project_root: &Path) -> Option> { + None + } + + fn asset_indexer(&self) -> Option> { + None + } + + fn asset_source_locator(&self) -> Option> { + None + } + + fn stub_generator(&self) -> Option> { + None + } +} diff --git a/crates/plugin/src/cap/indexing.rs b/crates/plugin/src/cap/indexing.rs new file mode 100644 index 0000000..e76308c --- /dev/null +++ b/crates/plugin/src/cap/indexing.rs @@ -0,0 +1,12 @@ +use crate::asset::BoxError; +use crate::model::ParsedFile; +use crate::resolver::ProjectContext; +use crate::ResolvedUnit; + +pub trait SourceIndexCap: Send + Sync { + fn compile_source(&self, file: &ParsedFile, context: &ProjectContext) -> Result; +} + +pub trait BuildIndexCap: Send + Sync { + fn compile_build(&self, files: &[&ParsedFile]) -> Result<(ResolvedUnit, ProjectContext), BoxError>; +} diff --git a/crates/plugin/src/cap/matcher.rs b/crates/plugin/src/cap/matcher.rs new file mode 100644 index 0000000..c46b9ab --- /dev/null +++ b/crates/plugin/src/cap/matcher.rs @@ -0,0 +1,5 @@ +use std::path::Path; + +pub trait FileMatcherCap: Send + Sync { + fn supports_path(&self, path: &Path) -> bool; +} diff --git a/crates/plugin/src/cap/metadata_codec.rs b/crates/plugin/src/cap/metadata_codec.rs new file mode 100644 index 0000000..a5d001e --- /dev/null +++ b/crates/plugin/src/cap/metadata_codec.rs @@ -0,0 +1,8 @@ +use crate::metadata_codec::NodeMetadataCodec; +use std::sync::Arc; + +pub trait MetadataCodecCap: Send + Sync { + fn metadata_codec(&self) -> Option> { + None + } +} diff --git a/crates/plugin/src/cap/mod.rs b/crates/plugin/src/cap/mod.rs new file mode 100644 index 0000000..e69f7c9 --- /dev/null +++ b/crates/plugin/src/cap/mod.rs @@ -0,0 +1,15 @@ +pub mod asset; +pub mod indexing; +pub mod matcher; +pub mod metadata_codec; +pub mod parse; +pub mod presentation; +pub mod runtime; + +pub use asset::*; +pub use indexing::*; +pub use matcher::*; +pub use metadata_codec::*; +pub use parse::*; +pub use presentation::*; +pub use runtime::*; diff --git a/crates/plugin/src/cap/parse.rs b/crates/plugin/src/cap/parse.rs new file mode 100644 index 0000000..d8afb35 --- /dev/null +++ b/crates/plugin/src/cap/parse.rs @@ -0,0 +1,11 @@ +use crate::asset::BoxError; +use crate::model::{BuildParseResult, GlobalParseResult}; +use std::path::Path; + +pub trait LanguageParseCap: Send + Sync { + fn parse_language_file(&self, source: &str, path: &Path) -> Result; +} + +pub trait BuildParseCap: Send + Sync { + fn parse_build_file(&self, source: &str) -> Result; +} diff --git a/crates/plugin/src/cap/presentation.rs b/crates/plugin/src/cap/presentation.rs new file mode 100644 index 0000000..878dc73 --- /dev/null +++ b/crates/plugin/src/cap/presentation.rs @@ -0,0 +1,16 @@ +use crate::naming::NamingConvention; +use crate::presentation::NodePresenter; +use naviscope_api::models::graph::NodeKind; +use std::sync::Arc; + +pub trait PresentationCap: Send + Sync { + fn naming_convention(&self) -> Option> { + None + } + + fn node_presenter(&self) -> Option> { + None + } + + fn symbol_kind(&self, kind: &NodeKind) -> lsp_types::SymbolKind; +} diff --git a/crates/plugin/src/cap/runtime.rs b/crates/plugin/src/cap/runtime.rs new file mode 100644 index 0000000..53ceb1d --- /dev/null +++ b/crates/plugin/src/cap/runtime.rs @@ -0,0 +1,99 @@ +use crate::graph::CodeGraph; +use naviscope_api::models::graph::DisplayGraphNode; +use naviscope_api::models::symbol::{FqnId, Range}; +use naviscope_api::models::SymbolResolution; +use tree_sitter::Tree; + +pub trait SymbolResolveService: Send + Sync { + fn resolve_at( + &self, + tree: &Tree, + source: &str, + line: usize, + byte_col: usize, + index: &dyn CodeGraph, + ) -> Option; +} + +pub trait SymbolQueryService: Send + Sync { + fn find_matches(&self, index: &dyn CodeGraph, res: &SymbolResolution) -> Vec; + fn resolve_type_of(&self, index: &dyn CodeGraph, res: &SymbolResolution) -> Vec; + fn find_implementations(&self, index: &dyn CodeGraph, res: &SymbolResolution) -> Vec; +} + +pub trait LspSyntaxService: Send + Sync { + fn parse(&self, source: &str, old_tree: Option<&Tree>) -> Option; + fn extract_symbols(&self, tree: &Tree, source: &str) -> Vec; + fn find_occurrences(&self, source: &str, tree: &Tree, target: &SymbolResolution) -> Vec; +} + +pub trait ReferenceCheckService: Send + Sync { + fn is_reference_to( + &self, + graph: &dyn CodeGraph, + candidate: &SymbolResolution, + target: &SymbolResolution, + ) -> bool; + + fn is_subtype(&self, graph: &dyn CodeGraph, sub: &str, sup: &str) -> bool { + if sub == sup { + return true; + } + + let sub_ids = graph.resolve_fqn(sub); + let sup_ids = graph.resolve_fqn(sup); + if sub_ids.is_empty() || sup_ids.is_empty() { + return false; + } + + use naviscope_api::models::graph::EdgeType; + use std::collections::{HashSet, VecDeque}; + + for &sub_id in &sub_ids { + let mut visited = HashSet::new(); + let mut queue = VecDeque::new(); + queue.push_back(sub_id); + visited.insert(sub_id); + + while let Some(current) = queue.pop_front() { + if sup_ids.contains(¤t) { + return true; + } + + let parents = graph.get_neighbors( + current, + crate::graph::Direction::Outgoing, + Some(EdgeType::InheritsFrom), + ); + for parent in parents { + if visited.insert(parent) { + queue.push_back(parent); + } + } + + let interfaces = graph.get_neighbors( + current, + crate::graph::Direction::Outgoing, + Some(EdgeType::Implements), + ); + for interface in interfaces { + if visited.insert(interface) { + queue.push_back(interface); + } + } + } + } + + false + } +} + +pub trait SemanticCap: + SymbolResolveService + SymbolQueryService + LspSyntaxService + ReferenceCheckService + Send + Sync +{ +} + +impl SemanticCap for T where + T: SymbolResolveService + SymbolQueryService + LspSyntaxService + ReferenceCheckService + Send + Sync +{ +} diff --git a/crates/plugin/src/codec.rs b/crates/plugin/src/codec.rs new file mode 100644 index 0000000..4f4b445 --- /dev/null +++ b/crates/plugin/src/codec.rs @@ -0,0 +1,6 @@ +use crate::interner::FqnInterner; + +pub trait CodecContext: Send + Sync { + fn interner(&mut self) -> &mut dyn FqnInterner; + fn as_any_mut(&mut self) -> &mut dyn std::any::Any; +} diff --git a/crates/plugin/src/interner.rs b/crates/plugin/src/interner.rs index 13dd547..cb404e6 100644 --- a/crates/plugin/src/interner.rs +++ b/crates/plugin/src/interner.rs @@ -14,16 +14,6 @@ pub trait FqnInterner: FqnReader { fn intern_node_id(&self, id: &NodeId) -> FqnId; } -/// Context for metadata serialization/deserialization operations. -/// Provides access to shared string interners and other storage facilities. -pub trait StorageContext: Send + Sync { - /// Get the string interner for converting strings to symbols. - fn interner(&mut self) -> &mut dyn FqnInterner; - - /// Downcast to Any for plugin-specific context access. - fn as_any_mut(&mut self) -> &mut dyn std::any::Any; -} - /// Context for interning strings during metadata conversion. pub trait SymbolInterner { fn intern_str(&mut self, s: &str) -> u32; diff --git a/crates/plugin/src/lib.rs b/crates/plugin/src/lib.rs index 18893e3..9f5b6c8 100644 --- a/crates/plugin/src/lib.rs +++ b/crates/plugin/src/lib.rs @@ -1,20 +1,28 @@ pub mod asset; +pub mod cap; +pub mod codec; pub mod converter; pub mod graph; pub mod interner; +pub mod metadata_codec; pub mod model; pub mod naming; -pub mod plugin; +pub mod presentation; +pub mod registration; pub mod resolver; -pub mod type_system; +pub mod typing; pub mod utils; pub use asset::*; +pub use cap::*; +pub use codec::*; pub use converter::*; pub use graph::*; pub use interner::*; +pub use metadata_codec::*; pub use model::*; pub use naming::{NamingConvention, StandardNamingConvention}; -pub use plugin::*; +pub use presentation::*; +pub use registration::*; pub use resolver::*; -pub use type_system::*; +pub use typing::*; diff --git a/crates/plugin/src/metadata_codec.rs b/crates/plugin/src/metadata_codec.rs new file mode 100644 index 0000000..68b1847 --- /dev/null +++ b/crates/plugin/src/metadata_codec.rs @@ -0,0 +1,8 @@ +use crate::codec::CodecContext; +use naviscope_api::models::graph::{NodeMetadata}; +use std::sync::Arc; + +pub trait NodeMetadataCodec: Send + Sync { + fn encode_metadata(&self, metadata: &dyn NodeMetadata, ctx: &mut dyn CodecContext) -> Vec; + fn decode_metadata(&self, bytes: &[u8], ctx: &dyn CodecContext) -> Arc; +} diff --git a/crates/plugin/src/plugin.rs b/crates/plugin/src/plugin.rs deleted file mode 100644 index e7de4cb..0000000 --- a/crates/plugin/src/plugin.rs +++ /dev/null @@ -1,161 +0,0 @@ -use crate::interner::StorageContext; -use crate::model::{BuildParseResult, GlobalParseResult}; -use crate::naming::NamingConvention; -use crate::resolver::{BuildResolver, LangResolver, LspService, SemanticResolver}; -use naviscope_api::models::graph::{DisplayGraphNode, GraphNode, NodeMetadata}; -use naviscope_api::models::symbol::FqnReader; -use std::path::Path; -use std::sync::Arc; - -/// Metadata for a plugin (plugin's own information). -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] -pub struct PluginInfo { - pub id: String, - pub name: String, - pub version: String, - pub description: Option, -} - -/// Unified plugin handle according to V2 architecture. -pub struct PluginHandle { - pub metadata: PluginInfo, - pub instance: Arc, -} - -/// The core trait that all plugins must implement. -/// It uses "capability discovery" instead of "fat interface inheritance". -pub trait PluginInstance: Send + Sync { - /// Get the naming convention for this plugin (if any). - fn get_naming_convention(&self) -> Option> { - None - } - - /// Get the node adapter for this plugin (if any). - /// The node adapter handles both display rendering and metadata serialization. - fn get_node_adapter(&self) -> Option> { - None - } -} - -/// Unified interface for language-specific node processing. -/// Handles both display rendering and metadata serialization. -pub trait NodeAdapter: Send + Sync { - // === Presentation Layer (Display) === - - /// Convert internal GraphNode to DisplayGraphNode with full information. - fn render_display_node(&self, node: &GraphNode, fqns: &dyn FqnReader) -> DisplayGraphNode; - - // === Storage Layer (Serialization) === - - /// Serialize metadata for storage. - fn encode_metadata( - &self, - _metadata: &dyn NodeMetadata, - _ctx: &mut dyn StorageContext, - ) -> Vec { - // Default: no metadata to store - Vec::new() - } - - /// Deserialize metadata from storage. - fn decode_metadata(&self, bytes: &[u8], ctx: &dyn StorageContext) -> Arc; -} - -/// Unified interface for language-specific support. -pub trait LanguagePlugin: PluginInstance + Send + Sync { - /// Plugin name, e.g., Language::JAVA - fn name(&self) -> naviscope_api::models::Language; - - /// Supported file extensions - fn supported_extensions(&self) -> &[&str]; - - /// Execute file parsing to extract nodes and relationships - fn parse_file( - &self, - source: &str, - path: &Path, - ) -> Result>; - - /// Get the semantic resolver for this language - fn resolver(&self) -> Arc; - - /// Get the type system for this language - fn type_system(&self) -> Arc; - - /// Get the language-level resolver for graph builds - fn lang_resolver(&self) -> Arc; - - /// Get the LSP parser for this language - fn lsp_service(&self) -> Arc; - - /// Get the external resolver for classpath resolution (Phase 2+) - fn external_resolver(&self) -> Option> { - None - } - - /// Check if this plugin can handle an external asset (by extension) for stubbing. - fn can_handle_external_asset(&self, _ext: &str) -> bool { - false - } - - /// Get the asset indexer for this language (Global Asset Scanner architecture) - fn asset_indexer(&self) -> Option> { - None - } - - /// Get the asset discoverer for this language (e.g., JdkDiscoverer for Java) - fn global_asset_discoverer(&self) -> Option> { - None - } - - /// Get the asset source locator for this language (optional hook). - fn asset_source_locator(&self) -> Option> { - None - } - - /// Get the project-local asset discoverer for this language (optional hook). - /// Use this for assets that exist only inside the current project (e.g. build outputs). - fn project_asset_discoverer( - &self, - _project_root: &Path, - ) -> Option> { - None - } -} - -/// Unified interface for build tool support. -pub trait BuildToolPlugin: PluginInstance + Send + Sync { - /// Plugin name, e.g., BuildTool::GRADLE - fn name(&self) -> naviscope_api::models::BuildTool; - - /// Checks if this plugin can handle the given file name - fn recognize(&self, file_name: &str) -> bool; - - /// Parse build-specific files - fn parse_build_file( - &self, - source: &str, - ) -> Result>; - - /// Get the build resolver - fn build_resolver(&self) -> Arc; - - /// Get the asset discoverer for this build tool (e.g., GradleCacheDiscoverer) - fn asset_discoverer(&self) -> Option> { - None - } - - /// Get the asset source locator for this build tool (optional hook). - fn asset_source_locator(&self) -> Option> { - None - } - - /// Get the project-local asset discoverer for this build tool (optional hook). - /// Use this for assets that exist only inside the current project (e.g. libs/*.jar). - fn project_asset_discoverer( - &self, - _project_root: &Path, - ) -> Option> { - None - } -} diff --git a/crates/plugin/src/presentation.rs b/crates/plugin/src/presentation.rs new file mode 100644 index 0000000..df3e5e2 --- /dev/null +++ b/crates/plugin/src/presentation.rs @@ -0,0 +1,6 @@ +use naviscope_api::models::graph::{DisplayGraphNode, GraphNode}; +use naviscope_api::models::symbol::FqnReader; + +pub trait NodePresenter: Send + Sync { + fn render_display_node(&self, node: &GraphNode, fqns: &dyn FqnReader) -> DisplayGraphNode; +} diff --git a/crates/plugin/src/registration.rs b/crates/plugin/src/registration.rs new file mode 100644 index 0000000..6b38d1b --- /dev/null +++ b/crates/plugin/src/registration.rs @@ -0,0 +1,29 @@ +use crate::cap::{ + AssetCap, BuildIndexCap, BuildParseCap, FileMatcherCap, LanguageParseCap, MetadataCodecCap, + PresentationCap, SemanticCap, SourceIndexCap, +}; +use naviscope_api::models::{BuildTool, Language}; +use std::sync::Arc; + +#[derive(Clone)] +pub struct LanguageCaps { + pub language: Language, + pub matcher: Arc, + pub parser: Arc, + pub semantic: Arc, + pub indexing: Arc, + pub asset: Arc, + pub presentation: Arc, + pub metadata_codec: Arc, +} + +#[derive(Clone)] +pub struct BuildCaps { + pub build_tool: BuildTool, + pub matcher: Arc, + pub parser: Arc, + pub indexing: Arc, + pub asset: Arc, + pub presentation: Arc, + pub metadata_codec: Arc, +} diff --git a/crates/plugin/src/resolver.rs b/crates/plugin/src/resolver.rs index cef1b33..89a379e 100644 --- a/crates/plugin/src/resolver.rs +++ b/crates/plugin/src/resolver.rs @@ -1,65 +1,7 @@ -use crate::graph::{CodeGraph, ResolvedUnit}; -use crate::model::ParsedFile; -use naviscope_api::models::graph::DisplayGraphNode; -use naviscope_api::models::graph::NodeKind; -use naviscope_api::models::symbol::{FqnId, Range}; use std::collections::HashMap; use std::path::{Path, PathBuf}; -use tree_sitter::Tree; - -/// Core interface for resolving a symbol at a specific position in a document. -pub trait SemanticResolver: Send + Sync { - /// Resolve a symbol at a specific position in a document (local view) - fn resolve_at( - &self, - tree: &Tree, - source: &str, - line: usize, - byte_col: usize, - index: &dyn CodeGraph, - ) -> Option; - - /// Find nodes in the global graph matching a resolution result (global view) - fn find_matches( - &self, - index: &dyn CodeGraph, - res: &naviscope_api::models::SymbolResolution, - ) -> Vec; - - /// Resolve the type(s) of a symbol (e.g., return type of a method, type of a field) - fn resolve_type_of( - &self, - index: &dyn CodeGraph, - res: &naviscope_api::models::SymbolResolution, - ) -> Vec; - - /// Find implementations or overrides of a symbol (global view) - fn find_implementations( - &self, - index: &dyn CodeGraph, - res: &naviscope_api::models::SymbolResolution, - ) -> Vec; -} - -pub trait LspService: Send + Sync { - fn parse(&self, source: &str, old_tree: Option<&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: &NodeKind) -> lsp_types::SymbolKind; - - /// Find occurrences of a symbol within a single file's AST. - fn find_occurrences( - &self, - source: &str, - tree: &Tree, - target: &naviscope_api::models::SymbolResolution, - ) -> Vec; -} - -/// Project context generated by BuildResolver during the first phase +/// Project context generated during build indexing. #[derive(Debug, Clone, Default)] pub struct ProjectContext { /// Mapping from path prefixes to module IDs (e.g., "/project/app" -> "module::app") @@ -73,7 +15,7 @@ impl ProjectContext { } } - /// Finds the best matching module ID for a given file path + /// Finds the best matching module ID for a given file path. pub fn find_module_for_path(&self, path: &Path) -> Option { let mut current = path.to_path_buf(); while let Some(parent) = current.parent() { @@ -85,50 +27,3 @@ impl ProjectContext { None } } - -/// Responsible for resolution logic at the build tool level -pub trait BuildResolver: Send + Sync { - /// Resolves build files into graph operations and project context - fn resolve( - &self, - files: &[&ParsedFile], - ) -> Result<(ResolvedUnit, ProjectContext), Box>; -} - -/// Responsible for resolution logic at the language level -pub trait LangResolver: Send + Sync { - /// Resolves source files into graph operations using the provided context - fn resolve( - &self, - file: &ParsedFile, - context: &ProjectContext, - ) -> Result>; -} - -pub trait ExternalResolver: Send + Sync { - /// 1. Asset Indexing: Pre-scan asset content, returns top-level symbols/packages - /// Used by the engine to build the route table. - fn index_asset( - &self, - asset: &Path, - ) -> std::result::Result, Box>; - - /// 2. Code Stubbing: Extract symbol definitions (without logic) from asset. - /// Used for background population of External nodes' metadata. - fn generate_stub( - &self, - fqn: &str, - asset: &Path, - ) -> std::result::Result>; - - /// 3. Source Resolution: Extract full parse results from source asset. - /// Triggered when the user requests Go to Definition. - fn resolve_source( - &self, - fqn: &str, - source_asset: &Path, - ) -> std::result::Result< - crate::model::GlobalParseResult, - Box, - >; -} diff --git a/crates/plugin/src/type_system.rs b/crates/plugin/src/type_system.rs deleted file mode 100644 index 83a4c4a..0000000 --- a/crates/plugin/src/type_system.rs +++ /dev/null @@ -1,92 +0,0 @@ -use crate::graph::CodeGraph; -use naviscope_api::models::SymbolResolution; -use std::sync::Arc; - -/// Unified interface for language-specific type systems and semantic reasoning. -pub trait TypeSystem: Send + Sync { - /// Checks if a candidate resolution is a semantically valid reference to the target resolution. - /// This handles subtyping, interface implementations, and member overrides. - fn is_reference_to( - &self, - graph: &dyn CodeGraph, - candidate: &SymbolResolution, - target: &SymbolResolution, - ) -> bool; - - /// Checks if `sub` is a subtype of `sup` in the given code graph. - /// Default implementation uses BFS to traverse InheritsFrom and Implements edges. - fn is_subtype(&self, graph: &dyn crate::graph::CodeGraph, sub: &str, sup: &str) -> bool { - if sub == sup { - return true; - } - - let sub_ids = graph.resolve_fqn(sub); - let sup_ids = graph.resolve_fqn(sup); - - if sub_ids.is_empty() || sup_ids.is_empty() { - return false; - } - - use naviscope_api::models::graph::EdgeType; - use std::collections::{HashSet, VecDeque}; - - for &sub_id in &sub_ids { - let mut visited = HashSet::new(); - let mut queue = VecDeque::new(); - queue.push_back(sub_id); - visited.insert(sub_id); - - while let Some(current) = queue.pop_front() { - if sup_ids.contains(¤t) { - return true; - } - - // Check parents (InheritsFrom / Implements) - let parents = graph.get_neighbors( - current, - crate::graph::Direction::Outgoing, - Some(EdgeType::InheritsFrom), - ); - for p in parents { - if !visited.contains(&p) { - visited.insert(p); - queue.push_back(p); - } - } - - let interfaces = graph.get_neighbors( - current, - crate::graph::Direction::Outgoing, - Some(EdgeType::Implements), - ); - for i in interfaces { - if !visited.contains(&i) { - visited.insert(i); - queue.push_back(i); - } - } - } - } - - false - } -} - -/// Pointer type for the type system. -pub type TypeSystemPtr = Arc; - -/// A simple no-op type system that only performs exact equality checks. -/// Useful as a fallback for languages that don't yet have full type system support. -pub struct NoOpTypeSystem; - -impl TypeSystem for NoOpTypeSystem { - fn is_reference_to( - &self, - _graph: &dyn CodeGraph, - candidate: &SymbolResolution, - target: &SymbolResolution, - ) -> bool { - // Fallback to basic equality check - candidate == target - } -} diff --git a/crates/plugin/src/typing.rs b/crates/plugin/src/typing.rs new file mode 100644 index 0000000..3533d04 --- /dev/null +++ b/crates/plugin/src/typing.rs @@ -0,0 +1,61 @@ +use crate::cap::ReferenceCheckService; +use crate::graph::{CodeGraph, Direction}; +use naviscope_api::models::graph::EdgeType; +use naviscope_api::models::SymbolResolution; + +pub struct NoOpReferenceCheckService; + +impl ReferenceCheckService for NoOpReferenceCheckService { + fn is_reference_to( + &self, + _graph: &dyn CodeGraph, + candidate: &SymbolResolution, + target: &SymbolResolution, + ) -> bool { + candidate == target + } + + fn is_subtype(&self, graph: &dyn CodeGraph, sub: &str, sup: &str) -> bool { + if sub == sup { + return true; + } + + let sub_ids = graph.resolve_fqn(sub); + let sup_ids = graph.resolve_fqn(sup); + if sub_ids.is_empty() || sup_ids.is_empty() { + return false; + } + + use std::collections::{HashSet, VecDeque}; + for &sub_id in &sub_ids { + let mut visited = HashSet::new(); + let mut queue = VecDeque::new(); + queue.push_back(sub_id); + visited.insert(sub_id); + + while let Some(current) = queue.pop_front() { + if sup_ids.contains(¤t) { + return true; + } + + let parents = + graph.get_neighbors(current, Direction::Outgoing, Some(EdgeType::InheritsFrom)); + for parent in parents { + if visited.insert(parent) { + queue.push_back(parent); + } + } + + let interfaces = + graph.get_neighbors(current, Direction::Outgoing, Some(EdgeType::Implements)); + for interface in interfaces { + if visited.insert(interface) { + queue.push_back(interface); + } + } + } + } + + false + } +} From 25c1d37d472b9d5f28013f58714aae122d5957dc Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Thu, 12 Feb 2026 04:08:02 +0800 Subject: [PATCH 13/49] refactor(core): consume language/build capabilities --- crates/core/src/facade/graph.rs | 3 +- crates/core/src/facade/mod.rs | 44 ++--- crates/core/src/facade/semantic.rs | 44 ++--- crates/core/src/features/discovery.rs | 16 +- crates/core/src/features/query.rs | 4 +- crates/core/src/ingest/builder.rs | 2 +- crates/core/src/ingest/parser/mod.rs | 4 +- crates/core/src/ingest/resolver/engine.rs | 134 +++++-------- crates/core/src/ingest/resolver/mod.rs | 2 +- crates/core/src/model/graph.rs | 14 +- crates/core/src/model/storage/converter.rs | 40 ++-- crates/core/src/model/storage/model.rs | 6 +- crates/core/src/plugin/mod.rs | 7 +- crates/core/src/runtime/orchestrator.rs | 217 ++++++++++----------- 14 files changed, 235 insertions(+), 302 deletions(-) diff --git a/crates/core/src/facade/graph.rs b/crates/core/src/facade/graph.rs index 69cde09..0020a34 100644 --- a/crates/core/src/facade/graph.rs +++ b/crates/core/src/facade/graph.rs @@ -14,7 +14,8 @@ impl graph::GraphService for EngineHandle { let result = tokio::task::spawn_blocking( move || -> Result { let conventions = (*handle.naming_conventions()).clone(); - let engine = QueryEngine::new(&graph, |lang| handle.get_node_adapter(lang), conventions); + let engine = + QueryEngine::new(&graph, |lang| handle.get_node_presenter(lang), conventions); engine.execute(&query_clone) }, ) diff --git a/crates/core/src/facade/mod.rs b/crates/core/src/facade/mod.rs index ca7f57d..9dd8b54 100644 --- a/crates/core/src/facade/mod.rs +++ b/crates/core/src/facade/mod.rs @@ -43,53 +43,41 @@ impl EngineHandle { // ---- Language specific services (internal) ---- - pub fn get_lsp_service( - &self, - language: crate::model::source::Language, - ) -> Option> { - self.engine.get_resolver().get_lsp_service(language) - } - pub fn get_semantic_resolver( &self, language: crate::model::source::Language, - ) -> Option> { - self.engine.get_resolver().get_semantic_resolver(language) + ) -> Option> { + self.engine.get_resolver().get_semantic_cap(language) } - pub fn get_type_system( + pub fn get_node_presenter( &self, language: crate::model::source::Language, - ) -> Option> { - self.engine.get_resolver().get_type_system(language) + ) -> Option> { + self.engine.get_resolver().get_node_presenter(language) } - pub fn get_node_adapter( + pub fn get_metadata_codec( &self, language: crate::model::source::Language, - ) -> Option> { - self.engine.get_resolver().get_node_adapter(language) + ) -> Option> { + self.engine.get_resolver().get_metadata_codec(language) } - pub fn get_language_by_extension(&self, ext: &str) -> Option { - self.engine.get_resolver().get_language_by_extension(ext) + pub fn get_language_for_path( + &self, + path: &std::path::Path, + ) -> Option { + self.engine.get_resolver().get_language_for_path(path) } pub fn get_services_for_path( &self, path: &std::path::Path, - ) -> Option<( - Arc, - Arc, - Arc, - crate::model::source::Language, - )> { - let ext = path.extension()?.to_str()?; - let lang = self.get_language_by_extension(ext)?; - let lsp_service = self.get_lsp_service(lang.clone())?; - let type_system = self.get_type_system(lang.clone())?; + ) -> Option<(Arc, crate::model::source::Language)> { + let lang = self.get_language_for_path(path)?; let resolver = self.get_semantic_resolver(lang.clone())?; - Some((lsp_service, type_system, resolver, lang)) + Some((resolver, lang)) } /// Get naming convention for a specific language diff --git a/crates/core/src/facade/semantic.rs b/crates/core/src/facade/semantic.rs index cc99be4..3e90af6 100644 --- a/crates/core/src/facade/semantic.rs +++ b/crates/core/src/facade/semantic.rs @@ -31,7 +31,7 @@ impl SymbolNavigator for EngineHandle { PathBuf::from(uri_str) }; - let (lsp_service, _type_system, resolver, _lang) = match self.get_services_for_path(&path) { + let (semantic, _lang) = match self.get_services_for_path(&path) { Some(x) => x, None => return Ok(None), }; @@ -42,7 +42,7 @@ impl SymbolNavigator for EngineHandle { fs::read_to_string(&path).map_err(|e| SemanticError::Internal(e.to_string()))? }; - let tree = lsp_service + let tree = semantic .parse(&content, None) .ok_or_else(|| SemanticError::Internal("Failed to parse".into()))?; @@ -50,7 +50,7 @@ impl SymbolNavigator for EngineHandle { let graph = self.graph().await; - Ok(resolver.resolve_at(&tree, &content, ctx.line as usize, byte_col, &graph)) + Ok(semantic.resolve_at(&tree, &content, ctx.line as usize, byte_col, &graph)) } async fn find_highlights(&self, ctx: &PositionContext) -> SemanticResult> { @@ -61,7 +61,7 @@ impl SymbolNavigator for EngineHandle { PathBuf::from(uri_str) }; - let (lsp_service, _type_system, _resolver, _) = match self.get_services_for_path(&path) { + let (semantic, _) = match self.get_services_for_path(&path) { Some(x) => x, None => return Ok(vec![]), }; @@ -72,7 +72,7 @@ impl SymbolNavigator for EngineHandle { fs::read_to_string(&path).map_err(|e| SemanticError::Internal(e.to_string()))? }; - let tree = lsp_service + let tree = semantic .parse(&content, None) .ok_or_else(|| SemanticError::Internal("Failed to parse".into()))?; @@ -81,7 +81,7 @@ impl SymbolNavigator for EngineHandle { None => return Ok(vec![]), }; - Ok(lsp_service.find_occurrences(&content, &tree, &res)) + Ok(semantic.find_occurrences(&content, &tree, &res)) } async fn find_definitions(&self, query: &SymbolQuery) -> SemanticResult> { @@ -210,7 +210,7 @@ impl ReferenceAnalyzer for EngineHandle { let conventions_clone = conventions.clone(); tasks.spawn(async move { - let (lsp_service, type_system, file_resolver, _file_lang) = + let (semantic, _file_lang) = match handle.get_services_for_path(&path) { Some(x) => x, None => return Vec::new(), @@ -230,9 +230,7 @@ impl ReferenceAnalyzer for EngineHandle { }; let locations = discovery.scan_file( - lsp_service.as_ref(), - type_system.as_ref(), - file_resolver.as_ref(), + semantic.as_ref(), &content, &resolution, &uri, @@ -353,7 +351,7 @@ impl CallHierarchyAnalyzer for EngineHandle { let conventions_clone = conventions.clone(); tasks.spawn(async move { - let (lsp_service, type_system, file_resolver, _file_lang) = + let (semantic, _file_lang) = match handle.get_services_for_path(&path) { Some(x) => x, None => return vec![], @@ -373,9 +371,7 @@ impl CallHierarchyAnalyzer for EngineHandle { // Verification discovery.scan_file( - lsp_service.as_ref(), - type_system.as_ref(), - file_resolver.as_ref(), + semantic.as_ref(), &content, &res, &uri, @@ -464,7 +460,7 @@ impl CallHierarchyAnalyzer for EngineHandle { .range() .ok_or_else(|| SemanticError::Internal("Node has no range".into()))?; - let (lsp_service, _type_system, resolver, _lang) = self + let (semantic, _lang) = self .get_services_for_path(&path) .ok_or_else(|| SemanticError::Internal("No services for file".into()))?; @@ -472,7 +468,7 @@ impl CallHierarchyAnalyzer for EngineHandle { fs::read_to_string(&path).map_err(|e| SemanticError::Internal(e.to_string()))?; // Micro-level scanning: extract method body and find all calls - let tree = lsp_service + let tree = semantic .parse(&content, None) .ok_or_else(|| SemanticError::Internal("Failed to parse".into()))?; @@ -508,7 +504,7 @@ impl CallHierarchyAnalyzer for EngineHandle { }; if let Ok(Some(res)) = self.resolve_symbol_at(&pos_ctx).await { - let matches = resolver.find_matches(&graph, &res); + let matches = semantic.find_matches(&graph, &res); for fqn_id in matches { if let Some(&m_idx) = graph.fqn_map().get(&fqn_id) { let m_node = &graph.topology()[m_idx]; @@ -570,8 +566,7 @@ impl SymbolInfoProvider for EngineHandle { PathBuf::from(uri) }; - let (lsp_service, _type_system, _resolver, _lang) = match self.get_services_for_path(&path) - { + let (semantic, _lang) = match self.get_services_for_path(&path) { Some(x) => x, None => return Ok(vec![]), }; @@ -579,11 +574,11 @@ impl SymbolInfoProvider for EngineHandle { let content = fs::read_to_string(&path).map_err(|e| SemanticError::Internal(e.to_string()))?; - let tree = lsp_service + let tree = semantic .parse(&content, None) .ok_or_else(|| SemanticError::Internal("Failed to parse".into()))?; - let symbols = lsp_service.extract_symbols(&tree, &content); + let symbols = semantic.extract_symbols(&tree, &content); Ok(symbols) } @@ -595,11 +590,6 @@ impl SymbolInfoProvider for EngineHandle { PathBuf::from(uri) }; - let ext = match path.extension().and_then(|e| e.to_str()) { - Some(e) => e, - None => return Ok(None), - }; - - Ok(self.get_language_by_extension(ext)) + Ok(self.get_language_for_path(&path)) } } diff --git a/crates/core/src/features/discovery.rs b/crates/core/src/features/discovery.rs index fb391d4..db8086c 100644 --- a/crates/core/src/features/discovery.rs +++ b/crates/core/src/features/discovery.rs @@ -1,5 +1,5 @@ use super::CodeGraphLike; -use crate::ingest::parser::LspService; +use naviscope_plugin::SemanticCap; use lsp_types::{Location, Url}; pub use naviscope_api::models::SymbolResolution; use std::collections::HashSet; @@ -148,31 +148,29 @@ impl<'a> DiscoveryEngine<'a> { /// Now performs SEMANTIC VERIFICATION using the Resolver. pub fn scan_file( &self, - lsp_service: &dyn LspService, - type_system: &dyn naviscope_plugin::TypeSystem, - resolver: &dyn crate::ingest::resolver::SemanticResolver, + semantic: &dyn SemanticCap, source: &str, target_resolution: &SymbolResolution, uri: &Url, ) -> Vec { - if let Some(tree) = lsp_service.parse(source, None) { + if let Some(tree) = semantic.parse(source, None) { // 1. Syntactic Scan (Fast) - let candidates = lsp_service.find_occurrences(source, &tree, target_resolution); + let candidates = semantic.find_occurrences(source, &tree, target_resolution); // 2. Semantic Verification (Precise) let mut valid_locations = Vec::new(); for range in candidates { // Resolve what is truly at this location - if let Some(resolved_at_loc) = resolver.resolve_at( + if let Some(resolved_at_loc) = semantic.resolve_at( &tree, source, range.start_line, range.start_col, self.index.as_plugin_graph(), ) { - // 3. Identity & Inheritance Check via TypeSystem - if type_system.is_reference_to( + // 3. Identity & inheritance check + if semantic.is_reference_to( self.index.as_plugin_graph(), &resolved_at_loc, target_resolution, diff --git a/crates/core/src/features/query.rs b/crates/core/src/features/query.rs index 2b42a81..350ba27 100644 --- a/crates/core/src/features/query.rs +++ b/crates/core/src/features/query.rs @@ -1,7 +1,7 @@ use crate::error::{NaviscopeError, Result}; use crate::model::source::Language; use crate::model::{DisplayGraphNode, EdgeType, NodeKind}; -use crate::plugin::NodeAdapter; +use crate::plugin::NodePresenter; pub use naviscope_api::models::{GraphQuery, QueryResult, QueryResultEdge}; use petgraph::Direction as PetDirection; use regex::RegexBuilder; @@ -19,7 +19,7 @@ pub struct QueryEngine { impl QueryEngine where G: CodeGraphLike, - L: Fn(Language) -> Option>, + L: Fn(Language) -> Option>, { pub fn new( graph: G, diff --git a/crates/core/src/ingest/builder.rs b/crates/core/src/ingest/builder.rs index aa7212a..391df01 100644 --- a/crates/core/src/ingest/builder.rs +++ b/crates/core/src/ingest/builder.rs @@ -7,7 +7,7 @@ use crate::model::CodeGraph; use crate::model::graph::CodeGraphInner; use crate::model::source::SourceFile; -// StorageContext unused +// codec context unused use crate::model::{GraphEdge, GraphOp}; use naviscope_api::models::symbol::Symbol; use naviscope_plugin::{FqnInterner, ModelConverter}; diff --git a/crates/core/src/ingest/parser/mod.rs b/crates/core/src/ingest/parser/mod.rs index ef9c9e0..b53a7e9 100644 --- a/crates/core/src/ingest/parser/mod.rs +++ b/crates/core/src/ingest/parser/mod.rs @@ -6,7 +6,9 @@ pub mod utils; pub use naviscope_api::SymbolResolution; pub use naviscope_api::models::symbol::NodeId; -pub use naviscope_plugin::{GlobalParseResult, IndexNode, IndexRelation, LspService, ParseOutput}; +pub use naviscope_plugin::{ + GlobalParseResult, IndexNode, IndexRelation, LspSyntaxService, ParseOutput, +}; /// Trait for parsers that provide data for the global code knowledge graph. pub trait IndexParser: Send + Sync { diff --git a/crates/core/src/ingest/resolver/engine.rs b/crates/core/src/ingest/resolver/engine.rs index eee3754..288ec3f 100644 --- a/crates/core/src/ingest/resolver/engine.rs +++ b/crates/core/src/ingest/resolver/engine.rs @@ -1,37 +1,35 @@ use crate::error::Result; use crate::ingest::resolver::StubbingManager; -use crate::ingest::resolver::{ProjectContext, SemanticResolver}; use crate::ingest::scanner::ParsedFile; use crate::model::source::Language; use crate::model::{GraphOp, ResolvedUnit}; +use naviscope_plugin::{ + BuildCaps, LanguageCaps, NodeMetadataCodec, NodePresenter, ProjectContext, SemanticCap, +}; use rayon::prelude::*; +use std::path::Path; use std::sync::Arc; -use crate::plugin::{BuildToolPlugin, LanguagePlugin}; - -/// Main resolver that dispatches to specific strategies based on file type for indexing +/// Main resolver that dispatches to specific capabilities based on file path. pub struct IndexResolver { - build_plugins: Vec>, - lang_plugins: Vec>, + build_caps: Vec, + lang_caps: Vec, stubbing: Option, } impl IndexResolver { pub fn new() -> Self { Self { - build_plugins: Vec::new(), - lang_plugins: Vec::new(), + build_caps: Vec::new(), + lang_caps: Vec::new(), stubbing: None, } } - pub fn with_plugins( - build_plugins: Vec>, - lang_plugins: Vec>, - ) -> Self { + pub fn with_caps(build_caps: Vec, lang_caps: Vec) -> Self { Self { - build_plugins, - lang_plugins, + build_caps, + lang_caps, stubbing: None, } } @@ -41,74 +39,62 @@ impl IndexResolver { self } - pub fn register_language(&mut self, plugin: Arc) { - self.lang_plugins.push(plugin); + pub fn register_language(&mut self, caps: LanguageCaps) { + self.lang_caps.push(caps); } - pub fn register_build_tool(&mut self, plugin: Arc) { - self.build_plugins.push(plugin); + pub fn register_build_tool(&mut self, caps: BuildCaps) { + self.build_caps.push(caps); } - pub fn get_semantic_resolver(&self, language: Language) -> Option> { - self.lang_plugins + pub fn get_semantic_cap(&self, language: Language) -> Option> { + self.lang_caps .iter() - .find(|p| p.name() == language) - .map(|p| p.resolver()) + .find(|c| c.language == language) + .map(|c| c.semantic.clone()) } - pub fn get_lsp_service( - &self, - language: Language, - ) -> Option> { - self.lang_plugins + pub fn get_node_presenter(&self, language: Language) -> Option> { + self.lang_caps .iter() - .find(|p| p.name() == language) - .map(|p| p.lsp_service()) - } - - pub fn get_type_system( - &self, - language: Language, - ) -> Option> { - self.lang_plugins - .iter() - .find(|p| p.name() == language) - .map(|p| p.type_system()) + .find(|c| c.language == language) + .and_then(|c| c.presentation.node_presenter()) + .or_else(|| { + self.build_caps + .iter() + .find(|c| c.build_tool.as_str() == language.as_str()) + .and_then(|c| c.presentation.node_presenter()) + }) } - pub fn get_node_adapter( - &self, - language: Language, - ) -> Option> { - self.lang_plugins + pub fn get_metadata_codec(&self, language: Language) -> Option> { + self.lang_caps .iter() - .find(|p| p.name() == language) - .and_then(|p| p.get_node_adapter()) + .find(|c| c.language == language) + .and_then(|c| c.metadata_codec.metadata_codec()) .or_else(|| { - self.build_plugins + self.build_caps .iter() - .find(|p| p.name().as_str() == language.as_str()) - .and_then(|p| p.get_node_adapter()) + .find(|c| c.build_tool.as_str() == language.as_str()) + .and_then(|c| c.metadata_codec.metadata_codec()) }) } - pub fn get_language_by_extension(&self, ext: &str) -> Option { - for plugin in &self.lang_plugins { - if plugin.supported_extensions().contains(&ext) { - return Some(plugin.name()); - } - } - Language::from_extension(ext) + pub fn get_language_for_path(&self, path: &Path) -> Option { + self.lang_caps + .iter() + .find(|c| c.matcher.supports_path(path)) + .map(|c| c.language.clone()) } pub fn get_naming_convention( &self, language: Language, ) -> Option> { - self.lang_plugins + self.lang_caps .iter() - .find(|p| p.name() == language) - .and_then(|p| p.get_naming_convention()) + .find(|c| c.language == language) + .and_then(|c| c.presentation.naming_convention()) } /// Resolve all parsed files into graph operations using a two-phase process. @@ -234,22 +220,16 @@ impl IndexResolver { context: &mut ProjectContext, ) -> Result> { let mut all_ops = Vec::new(); - for plugin in &self.build_plugins { + for caps in &self.build_caps { let tool_files: Vec<&ParsedFile> = build_files .iter() - .filter(|f| { - if let Some(file_name) = f.path().file_name().and_then(|n| n.to_str()) { - plugin.recognize(file_name) - } else { - false - } - }) + .filter(|f| caps.matcher.supports_path(f.path())) .collect(); if !tool_files.is_empty() { - let resolver = plugin.build_resolver(); - let (unit, ctx) = resolver - .resolve(&tool_files) + let (unit, ctx) = caps + .indexing + .compile_build(&tool_files) .map_err(crate::error::NaviscopeError::from)?; all_ops.extend(unit.ops); context.path_to_module.extend(ctx.path_to_module); @@ -266,13 +246,11 @@ impl IndexResolver { let source_results: Vec> = source_files .par_iter() .map(|file| { - let language = file.language().unwrap_or(Language::BUILDFILE); - let plugin = self.lang_plugins.iter().find(|p| p.name() == language); + let caps = self.lang_caps.iter().find(|c| c.matcher.supports_path(file.path())); - if let Some(p) = plugin { - let resolver = p.lang_resolver(); - resolver - .resolve(file, context) + if let Some(c) = caps { + c.indexing + .compile_source(file, context) .map_err(crate::error::NaviscopeError::from) } else { Ok(ResolvedUnit::new()) @@ -297,14 +275,10 @@ impl crate::ingest::pipeline::PipelineStage for IndexResolver { context: &ProjectContext, paths: Vec, ) -> Result> { - // In a pipeline batch, we need to scan and then resolve - // For simplicity in this first step, we assume paths are already filtered - // We need existing_metadata to avoid redundant parsing, but for a simple pipeline we can skip it or pass it in context let files = crate::ingest::scanner::Scanner::scan_files(paths, &std::collections::HashMap::new()); let (mut all_ops, _build, source) = self.prepare_and_partition(files); - // In this stage, we only care about source files in the pipeline let source_ops = self.resolve_source_batch(&source, context)?; all_ops.extend(source_ops); diff --git a/crates/core/src/ingest/resolver/mod.rs b/crates/core/src/ingest/resolver/mod.rs index cbad8f4..6669591 100644 --- a/crates/core/src/ingest/resolver/mod.rs +++ b/crates/core/src/ingest/resolver/mod.rs @@ -3,5 +3,5 @@ pub mod scope; pub mod stub; pub use engine::IndexResolver; -pub use naviscope_plugin::{BuildResolver, LangResolver, ProjectContext, SemanticResolver}; +pub use naviscope_plugin::{ProjectContext, SemanticCap}; pub use stub::{StubRequest, StubbingManager}; diff --git a/crates/core/src/model/graph.rs b/crates/core/src/model/graph.rs index ed48728..6f3b114 100644 --- a/crates/core/src/model/graph.rs +++ b/crates/core/src/model/graph.rs @@ -9,7 +9,7 @@ use crate::features::CodeGraphLike; use crate::model::FqnManager; use crate::model::source::SourceFile; use crate::model::{GraphEdge, GraphNode}; -use crate::plugin::NodeAdapter; +use crate::plugin::NodeMetadataCodec; use lasso::ThreadedRodeo; use naviscope_api::models::symbol::{FqnId, FqnReader, Symbol}; use petgraph::stable_graph::{NodeIndex, StableDiGraph}; @@ -253,10 +253,10 @@ impl CodeGraph { /// Serialize to bytes for persistence pub fn serialize( &self, - get_plugin: impl Fn(&str) -> Option>, + get_codec: impl Fn(&str) -> Option>, ) -> Result> { use super::storage::to_storage; - let storage = to_storage(&self.inner, get_plugin); + let storage = to_storage(&self.inner, get_codec); let bytes = rmp_serde::to_vec(&storage) .map_err(|e| NaviscopeError::Internal(format!("MSGPACK error: {}", e)))?; @@ -269,7 +269,7 @@ impl CodeGraph { /// Deserialize from bytes pub fn deserialize( bytes: &[u8], - get_plugin: impl Fn(&str) -> Option>, + get_codec: impl Fn(&str) -> Option>, ) -> Result { use super::storage::{StorageGraph, from_storage}; @@ -280,7 +280,7 @@ impl CodeGraph { let storage: StorageGraph = rmp_serde::from_read(decoder) .map_err(|e| NaviscopeError::Internal(format!("MSGPACK error: {}", e)))?; - let inner = from_storage(storage, get_plugin); + let inner = from_storage(storage, get_codec); Ok(Self::from_inner(inner)) } @@ -288,12 +288,12 @@ impl CodeGraph { pub fn save_to_json>( &self, path: P, - get_plugin: impl Fn(&str) -> Option>, + get_codec: impl Fn(&str) -> Option>, ) -> crate::error::Result<()> { use super::storage::to_storage; let file = std::fs::File::create(path)?; let writer = std::io::BufWriter::new(file); - let storage = to_storage(&self.inner, get_plugin); + let storage = to_storage(&self.inner, get_codec); serde_json::to_writer_pretty(writer, &storage) .map_err(|e| crate::error::NaviscopeError::Parsing(e.to_string()))?; Ok(()) diff --git a/crates/core/src/model/storage/converter.rs b/crates/core/src/model/storage/converter.rs index d3143ac..0a8cb8a 100644 --- a/crates/core/src/model/storage/converter.rs +++ b/crates/core/src/model/storage/converter.rs @@ -1,7 +1,7 @@ use super::model::*; use crate::model::graph::{CodeGraphInner, FileEntry}; use crate::model::{EmptyMetadata, GraphNode, InternedLocation, NodeMetadata}; -use crate::plugin::NodeAdapter; +use crate::plugin::NodeMetadataCodec; use lasso::{Key, Spur, ThreadedRodeo}; use naviscope_api::models::symbol::{FqnId, Symbol}; use petgraph::stable_graph::NodeIndex; @@ -9,21 +9,13 @@ use std::collections::HashMap; use std::path::Path; use std::sync::Arc; -/// Fallback adapter that uses empty metadata -struct DefaultNodeAdapter; -impl NodeAdapter for DefaultNodeAdapter { - fn render_display_node( - &self, - _node: &GraphNode, - _fqns: &dyn naviscope_api::models::symbol::FqnReader, - ) -> naviscope_api::models::DisplayGraphNode { - unimplemented!("DefaultNodeAdapter should not be used for rendering") - } - +/// Fallback codec that uses empty metadata. +struct DefaultNodeMetadataCodec; +impl NodeMetadataCodec for DefaultNodeMetadataCodec { fn encode_metadata( &self, _metadata: &dyn NodeMetadata, - _ctx: &mut dyn naviscope_plugin::StorageContext, + _ctx: &mut dyn naviscope_plugin::CodecContext, ) -> Vec { Vec::new() } @@ -31,7 +23,7 @@ impl NodeAdapter for DefaultNodeAdapter { fn decode_metadata( &self, _bytes: &[u8], - _ctx: &dyn naviscope_plugin::StorageContext, + _ctx: &dyn naviscope_plugin::CodecContext, ) -> Arc { Arc::new(EmptyMetadata) } @@ -46,7 +38,7 @@ impl crate::model::metadata::SymbolInterner for ReadOnlyStorageContext { } } -impl naviscope_plugin::StorageContext for ReadOnlyStorageContext { +impl naviscope_plugin::CodecContext for ReadOnlyStorageContext { fn interner(&mut self) -> &mut dyn naviscope_plugin::FqnInterner { unreachable!("Read-only context cannot intern") } @@ -69,7 +61,7 @@ impl naviscope_api::models::symbol::FqnReader for ReadOnlyStorageContext { } } -impl StorageContext for ReadOnlyStorageContext { +impl CodecContext for ReadOnlyStorageContext { fn intern_path(&mut self, _p: &Path) -> u32 { unreachable!("Read-only context") } @@ -89,12 +81,12 @@ impl StorageContext for ReadOnlyStorageContext { pub fn to_storage( inner: &CodeGraphInner, - get_plugin: impl Fn(&str) -> Option>, + get_codec: impl Fn(&str) -> Option>, ) -> StorageGraph { let rodeo_ref = inner.symbols.clone(); let mut ctx = GenericStorageContext { rodeo: rodeo_ref }; - let default_plugin = Arc::new(DefaultNodeAdapter); + let default_codec = Arc::new(DefaultNodeMetadataCodec); let mut node_id_map = HashMap::new(); let mut nodes = Vec::new(); @@ -105,8 +97,8 @@ pub fn to_storage( // Resolve language string for plugin lookup let lang_str = ctx.resolve_str(node.lang.0.into_usize() as u32).to_string(); - let plugin = get_plugin(&lang_str).unwrap_or_else(|| default_plugin.clone()); - let metadata = plugin.encode_metadata(&*node.metadata, &mut ctx); + let codec = get_codec(&lang_str).unwrap_or_else(|| default_codec.clone()); + let metadata = codec.encode_metadata(&*node.metadata, &mut ctx); nodes.push(StorageNode { id_sid: node.id.0, @@ -204,18 +196,18 @@ pub fn to_storage( pub fn from_storage( storage: StorageGraph, - get_plugin: impl Fn(&str) -> Option>, + get_codec: impl Fn(&str) -> Option>, ) -> CodeGraphInner { let mut topology = petgraph::stable_graph::StableDiGraph::new(); - let default_plugin = Arc::new(DefaultNodeAdapter); + let default_codec = Arc::new(DefaultNodeMetadataCodec); let rodeo = storage.fqns.rodeo.clone(); let ctx = ReadOnlyStorageContext(rodeo.clone()); for snode in &storage.nodes { let lang_str = ctx.resolve_str(snode.lang_sid).to_string(); - let plugin = get_plugin(&lang_str).unwrap_or_else(|| default_plugin.clone()); - let metadata = plugin.decode_metadata(&snode.metadata, &ctx); + let codec = get_codec(&lang_str).unwrap_or_else(|| default_codec.clone()); + let metadata = codec.decode_metadata(&snode.metadata, &ctx); let node = GraphNode { id: FqnId(snode.id_sid), diff --git a/crates/core/src/model/storage/model.rs b/crates/core/src/model/storage/model.rs index af6288a..e65aed5 100644 --- a/crates/core/src/model/storage/model.rs +++ b/crates/core/src/model/storage/model.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; use std::sync::Arc; /// Context for interning and resolving symbols during storage conversion. -pub trait StorageContext: crate::model::metadata::SymbolInterner { +pub trait CodecContext: crate::model::metadata::SymbolInterner { fn intern_path(&mut self, p: &std::path::Path) -> u32; fn resolve_str(&self, sid: u32) -> &str; fn resolve_path(&self, pid: u32) -> &std::path::Path; @@ -18,7 +18,7 @@ pub struct GenericStorageContext { pub rodeo: Arc, } -impl naviscope_plugin::StorageContext for GenericStorageContext { +impl naviscope_plugin::CodecContext for GenericStorageContext { fn interner(&mut self) -> &mut dyn FqnInterner { self } @@ -73,7 +73,7 @@ impl crate::model::metadata::SymbolInterner for GenericStorageContext { } } -impl StorageContext for GenericStorageContext { +impl CodecContext for GenericStorageContext { fn intern_path(&mut self, p: &std::path::Path) -> u32 { let s = p.to_string_lossy(); crate::model::metadata::SymbolInterner::intern_str(self, s.as_ref()) diff --git a/crates/core/src/plugin/mod.rs b/crates/core/src/plugin/mod.rs index 2aca216..20d5e1a 100644 --- a/crates/core/src/plugin/mod.rs +++ b/crates/core/src/plugin/mod.rs @@ -1,8 +1,5 @@ pub use naviscope_api::models::graph::DisplayGraphNode; pub use naviscope_plugin::{ - NamingConvention, NodeAdapter, PluginHandle, PluginInfo, PluginInstance, + BuildCaps, BuildContent, BuildParseResult, LanguageCaps, NamingConvention, NodeMetadataCodec, + NodePresenter, }; - -pub use naviscope_plugin::{BuildToolPlugin, LanguagePlugin}; - -pub use naviscope_plugin::{BuildContent, BuildParseResult}; diff --git a/crates/core/src/runtime/orchestrator.rs b/crates/core/src/runtime/orchestrator.rs index 7b84c08..bb4e49d 100644 --- a/crates/core/src/runtime/orchestrator.rs +++ b/crates/core/src/runtime/orchestrator.rs @@ -7,15 +7,16 @@ use crate::ingest::resolver::{IndexResolver, StubRequest, StubbingManager}; use crate::ingest::scanner::Scanner; use crate::model::CodeGraph; use crate::model::GraphOp; -use naviscope_plugin::{AssetDiscoverer, AssetIndexer, AssetSourceLocator, NamingConvention}; +use naviscope_plugin::{ + AssetDiscoverer, AssetEntry, AssetIndexer, AssetSource, AssetSourceLocator, BuildCaps, + LanguageCaps, NamingConvention, +}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::sync::RwLock; use xxhash_rust::xxh3::xxh3_64; -use crate::plugin::{BuildToolPlugin, LanguagePlugin}; - /// Naviscope indexing engine /// /// Manages the current version of the code graph using MVCC: @@ -32,9 +33,9 @@ pub struct NaviscopeEngine { /// Index storage path index_path: PathBuf, - /// Plugins - build_plugins: Arc>>, - lang_plugins: Arc>>, + /// Registered capabilities + build_caps: Arc>, + lang_caps: Arc>, /// Runtime registry: language name -> naming convention naming_conventions: Arc>>, @@ -54,26 +55,26 @@ pub struct NaviscopeEngine { pub struct NaviscopeEngineBuilder { project_root: PathBuf, - build_plugins: Vec>, - lang_plugins: Vec>, + build_caps: Vec, + lang_caps: Vec, } impl NaviscopeEngineBuilder { pub fn new(project_root: PathBuf) -> Self { Self { project_root, - build_plugins: Vec::new(), - lang_plugins: Vec::new(), + build_caps: Vec::new(), + lang_caps: Vec::new(), } } - pub fn with_language(mut self, plugin: Arc) -> Self { - self.lang_plugins.push(plugin); + pub fn with_language_caps(mut self, caps: LanguageCaps) -> Self { + self.lang_caps.push(caps); self } - pub fn with_build_tool(mut self, plugin: Arc) -> Self { - self.build_plugins.push(plugin); + pub fn with_build_caps(mut self, caps: BuildCaps) -> Self { + self.build_caps.push(caps); self } @@ -91,58 +92,58 @@ impl NaviscopeEngineBuilder { // Process naming conventions let mut conventions = HashMap::new(); - for plugin in &self.lang_plugins { - if let Some(nc) = plugin.get_naming_convention() { - conventions.insert(plugin.name().to_string(), nc); + for caps in &self.lang_caps { + if let Some(nc) = caps.presentation.naming_convention() { + conventions.insert(caps.language.to_string(), nc); } } // Collect asset indexers from language plugins let indexers: Vec> = self - .lang_plugins + .lang_caps .iter() - .filter_map(|p| p.asset_indexer()) + .filter_map(|c| c.asset.asset_indexer()) .collect(); // Collect asset discoverers from all plugins let mut discoverers: Vec> = Vec::new(); // From language plugins (e.g., JdkDiscoverer from Java) - for plugin in &self.lang_plugins { - if let Some(d) = plugin.global_asset_discoverer() { + for caps in &self.lang_caps { + if let Some(d) = caps.asset.global_asset_discoverer() { discoverers.push(d); } } // From build tool plugins (e.g., GradleCacheDiscoverer from Gradle) - for plugin in &self.build_plugins { - if let Some(d) = plugin.asset_discoverer() { + for caps in &self.build_caps { + if let Some(d) = caps.asset.global_asset_discoverer() { discoverers.push(d); } } // Collect asset source locators from all plugins let mut source_locators: Vec> = Vec::new(); - for plugin in &self.lang_plugins { - if let Some(locator) = plugin.asset_source_locator() { + for caps in &self.lang_caps { + if let Some(locator) = caps.asset.asset_source_locator() { source_locators.push(locator); } } - for plugin in &self.build_plugins { - if let Some(locator) = plugin.asset_source_locator() { + for caps in &self.build_caps { + if let Some(locator) = caps.asset.asset_source_locator() { source_locators.push(locator); } } // Project-local asset discoverers (optional hook) - for plugin in &self.lang_plugins { - if let Some(d) = plugin.project_asset_discoverer(&canonical_root) { + for caps in &self.lang_caps { + if let Some(d) = caps.asset.project_asset_discoverer(&canonical_root) { discoverers.push(d); } } - for plugin in &self.build_plugins { - if let Some(d) = plugin.project_asset_discoverer(&canonical_root) { + for caps in &self.build_caps { + if let Some(d) = caps.asset.project_asset_discoverer(&canonical_root) { discoverers.push(d); } } @@ -163,8 +164,8 @@ impl NaviscopeEngineBuilder { current: Arc::new(RwLock::new(Arc::new(CodeGraph::empty()))), project_root: canonical_root, index_path, - build_plugins: Arc::new(self.build_plugins), - lang_plugins: Arc::new(self.lang_plugins), + build_caps: Arc::new(self.build_caps), + lang_caps: Arc::new(self.lang_caps), naming_conventions: Arc::new(conventions), cancel_token: cancel_token.clone(), stub_tx, @@ -199,7 +200,7 @@ impl NaviscopeEngine { /// Get the index resolver configured with current plugins pub fn get_resolver(&self) -> IndexResolver { - IndexResolver::with_plugins((*self.build_plugins).clone(), (*self.lang_plugins).clone()) + IndexResolver::with_caps((*self.build_caps).clone(), (*self.lang_caps).clone()) .with_stubbing(StubbingManager::new(self.stub_tx.clone())) } @@ -257,12 +258,12 @@ impl NaviscopeEngine { /// Load index from disk pub async fn load(&self) -> Result { let path = self.index_path.clone(); - let lang_plugins = self.lang_plugins.clone(); - let build_plugins = self.build_plugins.clone(); + let lang_caps = self.lang_caps.clone(); + let build_caps = self.build_caps.clone(); // Load in blocking pool let graph_opt = tokio::task::spawn_blocking(move || { - Self::load_from_disk(&path, lang_plugins, build_plugins) + Self::load_from_disk(&path, lang_caps, build_caps) }) .await .map_err(|e| NaviscopeError::Internal(e.to_string()))??; @@ -281,11 +282,11 @@ impl NaviscopeEngine { pub async fn save(&self) -> Result<()> { let graph = self.snapshot().await; let path = self.index_path.clone(); - let lang_plugins = self.lang_plugins.clone(); - let build_plugins = self.build_plugins.clone(); + let lang_caps = self.lang_caps.clone(); + let build_caps = self.build_caps.clone(); tokio::task::spawn_blocking(move || { - Self::save_to_disk(&graph, &path, lang_plugins, build_plugins) + Self::save_to_disk(&graph, &path, lang_caps, build_caps) }) .await .map_err(|e| NaviscopeError::Internal(e.to_string()))? @@ -295,16 +296,16 @@ impl NaviscopeEngine { pub async fn rebuild(&self) -> Result<()> { let _ = self.scan_global_assets().await; let project_root = self.project_root.clone(); - let build_plugins = self.build_plugins.clone(); - let lang_plugins = self.lang_plugins.clone(); + let build_caps = self.build_caps.clone(); + let lang_caps = self.lang_caps.clone(); let global_routes = self.global_asset_routes(); let stub_tx = self.stub_tx.clone(); let (new_graph, stubs) = tokio::task::spawn_blocking(move || { Self::build_index( &project_root, - build_plugins, - lang_plugins, + build_caps, + lang_caps, stub_tx, global_routes, ) @@ -338,8 +339,8 @@ impl NaviscopeEngine { pub async fn update_files(&self, files: Vec) -> Result<()> { let _ = self.scan_global_assets().await; let base_graph = self.snapshot().await; - let build_plugins = self.build_plugins.clone(); - let lang_plugins = self.lang_plugins.clone(); + let build_caps = self.build_caps.clone(); + let lang_caps = self.lang_caps.clone(); let global_routes = Arc::new(self.global_asset_routes()); // Prepare existing file metadata for change detection @@ -381,7 +382,7 @@ impl NaviscopeEngine { scan_results.into_iter().partition(|f| f.is_build()); let resolver = - IndexResolver::with_plugins((*build_plugins).clone(), (*lang_plugins).clone()) + IndexResolver::with_caps((*build_caps).clone(), (*lang_caps).clone()) .with_stubbing(StubbingManager::new(stub_tx.clone())); // 2. Phase 1: Heavy Build Resolution (Global Context) @@ -417,9 +418,9 @@ impl NaviscopeEngine { let mut builder = base_graph.to_builder(); // Register naming conventions - for plugin in lang_plugins.iter() { - if let Some(nc) = plugin.get_naming_convention() { - builder.naming_conventions.insert(plugin.name(), nc); + for caps in lang_caps.iter() { + if let Some(nc) = caps.presentation.naming_convention() { + builder.naming_conventions.insert(caps.language.clone(), nc); } } @@ -540,7 +541,7 @@ impl NaviscopeEngine { stub_cache: Arc, ) { let current = self.current.clone(); - let lang_plugins = self.lang_plugins.clone(); + let lang_caps = self.lang_caps.clone(); let naming_conventions = self.naming_conventions.clone(); tokio::spawn(async move { @@ -588,41 +589,32 @@ impl NaviscopeEngine { } // If not in cache, generate stub - let ext = asset_path - .extension() - .and_then(|e| e.to_str()) - .unwrap_or("") - .to_lowercase(); - - // Also check if file_name is "modules" (JImage without extension) - let file_name = - asset_path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - let is_jimage = file_name == "modules"; - - for plugin in lang_plugins.iter() { - let can_handle = plugin.can_handle_external_asset(&ext); - let jimage_match = is_jimage && plugin.name().as_str() == "java"; - - if can_handle || jimage_match { - if let Some(external) = plugin.external_resolver() { - match external.generate_stub(&req.fqn, &asset_path) { - Ok(stub) => { - // Store in cache for future use - if let Some(ref key) = asset_key { - stub_cache.store(key, &stub); - tracing::trace!("Cached stub for {}", req.fqn); - } - ops.push(GraphOp::AddNode { data: Some(stub) }); - break; - } - Err(e) => { - tracing::debug!( - "Failed to generate stub for {}: {}", - req.fqn, - e - ); - } + for caps in lang_caps.iter() { + let Some(generator) = caps.asset.stub_generator() else { + continue; + }; + if !generator.can_generate(&asset_path) { + continue; + } + + let entry = + AssetEntry::new(asset_path.clone(), AssetSource::Unknown); + match generator.generate(&req.fqn, &entry) { + Ok(stub) => { + // Store in cache for future use + if let Some(ref key) = asset_key { + stub_cache.store(key, &stub); + tracing::trace!("Cached stub for {}", req.fqn); } + ops.push(GraphOp::AddNode { data: Some(stub) }); + break; + } + Err(e) => { + tracing::debug!( + "Failed to generate stub for {}: {}", + req.fqn, + e + ); } } } @@ -691,8 +683,8 @@ impl NaviscopeEngine { fn load_from_disk( path: &Path, - lang_plugins: Arc>>, - build_plugins: Arc>>, + lang_caps: Arc>, + build_caps: Arc>, ) -> Result> { if !path.exists() { return Ok(None); @@ -700,21 +692,21 @@ impl NaviscopeEngine { let bytes = std::fs::read(path)?; - let get_plugin = |lang: &str| -> Option> { - for p in lang_plugins.iter() { - if p.name().as_str() == lang { - return p.get_node_adapter(); + let get_codec = |lang: &str| -> Option> { + for caps in lang_caps.iter() { + if caps.language.as_str() == lang { + return caps.metadata_codec.metadata_codec(); } } - for p in build_plugins.iter() { - if p.name().as_str() == lang { - return p.get_node_adapter(); + for caps in build_caps.iter() { + if caps.build_tool.as_str() == lang { + return caps.metadata_codec.metadata_codec(); } } None }; - match CodeGraph::deserialize(&bytes, get_plugin) { + match CodeGraph::deserialize(&bytes, get_codec) { Ok(graph) => { if graph.version() != crate::model::graph::CURRENT_VERSION { tracing::warn!( @@ -744,30 +736,30 @@ impl NaviscopeEngine { fn save_to_disk( graph: &CodeGraph, path: &Path, - lang_plugins: Arc>>, - build_plugins: Arc>>, + lang_caps: Arc>, + build_caps: Arc>, ) -> Result<()> { // Ensure directory exists if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - let get_plugin = |lang: &str| -> Option> { - for p in lang_plugins.iter() { - if p.name().as_str() == lang { - return p.get_node_adapter(); + let get_codec = |lang: &str| -> Option> { + for caps in lang_caps.iter() { + if caps.language.as_str() == lang { + return caps.metadata_codec.metadata_codec(); } } - for p in build_plugins.iter() { - if p.name().as_str() == lang { - return p.get_node_adapter(); + for caps in build_caps.iter() { + if caps.build_tool.as_str() == lang { + return caps.metadata_codec.metadata_codec(); } } None }; // Serialize the graph - let bytes = graph.serialize(get_plugin)?; + let bytes = graph.serialize(get_codec)?; // Write to file atomically (write to temp, then rename) let temp_path = path.with_extension("tmp"); @@ -781,8 +773,8 @@ impl NaviscopeEngine { fn build_index( project_root: &Path, - build_plugins: Arc>>, - lang_plugins: Arc>>, + build_caps: Arc>, + lang_caps: Arc>, stub_tx: tokio::sync::mpsc::UnboundedSender, global_routes: HashMap>, ) -> Result<(CodeGraph, Vec)> { @@ -791,9 +783,8 @@ impl NaviscopeEngine { Scanner::scan_and_parse(project_root, &std::collections::HashMap::new()); // Resolve - let resolver = - IndexResolver::with_plugins((*build_plugins).clone(), (*lang_plugins).clone()) - .with_stubbing(StubbingManager::new(stub_tx)); + let resolver = IndexResolver::with_caps((*build_caps).clone(), (*lang_caps).clone()) + .with_stubbing(StubbingManager::new(stub_tx)); // resolve() now returns both ops and the filled ProjectContext let (ops, _project_context) = resolver.resolve(parse_results)?; @@ -802,9 +793,9 @@ impl NaviscopeEngine { let mut builder = CodeGraphBuilder::new(); // Register naming conventions - for plugin in lang_plugins.iter() { - if let Some(nc) = plugin.get_naming_convention() { - builder.naming_conventions.insert(plugin.name(), nc); + for caps in lang_caps.iter() { + if let Some(nc) = caps.presentation.naming_convention() { + builder.naming_conventions.insert(caps.language.clone(), nc); } } From 186e94fd1f50f1c3e2c583611a249c0639c86ada Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Thu, 12 Feb 2026 04:08:13 +0800 Subject: [PATCH 14/49] refactor(lang-java): provide java capability composition --- crates/lang-java/src/lib.rs | 178 ++++++++++++++---- crates/lang-java/src/lsp/mod.rs | 21 +-- crates/lang-java/src/lsp/type_system.rs | 4 +- crates/lang-java/src/resolver/external/mod.rs | 28 ++- crates/lang-java/src/resolver/lang.rs | 6 +- crates/lang-java/src/resolver/semantic.rs | 7 +- 6 files changed, 179 insertions(+), 65 deletions(-) diff --git a/crates/lang-java/src/lib.rs b/crates/lang-java/src/lib.rs index 6a678c7..fe3d7a3 100644 --- a/crates/lang-java/src/lib.rs +++ b/crates/lang-java/src/lib.rs @@ -15,8 +15,11 @@ use naviscope_api::models::graph::{EmptyMetadata, GraphNode, NodeKind}; use naviscope_api::models::symbol::{FqnReader, Symbol}; use naviscope_api::models::{DisplayGraphNode, Language}; use naviscope_plugin::{ - AssetIndexer, AssetSourceLocator, GlobalParseResult, LangResolver, LanguagePlugin, LspService, - NamingConvention, NodeAdapter, PluginInstance, SemanticResolver, StorageContext, + AssetCap, AssetDiscoverer, AssetIndexer, AssetSourceLocator, CodecContext, + FileMatcherCap, LanguageCaps, LanguageParseCap, MetadataCodecCap, NodeMetadataCodec, + NodePresenter, PresentationCap, ProjectContext, ReferenceCheckService, SemanticCap, + SourceIndexCap, SymbolQueryService, SymbolResolveService, LspSyntaxService, NamingConvention, + ParsedFile, ResolvedUnit, }; use std::path::Path; use std::sync::Arc; @@ -28,7 +31,7 @@ pub struct JavaPlugin { type_system: Arc, } -impl NodeAdapter for JavaPlugin { +impl NodePresenter for JavaPlugin { fn render_display_node(&self, node: &GraphNode, fqns: &dyn FqnReader) -> DisplayGraphNode { let mut display = DisplayGraphNode { id: crate::naming::JavaNamingConvention::default().render_fqn(node.id, fqns), @@ -206,10 +209,13 @@ impl NodeAdapter for JavaPlugin { display } +} + +impl NodeMetadataCodec for JavaPlugin { fn encode_metadata( &self, metadata: &dyn naviscope_api::models::graph::NodeMetadata, - _ctx: &mut dyn StorageContext, + _ctx: &mut dyn CodecContext, ) -> Vec { if let Some(java_meta) = metadata .as_any() @@ -229,7 +235,7 @@ impl NodeAdapter for JavaPlugin { fn decode_metadata( &self, bytes: &[u8], - _ctx: &dyn StorageContext, + _ctx: &dyn CodecContext, ) -> Arc { if let Ok(element) = rmp_serde::from_slice::(bytes) { Arc::new(element) @@ -260,58 +266,115 @@ impl JavaPlugin { } } -impl PluginInstance for JavaPlugin { - fn get_naming_convention(&self) -> Option> { - Some(Arc::new(crate::naming::JavaNamingConvention::default())) +impl FileMatcherCap for JavaPlugin { + fn supports_path(&self, path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .map(|ext| ext.eq_ignore_ascii_case("java")) + .unwrap_or(false) } +} - fn get_node_adapter(&self) -> Option> { - Some(Arc::new(self.clone())) +impl LanguageParseCap for JavaPlugin { + fn parse_language_file( + &self, + source: &str, + path: &Path, + ) -> std::result::Result { + self.parser.parse_file(source, Some(path)) } } -impl LanguagePlugin for JavaPlugin { - fn name(&self) -> Language { - Language::JAVA +impl SymbolResolveService for JavaPlugin { + fn resolve_at( + &self, + tree: &tree_sitter::Tree, + source: &str, + line: usize, + byte_col: usize, + index: &dyn naviscope_plugin::CodeGraph, + ) -> Option { + self.resolver.resolve_at(tree, source, line, byte_col, index) } +} - fn supported_extensions(&self) -> &[&str] { - &["java"] +impl SymbolQueryService for JavaPlugin { + fn find_matches( + &self, + index: &dyn naviscope_plugin::CodeGraph, + res: &naviscope_api::models::SymbolResolution, + ) -> Vec { + self.resolver.find_matches(index, res) } - fn parse_file( + fn resolve_type_of( &self, - source: &str, - path: &Path, - ) -> std::result::Result> { - self.parser.parse_file(source, Some(path)) + index: &dyn naviscope_plugin::CodeGraph, + res: &naviscope_api::models::SymbolResolution, + ) -> Vec { + self.resolver.resolve_type_of(index, res) } - fn resolver(&self) -> Arc { - self.resolver.clone() + fn find_implementations( + &self, + index: &dyn naviscope_plugin::CodeGraph, + res: &naviscope_api::models::SymbolResolution, + ) -> Vec { + self.resolver.find_implementations(index, res) } +} - fn type_system(&self) -> Arc { - self.type_system.clone() +impl LspSyntaxService for JavaPlugin { + fn parse(&self, source: &str, old_tree: Option<&tree_sitter::Tree>) -> Option { + crate::lsp::JavaLspService::new(self.parser.clone()).parse(source, old_tree) } - fn lang_resolver(&self) -> Arc { - self.resolver.clone() + fn extract_symbols( + &self, + tree: &tree_sitter::Tree, + source: &str, + ) -> Vec { + crate::lsp::JavaLspService::new(self.parser.clone()).extract_symbols(tree, source) } - fn lsp_service(&self) -> Arc { - Arc::new(crate::lsp::JavaLspService::new(self.parser.clone())) + fn find_occurrences( + &self, + source: &str, + tree: &tree_sitter::Tree, + target: &naviscope_api::models::SymbolResolution, + ) -> Vec { + crate::lsp::JavaLspService::new(self.parser.clone()) + .find_occurrences(source, tree, target) } +} - fn external_resolver(&self) -> Option> { - Some(Arc::new(crate::resolver::external::JavaExternalResolver)) +impl ReferenceCheckService for JavaPlugin { + fn is_reference_to( + &self, + graph: &dyn naviscope_plugin::CodeGraph, + candidate: &naviscope_api::models::SymbolResolution, + target: &naviscope_api::models::SymbolResolution, + ) -> bool { + self.type_system.is_reference_to(graph, candidate, target) } - fn can_handle_external_asset(&self, ext: &str) -> bool { - ext == "jar" || ext == "jmod" || ext == "class" + fn is_subtype(&self, graph: &dyn naviscope_plugin::CodeGraph, sub: &str, sup: &str) -> bool { + self.type_system.is_subtype(graph, sub, sup) } +} + +impl SourceIndexCap for JavaPlugin { + fn compile_source( + &self, + file: &ParsedFile, + context: &ProjectContext, + ) -> std::result::Result { + self.resolver.compile_source(file, context) + } +} - fn global_asset_discoverer(&self) -> Option> { +impl AssetCap for JavaPlugin { + fn global_asset_discoverer(&self) -> Option> { Some(Box::new(crate::discoverer::JdkDiscoverer::new())) } @@ -322,4 +385,53 @@ impl LanguagePlugin for JavaPlugin { fn asset_source_locator(&self) -> Option> { Some(Arc::new(crate::resolver::external::JavaExternalResolver)) } + + fn stub_generator(&self) -> Option> { + Some(Arc::new(crate::resolver::external::JavaExternalResolver)) + } +} + +impl PresentationCap for JavaPlugin { + fn naming_convention(&self) -> Option> { + Some(Arc::new(crate::naming::JavaNamingConvention::default())) + } + + fn node_presenter(&self) -> Option> { + Some(Arc::new(self.clone())) + } + + fn symbol_kind(&self, kind: &NodeKind) -> lsp_types::SymbolKind { + use lsp_types::SymbolKind; + match kind { + 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, + NodeKind::Package => SymbolKind::PACKAGE, + _ => SymbolKind::VARIABLE, + } + } +} + +impl MetadataCodecCap for JavaPlugin { + fn metadata_codec(&self) -> Option> { + Some(Arc::new(self.clone())) + } +} + +pub fn java_caps() -> std::result::Result> { + let plugin = Arc::new(JavaPlugin::new()?); + Ok(LanguageCaps { + language: Language::JAVA, + matcher: plugin.clone(), + parser: plugin.clone(), + semantic: plugin.clone() as Arc, + indexing: plugin.clone(), + asset: plugin.clone(), + presentation: plugin.clone(), + metadata_codec: plugin, + }) } diff --git a/crates/lang-java/src/lsp/mod.rs b/crates/lang-java/src/lsp/mod.rs index 535529a..afca47f 100644 --- a/crates/lang-java/src/lsp/mod.rs +++ b/crates/lang-java/src/lsp/mod.rs @@ -4,8 +4,8 @@ pub mod type_system; use crate::parser::JavaParser; use naviscope_api::models::SymbolResolution; -use naviscope_api::models::graph::{DisplayGraphNode, NodeKind}; -use naviscope_plugin::LspService; +use naviscope_api::models::graph::DisplayGraphNode; +use naviscope_plugin::LspSyntaxService; use std::sync::Arc; use tree_sitter::Tree; @@ -19,7 +19,7 @@ impl JavaLspService { } } -impl LspService for JavaLspService { +impl LspSyntaxService for JavaLspService { fn parse(&self, source: &str, old_tree: Option<&Tree>) -> Option { self.parser.parse(source, old_tree) } @@ -28,21 +28,6 @@ impl LspService for JavaLspService { symbols::extract_symbols(&self.parser, tree, source) } - fn symbol_kind(&self, kind: &NodeKind) -> lsp_types::SymbolKind { - use lsp_types::SymbolKind; - match kind { - 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, - NodeKind::Package => SymbolKind::PACKAGE, - _ => SymbolKind::VARIABLE, - } - } - fn find_occurrences( &self, source: &str, diff --git a/crates/lang-java/src/lsp/type_system.rs b/crates/lang-java/src/lsp/type_system.rs index 3be6b09..f8d9072 100644 --- a/crates/lang-java/src/lsp/type_system.rs +++ b/crates/lang-java/src/lsp/type_system.rs @@ -1,6 +1,6 @@ use naviscope_api::models::SymbolResolution; use naviscope_plugin::graph::CodeGraph; -use naviscope_plugin::type_system::TypeSystem; +use naviscope_plugin::ReferenceCheckService; pub struct JavaTypeSystem; @@ -10,7 +10,7 @@ impl JavaTypeSystem { } } -impl TypeSystem for JavaTypeSystem { +impl ReferenceCheckService for JavaTypeSystem { fn is_reference_to( &self, graph: &dyn CodeGraph, diff --git a/crates/lang-java/src/resolver/external/mod.rs b/crates/lang-java/src/resolver/external/mod.rs index 46cde85..ced6d3c 100644 --- a/crates/lang-java/src/resolver/external/mod.rs +++ b/crates/lang-java/src/resolver/external/mod.rs @@ -1,6 +1,6 @@ use naviscope_plugin::{ - AssetEntry, AssetIndexer, AssetSource, AssetSourceLocator, ExternalResolver, GlobalParseResult, - IndexNode, + AssetEntry, AssetIndexer, AssetSource, AssetSourceLocator, GlobalParseResult, IndexNode, + StubGenerator, }; use ristretto_jimage::Image; use std::collections::HashSet; @@ -64,8 +64,8 @@ impl JavaExternalResolver { } } -impl ExternalResolver for JavaExternalResolver { - fn index_asset( +impl JavaExternalResolver { + pub fn index_asset( &self, asset: &Path, ) -> std::result::Result, Box> { @@ -101,7 +101,7 @@ impl ExternalResolver for JavaExternalResolver { Ok(result) } - fn generate_stub( + pub fn generate_stub( &self, fqn: &str, asset: &Path, @@ -305,7 +305,7 @@ impl ExternalResolver for JavaExternalResolver { Err(format!("Member {} not found in class {}", member_name, current_fqn).into()) } - fn resolve_source( + pub fn resolve_source( &self, _fqn: &str, _source_asset: &Path, @@ -330,7 +330,21 @@ impl AssetIndexer for JavaExternalResolver { &self, asset: &Path, ) -> std::result::Result, Box> { - ExternalResolver::index_asset(self, asset) + self.index_asset(asset) + } +} + +impl StubGenerator for JavaExternalResolver { + fn can_generate(&self, asset: &Path) -> bool { + self.can_index(asset) + } + + fn generate( + &self, + fqn: &str, + entry: &AssetEntry, + ) -> std::result::Result> { + self.generate_stub(fqn, &entry.path) } } diff --git a/crates/lang-java/src/resolver/lang.rs b/crates/lang-java/src/resolver/lang.rs index 2e42a7e..f08b23b 100644 --- a/crates/lang-java/src/resolver/lang.rs +++ b/crates/lang-java/src/resolver/lang.rs @@ -6,12 +6,12 @@ use crate::resolver::context::ResolutionContext; use naviscope_api::models::graph::{EdgeType, GraphEdge, NodeKind}; use naviscope_api::models::symbol::{NodeId, SymbolResolution, TypeRef}; use naviscope_plugin::{ - GraphOp, IndexNode, LangResolver, ParsedContent, ParsedFile, ProjectContext, ResolvedUnit, + GraphOp, IndexNode, ParsedContent, ParsedFile, ProjectContext, ResolvedUnit, SourceIndexCap, }; use std::sync::Arc; -impl LangResolver for JavaResolver { - fn resolve( +impl SourceIndexCap for JavaResolver { + fn compile_source( &self, file: &ParsedFile, context: &ProjectContext, diff --git a/crates/lang-java/src/resolver/semantic.rs b/crates/lang-java/src/resolver/semantic.rs index 24562b1..6e355dd 100644 --- a/crates/lang-java/src/resolver/semantic.rs +++ b/crates/lang-java/src/resolver/semantic.rs @@ -5,10 +5,10 @@ use crate::resolver::context::ResolutionContext; use naviscope_api::models::graph::EdgeType; use naviscope_api::models::symbol::{FqnId, matches_intent}; use naviscope_api::models::{SymbolIntent, SymbolResolution, TypeRef}; -use naviscope_plugin::{CodeGraph, NamingConvention, SemanticResolver}; +use naviscope_plugin::{CodeGraph, NamingConvention, SymbolQueryService, SymbolResolveService}; use tree_sitter::Tree; -impl SemanticResolver for JavaResolver { +impl SymbolResolveService for JavaResolver { fn resolve_at( &self, tree: &Tree, @@ -34,6 +34,9 @@ impl SemanticResolver for JavaResolver { self.resolve_symbol_internal(&context) } +} + +impl SymbolQueryService for JavaResolver { fn find_matches(&self, index: &dyn CodeGraph, resolution: &SymbolResolution) -> Vec { match resolution { SymbolResolution::Local(_, _) => vec![], From b5e8b0a6173870a4934b94250d2b09b51aad9bb2 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Thu, 12 Feb 2026 04:08:19 +0800 Subject: [PATCH 15/49] refactor(lang-gradle): provide gradle capability composition --- Cargo.lock | 1 + crates/lang-gradle/Cargo.toml | 1 + crates/lang-gradle/src/lib.rs | 88 ++++++++++++++++++++++-------- crates/lang-gradle/src/resolver.rs | 10 ++-- 4 files changed, 72 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9e5add7..9b99797 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1521,6 +1521,7 @@ version = "0.5.5" dependencies = [ "dirs", "lasso", + "lsp-types", "naviscope-api", "naviscope-core", "naviscope-plugin", diff --git a/crates/lang-gradle/Cargo.toml b/crates/lang-gradle/Cargo.toml index c52e562..29d9f89 100644 --- a/crates/lang-gradle/Cargo.toml +++ b/crates/lang-gradle/Cargo.toml @@ -18,6 +18,7 @@ rmp-serde = { workspace = true } lasso = { workspace = true } dirs = { workspace = true } walkdir = { workspace = true } +lsp-types = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/lang-gradle/src/lib.rs b/crates/lang-gradle/src/lib.rs index 27120b6..a3a969b 100644 --- a/crates/lang-gradle/src/lib.rs +++ b/crates/lang-gradle/src/lib.rs @@ -10,16 +10,18 @@ use naviscope_api::models::BuildTool; use naviscope_api::models::graph::DisplayGraphNode; use naviscope_api::models::symbol::FqnReader; use naviscope_plugin::{ - BuildContent, BuildParseResult, BuildToolPlugin, StandardNamingConvention, NamingConvention, - NodeAdapter, PluginInstance, StorageContext, + AssetCap, AssetDiscoverer, BuildCaps, BuildContent, BuildIndexCap, BuildParseCap, + BuildParseResult, CodecContext, FileMatcherCap, MetadataCodecCap, NamingConvention, + NodeMetadataCodec, NodePresenter, PresentationCap, StandardNamingConvention, }; +use std::path::Path; use std::sync::Arc; pub struct GradlePlugin { resolver: Arc, } -impl NodeAdapter for GradlePlugin { +impl NodePresenter for GradlePlugin { fn render_display_node( &self, node: &naviscope_api::models::graph::GraphNode, @@ -51,10 +53,13 @@ impl NodeAdapter for GradlePlugin { display } +} + +impl NodeMetadataCodec for GradlePlugin { fn encode_metadata( &self, metadata: &dyn naviscope_api::models::graph::NodeMetadata, - _ctx: &mut dyn StorageContext, + _ctx: &mut dyn CodecContext, ) -> Vec { if let Some(gradle_meta) = metadata .as_any() @@ -69,7 +74,7 @@ impl NodeAdapter for GradlePlugin { fn decode_metadata( &self, bytes: &[u8], - _ctx: &dyn StorageContext, + _ctx: &dyn CodecContext, ) -> Arc { if let Ok(element) = rmp_serde::from_slice::(bytes) { Arc::new(element) @@ -87,24 +92,21 @@ impl GradlePlugin { } } -impl PluginInstance for GradlePlugin { - fn get_node_adapter(&self) -> Option> { - Some(Arc::new(Self::new())) +impl FileMatcherCap for GradlePlugin { + fn supports_path(&self, path: &Path) -> bool { + path.file_name() + .and_then(|n| n.to_str()) + .map(|file_name| { + file_name == "build.gradle" + || file_name == "build.gradle.kts" + || file_name == "settings.gradle" + || file_name == "settings.gradle.kts" + }) + .unwrap_or(false) } } -impl BuildToolPlugin for GradlePlugin { - fn name(&self) -> BuildTool { - BuildTool::GRADLE - } - - fn recognize(&self, file_name: &str) -> bool { - file_name == "build.gradle" - || file_name == "build.gradle.kts" - || file_name == "settings.gradle" - || file_name == "settings.gradle.kts" - } - +impl BuildParseCap for GradlePlugin { fn parse_build_file( &self, source: &str, @@ -131,11 +133,51 @@ impl BuildToolPlugin for GradlePlugin { } } - fn build_resolver(&self) -> Arc { - self.resolver.clone() +} + +impl BuildIndexCap for GradlePlugin { + fn compile_build( + &self, + files: &[&naviscope_plugin::ParsedFile], + ) -> Result<(naviscope_plugin::ResolvedUnit, naviscope_plugin::ProjectContext), naviscope_plugin::BoxError> { + self.resolver.compile_build(files) } +} - fn asset_discoverer(&self) -> Option> { +impl AssetCap for GradlePlugin { + fn global_asset_discoverer(&self) -> Option> { Some(Box::new(crate::discoverer::GradleCacheDiscoverer::new())) } } + +impl PresentationCap for GradlePlugin { + fn node_presenter(&self) -> Option> { + Some(Arc::new(Self::new())) + } + + fn symbol_kind( + &self, + _kind: &naviscope_api::models::graph::NodeKind, + ) -> lsp_types::SymbolKind { + lsp_types::SymbolKind::MODULE + } +} + +impl MetadataCodecCap for GradlePlugin { + fn metadata_codec(&self) -> Option> { + Some(Arc::new(Self::new())) + } +} + +pub fn gradle_caps() -> BuildCaps { + let plugin = Arc::new(GradlePlugin::new()); + BuildCaps { + build_tool: BuildTool::GRADLE, + matcher: plugin.clone(), + parser: plugin.clone(), + indexing: plugin.clone(), + asset: plugin.clone(), + presentation: plugin.clone(), + metadata_codec: plugin, + } +} diff --git a/crates/lang-gradle/src/resolver.rs b/crates/lang-gradle/src/resolver.rs index bc8bcf9..80310a1 100644 --- a/crates/lang-gradle/src/resolver.rs +++ b/crates/lang-gradle/src/resolver.rs @@ -3,7 +3,7 @@ use naviscope_api::models::graph::{ }; use naviscope_api::models::symbol::{NodeId, Range}; use naviscope_plugin::{ - BuildResolver, IndexNode, ParsedContent, ParsedFile, ProjectContext, ResolvedUnit, + BuildIndexCap, IndexNode, ParsedContent, ParsedFile, ProjectContext, ResolvedUnit, }; use std::collections::HashMap; use std::path::{Path, PathBuf}; @@ -22,8 +22,8 @@ impl GradleResolver { } } -impl BuildResolver for GradleResolver { - fn resolve( +impl BuildIndexCap for GradleResolver { + fn compile_build( &self, files: &[&ParsedFile], ) -> std::result::Result<(ResolvedUnit, ProjectContext), Box> @@ -364,7 +364,7 @@ struct ModuleData<'a> { #[cfg(test)] mod tests { use super::*; - use naviscope_plugin::{GraphOp, SourceFile}; + use naviscope_plugin::{BuildIndexCap, GraphOp, SourceFile}; fn create_mock_file(path: &str, content: ParsedContent) -> ParsedFile { ParsedFile { @@ -411,7 +411,7 @@ mod tests { ); let files = vec![&root_settings, &sub_project_build, &core_build]; - let (unit, _) = resolver.resolve(&files).unwrap(); + let (unit, _) = resolver.compile_build(&files).unwrap(); let edges: Vec<_> = unit .ops From d5695c6760bb54d882bd772d3d8596ac3155f689 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Thu, 12 Feb 2026 04:08:24 +0800 Subject: [PATCH 16/49] test(runtime): migrate registration and tests to capabilities --- crates/core/tests/async_stubbing.rs | 25 +- crates/core/tests/global_assets.rs | 6 +- crates/core/tests/indexing.rs | 115 ++-- crates/core/tests/semantic_traits.rs | 600 ++++++------------- crates/lang-java/tests/common/mod.rs | 25 +- crates/lang-java/tests/java_integration.rs | 2 +- crates/lang-java/tests/logic_goto_def.rs | 2 +- crates/lang-java/tests/logic_goto_impl.rs | 2 +- crates/lang-java/tests/logic_goto_ref.rs | 2 +- crates/lang-java/tests/logic_goto_type.rs | 2 +- crates/lang-java/tests/logic_hierarchy.rs | 14 +- crates/lang-java/tests/test_engine_facade.rs | 2 +- crates/runtime/src/lib.rs | 10 +- 13 files changed, 277 insertions(+), 530 deletions(-) diff --git a/crates/core/tests/async_stubbing.rs b/crates/core/tests/async_stubbing.rs index 2893a5d..8e8f290 100644 --- a/crates/core/tests/async_stubbing.rs +++ b/crates/core/tests/async_stubbing.rs @@ -4,8 +4,7 @@ use naviscope_api::models::graph::ResolutionStatus; use naviscope_core::ingest::resolver::StubbingManager; use naviscope_core::model::GraphOp; use naviscope_core::runtime::orchestrator::NaviscopeEngine; -use naviscope_java::JavaPlugin; -use naviscope_plugin::LanguagePlugin; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::mpsc; @@ -30,17 +29,21 @@ async fn test_stubbing_manager_sends_requests() { /// Test that JavaPlugin correctly reports external asset handling #[test] fn test_java_plugin_handles_external_assets() { - let plugin = JavaPlugin::new().expect("Failed to create JavaPlugin"); + let java_caps = naviscope_java::java_caps().expect("Failed to create Java caps"); + let generator = java_caps + .asset + .stub_generator() + .expect("Java should provide a stub generator"); // Java plugin should handle these - assert!(plugin.can_handle_external_asset("jar")); - assert!(plugin.can_handle_external_asset("jmod")); - assert!(plugin.can_handle_external_asset("class")); + assert!(generator.can_generate(Path::new("test.jar"))); + assert!(generator.can_generate(Path::new("java.base.jmod"))); + assert!(generator.can_generate(Path::new("modules"))); // Java plugin should NOT handle these - assert!(!plugin.can_handle_external_asset("rs")); - assert!(!plugin.can_handle_external_asset("py")); - assert!(!plugin.can_handle_external_asset("go")); + assert!(!generator.can_generate(Path::new("foo.rs"))); + assert!(!generator.can_generate(Path::new("foo.py"))); + assert!(!generator.can_generate(Path::new("foo.go"))); } /// Test the full async stubbing flow with a real JAR file @@ -56,9 +59,9 @@ async fn test_async_stubbing_with_jar() { std::fs::create_dir_all(&temp_dir).unwrap(); // Create engine with Java plugin - let java_plugin = Arc::new(JavaPlugin::new().expect("Failed to create JavaPlugin")); + let java_caps = naviscope_java::java_caps().expect("Failed to create Java caps"); let engine = NaviscopeEngine::builder(temp_dir.clone()) - .with_language(java_plugin) + .with_language_caps(java_caps) .build(); // Find a real JAR file (use JDK's rt.jar or similar) diff --git a/crates/core/tests/global_assets.rs b/crates/core/tests/global_assets.rs index e455c16..f5db1f0 100644 --- a/crates/core/tests/global_assets.rs +++ b/crates/core/tests/global_assets.rs @@ -1,14 +1,12 @@ use naviscope_core::runtime::orchestrator::NaviscopeEngine; -use naviscope_java::JavaPlugin; -use std::sync::Arc; use tempfile::tempdir; #[tokio::test] async fn test_global_asset_scan_produces_routes() { let dir = tempdir().unwrap(); - let java_plugin = Arc::new(JavaPlugin::new().expect("Failed to create JavaPlugin")); + let java_caps = naviscope_java::java_caps().expect("Failed to create Java caps"); let engine = NaviscopeEngine::builder(dir.path().to_path_buf()) - .with_language(java_plugin) + .with_language_caps(java_caps) .build(); let scan = engine diff --git a/crates/core/tests/indexing.rs b/crates/core/tests/indexing.rs index 0850874..01dafbf 100644 --- a/crates/core/tests/indexing.rs +++ b/crates/core/tests/indexing.rs @@ -1,71 +1,44 @@ use naviscope_api::models::graph::{NodeKind, NodeSource, ResolutionStatus}; use naviscope_api::models::{BuildTool, EmptyMetadata, Range}; -use naviscope_core::ingest::parser::IndexNode; -use naviscope_core::ingest::resolver::BuildResolver; use naviscope_core::runtime::orchestrator::NaviscopeEngine; use naviscope_plugin::{ - BuildParseResult, BuildToolPlugin, NodeAdapter, ParsedFile, PluginInstance, ProjectContext, - ResolvedUnit, StorageContext, + AssetCap, BuildCaps, BuildContent, BuildIndexCap, BuildParseCap, FileMatcherCap, + MetadataCodecCap, ParsedFile, PresentationCap, ProjectContext, ResolvedUnit, }; use std::fs; +use std::path::Path; use std::sync::Arc; +use std::sync::Once; use tempfile::tempdir; -struct MockBuildPlugin; +struct MockBuildCap; -impl PluginInstance for MockBuildPlugin { - fn get_node_adapter(&self) -> Option> { - Some(Arc::new(MockBuildPlugin)) +impl FileMatcherCap for MockBuildCap { + fn supports_path(&self, path: &Path) -> bool { + path.file_name().and_then(|n| n.to_str()) == Some("build.gradle") } } -impl NodeAdapter for MockBuildPlugin { - fn render_display_node( - &self, - _node: &naviscope_api::models::GraphNode, - _rodeo: &dyn naviscope_api::models::symbol::FqnReader, - ) -> naviscope_api::models::DisplayGraphNode { - unimplemented!() - } - - fn decode_metadata( - &self, - _bytes: &[u8], - _ctx: &dyn StorageContext, - ) -> Arc { - Arc::new(EmptyMetadata) - } -} - -impl BuildToolPlugin for MockBuildPlugin { - fn name(&self) -> BuildTool { - BuildTool::GRADLE - } - fn recognize(&self, name: &str) -> bool { - name == "build.gradle" - } +impl BuildParseCap for MockBuildCap { fn parse_build_file( &self, _source: &str, - ) -> Result> { - unimplemented!() - } - fn build_resolver(&self) -> Arc { - Arc::new(MockBuildResolver) + ) -> Result { + Ok(naviscope_plugin::BuildParseResult { + content: BuildContent::Unparsed(String::new()), + }) } } -struct MockBuildResolver; - -impl BuildResolver for MockBuildResolver { - fn resolve( +impl BuildIndexCap for MockBuildCap { + fn compile_build( &self, files: &[&ParsedFile], - ) -> Result<(ResolvedUnit, ProjectContext), Box> { + ) -> Result<(ResolvedUnit, ProjectContext), naviscope_plugin::BoxError> { let mut unit = ResolvedUnit::new(); let mut context = ProjectContext::new(); if let Some(f) = files.first() { - unit.add_node(IndexNode { + unit.add_node(naviscope_plugin::IndexNode { id: naviscope_api::models::symbol::NodeId::Flat("project:test".to_string()), name: "test".to_string(), kind: NodeKind::Project, @@ -88,34 +61,56 @@ impl BuildResolver for MockBuildResolver { } } +impl AssetCap for MockBuildCap {} + +impl PresentationCap for MockBuildCap { + fn symbol_kind(&self, _kind: &NodeKind) -> lsp_types::SymbolKind { + lsp_types::SymbolKind::MODULE + } +} + +impl MetadataCodecCap for MockBuildCap {} + +fn mock_build_caps() -> BuildCaps { + let cap = Arc::new(MockBuildCap); + BuildCaps { + build_tool: BuildTool::GRADLE, + matcher: cap.clone(), + parser: cap.clone(), + indexing: cap.clone(), + asset: cap.clone(), + presentation: cap.clone(), + metadata_codec: cap, + } +} + +fn ensure_test_index_dir() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + let dir = std::env::temp_dir().join("naviscope_test_index_dir"); + std::fs::create_dir_all(&dir).unwrap(); + unsafe { + std::env::set_var("NAVISCOPE_INDEX_DIR", dir); + } + }); +} + #[tokio::test] async fn test_update_files_persistence_integration() { + ensure_test_index_dir(); let dir = tempdir().unwrap(); let build_gradle = dir.path().join("build.gradle"); fs::write(&build_gradle, "println 'hello'").unwrap(); let engine = NaviscopeEngine::builder(dir.path().to_path_buf()) - .with_build_tool(Arc::new(MockBuildPlugin)) // This requires MockBuildPlugin to implement Clone if not wrapped in Arc, but `with_build_tool` takes Arc so we need to construct it + .with_build_caps(mock_build_caps()) .build(); - // First index - engine - .update_files(vec![build_gradle.clone()]) - .await - .unwrap(); - + engine.update_files(vec![build_gradle.clone()]).await.unwrap(); let graph = engine.snapshot().await; + assert!(graph.node_count() > 0, "Project node should exist after first indexing"); - // Use a more direct check on nodes - let has_project = graph.node_count() > 0; - assert!( - has_project, - "Project node should exist after first indexing" - ); - - // Second index (incremental update on the same file) engine.update_files(vec![build_gradle]).await.unwrap(); - let graph = engine.snapshot().await; assert!( graph.node_count() > 0, diff --git a/crates/core/tests/semantic_traits.rs b/crates/core/tests/semantic_traits.rs index 0ee01b3..9d454c0 100644 --- a/crates/core/tests/semantic_traits.rs +++ b/crates/core/tests/semantic_traits.rs @@ -1,235 +1,91 @@ use naviscope_api::models::{ - DisplayGraphNode, DisplaySymbolLocation, Language, NodeKind, NodeSource, Range, ReferenceQuery, - SymbolQuery, SymbolResolution, -}; -use naviscope_api::semantic::{ - CallHierarchyAnalyzer, ReferenceAnalyzer, SymbolInfoProvider, SymbolNavigator, + DisplayGraphNode, DisplaySymbolLocation, Language, NodeKind, NodeSource, Range, SymbolQuery, + SymbolResolution, }; +use naviscope_api::semantic::{SymbolInfoProvider, SymbolNavigator}; use naviscope_core::facade::EngineHandle; -// naviscope_core imports removed in favor of naviscope_plugin use naviscope_core::runtime::orchestrator::NaviscopeEngine as CoreEngine; use naviscope_plugin::{ - GlobalParseResult, LangResolver, LanguagePlugin, LspService, NamingConvention, NodeAdapter, - ParsedFile, PluginInstance, ProjectContext, ResolvedUnit, SemanticResolver, StorageContext, + AssetCap, CodecContext, FileMatcherCap, GlobalParseResult, LanguageCaps, LanguageParseCap, + LspSyntaxService, MetadataCodecCap, NamingConvention, NodeMetadataCodec, NodePresenter, + ParsedContent, ParsedFile, PresentationCap, ProjectContext, ReferenceCheckService, ResolvedUnit, + SemanticCap, SourceIndexCap, StandardNamingConvention, SymbolQueryService, + SymbolResolveService, }; -use std::any::Any; use std::path::Path; use std::sync::Arc; +use std::sync::Once; use tree_sitter::Tree; -#[derive(Debug)] -struct MockMetadata { - id: String, -} - -impl naviscope_api::models::NodeMetadata for MockMetadata { - fn as_any(&self) -> &dyn Any { - self - } -} - -impl naviscope_core::model::IndexMetadata for MockMetadata { - fn as_any(&self) -> &dyn Any { - self - } - - fn intern( - &self, - _interner: &mut dyn naviscope_core::model::SymbolInterner, - ) -> Arc { - Arc::new(MockMetadata { - id: self.id.clone(), - }) - } -} - -struct MockPlugin { - resolver: Arc, - lsp_parser: Arc, - lang_resolver: Arc, -} - -impl NodeAdapter for MockPlugin { - fn render_display_node( - &self, - node: &naviscope_api::models::graph::GraphNode, - rodeo: &dyn naviscope_api::models::symbol::FqnReader, - ) -> DisplayGraphNode { - let display_id = naviscope_plugin::StandardNamingConvention.render_fqn(node.id, rodeo); - let mut display = DisplayGraphNode { - id: display_id, - name: rodeo.resolve_atom(node.name).to_string(), - kind: node.kind.clone(), - lang: rodeo.resolve_atom(node.lang).to_string(), - source: node.source.clone(), - status: node.status, - location: node.location.as_ref().map(|l| l.to_display(rodeo)), - detail: None, - signature: None, - modifiers: vec![], - children: None, - }; - - if let Some(m) = node.metadata.as_any().downcast_ref::() { - display.detail = Some(format!("Mock detail for {}", m.id)); - display.signature = Some(format!("Mock signature for {}", m.id)); - display.modifiers = vec!["mock".to_string()]; - } - - display - } - - fn encode_metadata( - &self, - metadata: &dyn naviscope_api::models::graph::NodeMetadata, - _ctx: &mut dyn StorageContext, - ) -> Vec { - if let Some(m) = metadata.as_any().downcast_ref::() { - m.id.as_bytes().to_vec() - } else { - Vec::new() - } - } - - fn decode_metadata( - &self, - bytes: &[u8], - _ctx: &dyn StorageContext, - ) -> Arc { - let id = String::from_utf8_lossy(bytes).to_string(); - Arc::new(MockMetadata { id }) - } -} +#[derive(Clone)] +struct MockCap; -impl PluginInstance for MockPlugin { - fn get_node_adapter(&self) -> Option> { - Some(Arc::new(self.clone_internal())) +impl FileMatcherCap for MockCap { + fn supports_path(&self, path: &Path) -> bool { + path.extension().and_then(|e| e.to_str()) == Some("mock") } } -impl LanguagePlugin for MockPlugin { - fn name(&self) -> Language { - Language::new("mock") - } - fn supported_extensions(&self) -> &[&str] { - &["mock"] - } - fn parse_file( +impl LanguageParseCap for MockCap { + fn parse_language_file( &self, - _source: &str, + source: &str, _path: &Path, - ) -> Result> { + ) -> Result { Ok(GlobalParseResult { package_name: None, imports: vec![], - output: naviscope_core::ingest::parser::ParseOutput { + output: naviscope_plugin::ParseOutput { nodes: vec![], relations: vec![], - identifiers: vec!["Callee".to_string()], - ..Default::default() + identifiers: vec!["Symbol".to_string()], }, - source: Some(_source.to_string()), + source: Some(source.to_string()), tree: None, }) } - fn resolver(&self) -> Arc { - self.resolver.clone() - } - fn lang_resolver(&self) -> Arc { - self.lang_resolver.clone() - } - fn type_system(&self) -> Arc { - Arc::new(naviscope_plugin::type_system::NoOpTypeSystem) - } - fn lsp_service(&self) -> Arc { - Arc::new(MockLspParserWrapper { - parser: self.lsp_parser.clone(), - }) - } -} - -impl MockPlugin { - fn clone_internal(&self) -> Self { - Self { - resolver: self.resolver.clone(), - lsp_parser: self.lsp_parser.clone(), - lang_resolver: self.lang_resolver.clone(), - } - } -} - -struct MockLspParserWrapper { - parser: Arc, } -impl LspService for MockLspParserWrapper { - fn parse(&self, source: &str, old_tree: Option<&Tree>) -> Option { - self.parser.parse(source, old_tree) - } - - fn extract_symbols(&self, tree: &Tree, source: &str) -> Vec { - // Symbols are already fully rendered - self.parser.extract_symbols(tree, source) - } - - fn symbol_kind(&self, kind: &naviscope_core::model::NodeKind) -> lsp_types::SymbolKind { - self.parser.symbol_kind(kind) - } - - fn find_occurrences( - &self, - source: &str, - tree: &Tree, - target: &naviscope_core::ingest::parser::SymbolResolution, - ) -> Vec { - self.parser.find_occurrences(source, tree, target) - } -} - -struct MockLangResolver { - nodes: std::sync::Mutex>, -} - -impl LangResolver for MockLangResolver { - fn resolve( +impl SourceIndexCap for MockCap { + fn compile_source( &self, file: &ParsedFile, _context: &ProjectContext, - ) -> Result> { + ) -> Result { let mut unit = ResolvedUnit::new(); - let identifiers = match &file.content { - naviscope_core::ingest::scanner::ParsedContent::Language(res) => { - res.output.identifiers.clone() - } - naviscope_core::ingest::scanner::ParsedContent::Unparsed(_src) => { - vec!["Callee".to_string()] - } - _ => vec!["Callee".to_string()], + ParsedContent::Language(res) => res.output.identifiers.clone(), + _ => vec!["Symbol".to_string()], }; - - if !identifiers.is_empty() { - unit.identifiers = identifiers.clone(); - unit.ops - .push(naviscope_core::model::GraphOp::UpdateIdentifiers { - path: file.file.path.clone().into(), - identifiers: unit.identifiers.clone(), - }); - } - - let nodes = self.nodes.lock().unwrap(); - for node in nodes.iter() { - unit.add_node(node.clone()); - } + unit.identifiers = identifiers.clone(); + unit.ops.push(naviscope_plugin::GraphOp::UpdateIdentifiers { + path: Arc::from(file.file.path.as_path()), + identifiers, + }); + unit.add_node(naviscope_plugin::IndexNode { + id: naviscope_api::models::symbol::NodeId::Flat("test::Symbol".to_string()), + name: "Symbol".to_string(), + kind: NodeKind::Class, + lang: "mock".to_string(), + source: NodeSource::Project, + status: naviscope_api::models::graph::ResolutionStatus::Resolved, + location: Some(DisplaySymbolLocation { + path: file.path().to_string_lossy().to_string(), + range: Range { + start_line: 0, + start_col: 0, + end_line: 0, + end_col: 10, + }, + selection_range: None, + }), + metadata: Arc::new(naviscope_api::models::graph::EmptyMetadata), + }); Ok(unit) } } -struct MockResolver { - res_at: std::sync::Mutex>, -} - -impl SemanticResolver for MockResolver { +impl SymbolResolveService for MockCap { fn resolve_at( &self, _tree: &Tree, @@ -238,18 +94,21 @@ impl SemanticResolver for MockResolver { _byte_col: usize, _index: &dyn naviscope_plugin::CodeGraph, ) -> Option { - self.res_at.lock().unwrap().clone() + Some(SymbolResolution::Global("test::Symbol".to_string())) } +} +impl SymbolQueryService for MockCap { fn find_matches( &self, index: &dyn naviscope_plugin::CodeGraph, res: &SymbolResolution, ) -> Vec { - if let SymbolResolution::Global(id) = res { - return index.resolve_fqn(id.as_str()); + match res { + SymbolResolution::Global(fqn) => index.resolve_fqn(fqn), + SymbolResolution::Precise(fqn, _) => index.resolve_fqn(fqn), + SymbolResolution::Local(_, _) => vec![], } - vec![] } fn resolve_type_of( @@ -257,142 +116,163 @@ impl SemanticResolver for MockResolver { _index: &dyn naviscope_plugin::CodeGraph, _res: &SymbolResolution, ) -> Vec { - vec![SymbolResolution::Global("test::Type".to_string())] + vec![] } fn find_implementations( &self, - index: &dyn naviscope_plugin::CodeGraph, + _index: &dyn naviscope_plugin::CodeGraph, _res: &SymbolResolution, ) -> Vec { - index.resolve_fqn("test::Impl") + vec![] } } -struct MockLspParser; -impl LspService for MockLspParser { - fn parse(&self, source: &str, _old_tree: Option<&Tree>) -> Option { +impl LspSyntaxService for MockCap { + fn parse(&self, source: &str, old_tree: Option<&Tree>) -> Option { let mut parser = tree_sitter::Parser::new(); - parser - .set_language(&tree_sitter_java::LANGUAGE.into()) - .ok()?; - parser.parse(source, None) + parser.set_language(&tree_sitter_java::LANGUAGE.into()).ok()?; + parser.parse(source, old_tree) } + fn extract_symbols(&self, _tree: &Tree, _source: &str) -> Vec { vec![] } - fn symbol_kind(&self, _kind: &naviscope_core::model::NodeKind) -> lsp_types::SymbolKind { - lsp_types::SymbolKind::CLASS - } + fn find_occurrences( &self, _source: &str, _tree: &Tree, - target: &SymbolResolution, + _target: &SymbolResolution, ) -> Vec { - if let SymbolResolution::Global(id) = target { - if id == "test::Callee" { - return vec![Range { - start_line: 1, - start_col: 1, - end_line: 1, - end_col: 5, - }]; - } - } vec![] } } -fn setup_engine(temp_dir: &Path) -> (CoreEngine, Arc) { - let mock_resolver = Arc::new(MockResolver { - res_at: std::sync::Mutex::new(None), - }); - let mock_parser = Arc::new(MockLspParser); - let mock_lang_resolver = Arc::new(MockLangResolver { - nodes: std::sync::Mutex::new(vec![]), - }); - let plugin = Arc::new(MockPlugin { - resolver: mock_resolver, - lsp_parser: mock_parser, - lang_resolver: mock_lang_resolver, - }); +impl ReferenceCheckService for MockCap { + fn is_reference_to( + &self, + _graph: &dyn naviscope_plugin::CodeGraph, + candidate: &SymbolResolution, + target: &SymbolResolution, + ) -> bool { + candidate == target + } +} - let engine = CoreEngine::builder(temp_dir.to_path_buf()) - .with_language(plugin.clone()) - .build(); +impl AssetCap for MockCap {} - (engine, plugin) +impl NodePresenter for MockCap { + fn render_display_node( + &self, + node: &naviscope_api::models::graph::GraphNode, + fqns: &dyn naviscope_api::models::symbol::FqnReader, + ) -> DisplayGraphNode { + DisplayGraphNode { + id: StandardNamingConvention.render_fqn(node.id, fqns), + name: fqns.resolve_atom(node.name).to_string(), + kind: node.kind.clone(), + lang: fqns.resolve_atom(node.lang).to_string(), + source: node.source.clone(), + status: node.status, + location: node.location.as_ref().map(|l| l.to_display(fqns)), + detail: None, + signature: None, + modifiers: vec![], + children: None, + } + } } -#[tokio::test] -async fn test_symbol_navigator_queries() { - let temp_dir = std::env::temp_dir().join("naviscope_test_navigator_real"); - std::fs::create_dir_all(&temp_dir).ok(); +impl NodeMetadataCodec for MockCap { + fn encode_metadata( + &self, + _metadata: &dyn naviscope_api::models::graph::NodeMetadata, + _ctx: &mut dyn CodecContext, + ) -> Vec { + Vec::new() + } - let (engine, plugin) = setup_engine(&temp_dir); + fn decode_metadata( + &self, + _bytes: &[u8], + _ctx: &dyn CodecContext, + ) -> Arc { + Arc::new(naviscope_api::models::graph::EmptyMetadata) + } +} - // Add a node to the mock plugin's resolver - { - let mut nodes = plugin.lang_resolver.nodes.lock().unwrap(); - nodes.push(naviscope_core::ingest::parser::IndexNode { - id: naviscope_api::models::symbol::NodeId::Flat("test::Symbol".to_string()), - name: "Symbol".to_string(), - kind: NodeKind::Class, - lang: "mock".to_string(), - source: NodeSource::Project, - status: naviscope_api::models::graph::ResolutionStatus::Resolved, - location: Some(DisplaySymbolLocation { - path: temp_dir.join("test.mock").to_string_lossy().to_string(), - range: Range { - start_line: 0, - start_col: 0, - end_line: 0, - end_col: 10, - }, - selection_range: None, - }), - metadata: Arc::new(MockMetadata { - id: "test::Symbol".to_string(), - }), - }); +impl PresentationCap for MockCap { + fn naming_convention(&self) -> Option> { + Some(Arc::new(StandardNamingConvention)) } - let test_file = temp_dir.join("test.mock"); - std::fs::write(&test_file, "mock content").unwrap(); + fn node_presenter(&self) -> Option> { + Some(Arc::new(self.clone())) + } - // Trigger update to populate graph - engine.update_files(vec![test_file.clone()]).await.unwrap(); + fn symbol_kind(&self, _kind: &NodeKind) -> lsp_types::SymbolKind { + lsp_types::SymbolKind::CLASS + } +} - let handle = EngineHandle::from_engine(Arc::new(engine)); +impl MetadataCodecCap for MockCap { + fn metadata_codec(&self) -> Option> { + Some(Arc::new(self.clone())) + } +} - // Test find_definitions - let query = SymbolQuery { +fn mock_caps() -> LanguageCaps { + let cap = Arc::new(MockCap); + LanguageCaps { language: Language::new("mock"), - resolution: SymbolResolution::Global("test::Symbol".to_string()), - }; + matcher: cap.clone(), + parser: cap.clone(), + semantic: cap.clone() as Arc, + indexing: cap.clone(), + asset: cap.clone(), + presentation: cap.clone(), + metadata_codec: cap, + } +} - let defs = handle.find_definitions(&query).await.unwrap(); - assert_eq!(defs.len(), 1); - assert_eq!(defs[0].path.as_os_str(), test_file.as_os_str()); +fn setup_engine(temp_dir: &Path) -> CoreEngine { + ensure_test_index_dir(); + CoreEngine::builder(temp_dir.to_path_buf()) + .with_language_caps(mock_caps()) + .build() +} + +fn ensure_test_index_dir() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + let dir = std::env::temp_dir().join("naviscope_test_index_dir"); + std::fs::create_dir_all(&dir).unwrap(); + unsafe { + std::env::set_var("NAVISCOPE_INDEX_DIR", dir); + } + }); } #[tokio::test] -async fn test_reference_analyzer() { - let temp_dir = std::env::temp_dir().join("naviscope_test_references"); +async fn test_symbol_navigator_queries() { + let temp_dir = std::env::temp_dir().join("naviscope_test_navigator_real"); std::fs::create_dir_all(&temp_dir).ok(); - let (engine, _) = setup_engine(&temp_dir); - let handle = EngineHandle::from_engine(Arc::new(engine)); + let engine = setup_engine(&temp_dir); + let test_file = temp_dir.join("test.mock"); + std::fs::write(&test_file, "class Symbol {}").unwrap(); + engine.update_files(vec![test_file.clone()]).await.unwrap(); - let query = ReferenceQuery { + let handle = EngineHandle::from_engine(Arc::new(engine)); + let query = SymbolQuery { language: Language::new("mock"), resolution: SymbolResolution::Global("test::Symbol".to_string()), - include_declaration: true, }; - let refs = handle.find_references(&query).await.unwrap(); - assert!(refs.is_empty()); + let defs = handle.find_definitions(&query).await.unwrap(); + assert_eq!(defs.len(), 1); + assert_eq!(defs[0].path.as_os_str(), test_file.as_os_str()); } #[tokio::test] @@ -400,11 +280,11 @@ async fn test_symbol_info_provider() { let temp_dir = std::env::temp_dir().join("naviscope_test_info"); std::fs::create_dir_all(&temp_dir).ok(); - let (engine, _) = setup_engine(&temp_dir); + let engine = setup_engine(&temp_dir); let handle = EngineHandle::from_engine(Arc::new(engine)); let test_file = temp_dir.join("test.mock"); - std::fs::write(&test_file, "mock content").unwrap(); + std::fs::write(&test_file, "class Symbol {}").unwrap(); let uri = format!("file://{}", test_file.display()); let lang = handle.get_language_for_document(&uri).await.unwrap(); @@ -413,141 +293,3 @@ async fn test_symbol_info_provider() { let symbols = handle.get_document_symbols(&uri).await.unwrap(); assert!(symbols.is_empty()); } - -#[tokio::test] -async fn test_call_hierarchy_analyzer() { - let temp_dir = std::env::temp_dir().join("naviscope_test_hierarchy"); - std::fs::create_dir_all(&temp_dir).ok(); - - let (engine, plugin) = setup_engine(&temp_dir); - - let test_file = temp_dir.join("test.mock"); - let test_file_path = test_file.to_string_lossy().to_string(); - - // Add caller and callee nodes - { - let mut nodes = plugin.lang_resolver.nodes.lock().unwrap(); - // Callee - nodes.push(naviscope_core::ingest::parser::IndexNode { - id: naviscope_api::models::symbol::NodeId::Flat("test::Callee".to_string()), - name: "Callee".to_string(), - kind: NodeKind::Method, - lang: "mock".to_string(), - source: NodeSource::Project, - status: naviscope_api::models::graph::ResolutionStatus::Resolved, - location: Some(DisplaySymbolLocation { - path: test_file_path.clone(), - range: Range { - start_line: 5, - start_col: 0, - end_line: 5, - end_col: 10, - }, - selection_range: None, - }), - metadata: Arc::new(MockMetadata { - id: "test::Callee".to_string(), - }), - }); - // Caller - nodes.push(naviscope_core::ingest::parser::IndexNode { - id: naviscope_api::models::symbol::NodeId::Flat("test::Caller".to_string()), - name: "Caller".to_string(), - kind: NodeKind::Method, - lang: "mock".to_string(), - source: NodeSource::Project, - status: naviscope_api::models::graph::ResolutionStatus::Resolved, - location: Some(DisplaySymbolLocation { - path: test_file_path.clone(), - range: Range { - start_line: 0, - start_col: 0, - end_line: 2, - end_col: 10, - }, - selection_range: None, - }), - metadata: Arc::new(MockMetadata { - id: "test::Caller".to_string(), - }), - }); - } - - std::fs::write(&test_file, "caller calls callee").unwrap(); - engine.update_files(vec![test_file.clone()]).await.unwrap(); - - // Set mock resolution for verification (needed by scan_file) - *plugin.resolver.res_at.lock().unwrap() = - Some(SymbolResolution::Global("test::Callee".to_string())); - - let handle = EngineHandle::from_engine(Arc::new(engine)); - - // 1. Test Incoming Calls (Who calls Callee?) - let incoming = handle.find_incoming_calls("test::Callee").await.unwrap(); - assert_eq!(incoming.len(), 1); - assert_eq!(incoming[0].from.id, "test::Caller"); - assert_eq!(incoming[0].from_ranges.len(), 1); - assert_eq!(incoming[0].from_ranges[0].start_line, 1); - - // 2. Test Outgoing Calls (Who does Caller call?) - let outgoing = handle.find_outgoing_calls("test::Caller").await.unwrap(); - assert!(!outgoing.is_empty()); - assert_eq!(outgoing[0].to.id, "test::Callee"); -} - -#[tokio::test] -async fn test_get_symbol_info() { - let temp_dir = std::env::temp_dir().join("naviscope_test_symbol_info_final"); - std::fs::create_dir_all(&temp_dir).ok(); - - let (engine, plugin) = setup_engine(&temp_dir); - - // Add a node to the mock plugin's resolver - { - let mut nodes = plugin.lang_resolver.nodes.lock().unwrap(); - nodes.push(naviscope_core::ingest::parser::IndexNode { - id: naviscope_api::models::symbol::NodeId::Flat("test::Symbol".to_string()), - name: "Symbol".to_string(), - kind: NodeKind::Class, - lang: "mock".to_string(), - source: NodeSource::Project, - status: naviscope_api::models::graph::ResolutionStatus::Resolved, - location: Some(DisplaySymbolLocation { - path: temp_dir.join("test.mock").to_string_lossy().to_string(), - range: Range { - start_line: 0, - start_col: 0, - end_line: 0, - end_col: 10, - }, - selection_range: None, - }), - metadata: Arc::new(MockMetadata { - id: "test::Symbol".to_string(), - }), - }); - } - - let test_file = temp_dir.join("test.mock"); - std::fs::write(&test_file, "mock content").unwrap(); - - // Trigger update to populate graph - engine.update_files(vec![test_file.clone()]).await.unwrap(); - - let handle = EngineHandle::from_engine(Arc::new(engine)); - - // Test get_symbol_info - let info = handle.get_symbol_info("test::Symbol").await.unwrap(); - assert!(info.is_some()); - let info = info.unwrap(); - assert_eq!(info.name, "Symbol"); - assert_eq!( - info.detail, - Some("Mock detail for test::Symbol".to_string()) - ); - assert_eq!( - info.signature, - Some("Mock signature for test::Symbol".to_string()) - ); - assert_eq!(info.lang, "mock"); -} diff --git a/crates/lang-java/tests/common/mod.rs b/crates/lang-java/tests/common/mod.rs index 794497d..4495c26 100644 --- a/crates/lang-java/tests/common/mod.rs +++ b/crates/lang-java/tests/common/mod.rs @@ -3,10 +3,11 @@ use naviscope_core::ingest::builder::CodeGraphBuilder; use naviscope_java::parser::JavaParser; use naviscope_java::resolver::JavaResolver; use naviscope_plugin::{ - GraphOp, LangResolver, ParsedContent, ParsedFile, ProjectContext, SourceFile, + GraphOp, ParsedContent, ParsedFile, ProjectContext, SourceFile, SourceIndexCap, }; use std::path::PathBuf; use std::sync::Arc; +use std::sync::Once; use tree_sitter::Parser; #[allow(dead_code)] @@ -41,7 +42,7 @@ pub fn setup_java_test_graph( all_parsed_files.push((parsed_file, content.to_string())); } - // Phase 2: Resolve (using JavaResolver's LangResolver implementation) + // Phase 2: Resolve (using JavaResolver source-index implementation) let resolver = JavaResolver::new(); let context = ProjectContext::new(); // Uses default V2 context @@ -51,7 +52,7 @@ pub fn setup_java_test_graph( let tree = ts_parser.parse(&content, None).unwrap(); // Use JavaResolver to get resolved unit - let unit = resolver.resolve(&pf, &context).unwrap(); + let unit = resolver.compile_source(&pf, &context).unwrap(); all_ops.extend(unit.ops); @@ -90,12 +91,11 @@ pub async fn setup_java_engine( temp_dir: &std::path::Path, files: Vec<(&str, &str)>, ) -> naviscope_core::facade::EngineHandle { + ensure_test_index_dir(); use naviscope_core::runtime::orchestrator::NaviscopeEngine as CoreEngine; - use naviscope_java::JavaPlugin; - - let java_plugin = JavaPlugin::new().expect("Failed to create JavaPlugin"); + let java_caps = naviscope_java::java_caps().expect("Failed to create Java caps"); let engine = CoreEngine::builder(temp_dir.to_path_buf()) - .with_language(Arc::new(java_plugin)) + .with_language_caps(java_caps) .build(); // Create files @@ -113,3 +113,14 @@ pub async fn setup_java_engine( naviscope_core::facade::EngineHandle::from_engine(Arc::new(engine)) } + +fn ensure_test_index_dir() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + let dir = std::env::temp_dir().join("naviscope_test_index_dir"); + std::fs::create_dir_all(&dir).unwrap(); + unsafe { + std::env::set_var("NAVISCOPE_INDEX_DIR", dir); + } + }); +} diff --git a/crates/lang-java/tests/java_integration.rs b/crates/lang-java/tests/java_integration.rs index bebcf36..918fa08 100644 --- a/crates/lang-java/tests/java_integration.rs +++ b/crates/lang-java/tests/java_integration.rs @@ -1,7 +1,7 @@ mod common; use common::{offset_to_point, setup_java_test_graph}; -use naviscope_core::ingest::resolver::SemanticResolver; +use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; use naviscope_java::resolver::JavaResolver; #[test] diff --git a/crates/lang-java/tests/logic_goto_def.rs b/crates/lang-java/tests/logic_goto_def.rs index b1c2efe..3dba5bf 100644 --- a/crates/lang-java/tests/logic_goto_def.rs +++ b/crates/lang-java/tests/logic_goto_def.rs @@ -3,7 +3,7 @@ mod common; use common::{offset_to_point, setup_java_test_graph}; use naviscope_core::features::CodeGraphLike; use naviscope_core::ingest::parser::SymbolResolution; -use naviscope_core::ingest::resolver::SemanticResolver; +use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; use naviscope_java::resolver::JavaResolver; #[test] diff --git a/crates/lang-java/tests/logic_goto_impl.rs b/crates/lang-java/tests/logic_goto_impl.rs index 5edd076..273ea0f 100644 --- a/crates/lang-java/tests/logic_goto_impl.rs +++ b/crates/lang-java/tests/logic_goto_impl.rs @@ -2,7 +2,7 @@ mod common; use common::{offset_to_point, setup_java_test_graph}; use naviscope_core::features::CodeGraphLike; -use naviscope_core::ingest::resolver::SemanticResolver; +use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; use naviscope_java::resolver::JavaResolver; #[test] diff --git a/crates/lang-java/tests/logic_goto_ref.rs b/crates/lang-java/tests/logic_goto_ref.rs index 6dc258e..5296a21 100644 --- a/crates/lang-java/tests/logic_goto_ref.rs +++ b/crates/lang-java/tests/logic_goto_ref.rs @@ -1,7 +1,7 @@ mod common; use common::{offset_to_point, setup_java_test_graph}; -use naviscope_core::ingest::resolver::SemanticResolver; +use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; use naviscope_java::resolver::JavaResolver; #[test] diff --git a/crates/lang-java/tests/logic_goto_type.rs b/crates/lang-java/tests/logic_goto_type.rs index 5d635b6..cb69434 100644 --- a/crates/lang-java/tests/logic_goto_type.rs +++ b/crates/lang-java/tests/logic_goto_type.rs @@ -2,7 +2,7 @@ mod common; use common::{offset_to_point, setup_java_test_graph}; use naviscope_core::features::CodeGraphLike; -use naviscope_core::ingest::resolver::SemanticResolver; +use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; use naviscope_java::resolver::JavaResolver; #[test] diff --git a/crates/lang-java/tests/logic_hierarchy.rs b/crates/lang-java/tests/logic_hierarchy.rs index 42e1119..508cae0 100644 --- a/crates/lang-java/tests/logic_hierarchy.rs +++ b/crates/lang-java/tests/logic_hierarchy.rs @@ -4,8 +4,8 @@ use common::{offset_to_point, setup_java_test_graph}; use naviscope_core::features::CodeGraphLike; use naviscope_core::features::discovery::DiscoveryEngine; use naviscope_core::ingest::parser::SymbolResolution; -use naviscope_core::ingest::resolver::SemanticResolver; -use naviscope_java::lsp::JavaLspService; +use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; +use naviscope_java::JavaPlugin; use naviscope_java::resolver::JavaResolver; #[test] @@ -42,10 +42,9 @@ fn test_call_hierarchy_incoming() { let abs_path = std::env::current_dir().unwrap().join("Test.java"); let uri = lsp_types::Url::from_file_path(&abs_path).unwrap(); - let java_ts = naviscope_java::lsp::type_system::JavaTypeSystem::new(); + let semantic = JavaPlugin::new().expect("failed to create java plugin"); for path in candidate_files { - let lsp_service = JavaLspService::new(std::sync::Arc::new(resolver.parser.clone())); - let locations = discovery.scan_file(&lsp_service, &java_ts, &resolver, content, &res, &uri); + let locations = discovery.scan_file(&semantic, content, &res, &uri); for loc in locations { if let Some(container_idx) = index.find_container_node_at( &path, @@ -175,9 +174,8 @@ fn test_call_hierarchy_recursion() { let abs_path = std::env::current_dir().unwrap().join("Test.java"); let uri = lsp_types::Url::from_file_path(&abs_path).unwrap(); - let java_ts = naviscope_java::lsp::type_system::JavaTypeSystem::new(); - let lsp_service = JavaLspService::new(std::sync::Arc::new(resolver.parser.clone())); - let locations = discovery.scan_file(&lsp_service, &java_ts, &resolver, content, &res, &uri); + let semantic = JavaPlugin::new().expect("failed to create java plugin"); + let locations = discovery.scan_file(&semantic, content, &res, &uri); for loc in locations { if let Some(c_idx) = index.find_container_node_at( &std::path::PathBuf::from("Test.java"), diff --git a/crates/lang-java/tests/test_engine_facade.rs b/crates/lang-java/tests/test_engine_facade.rs index 86df75c..a20eb09 100644 --- a/crates/lang-java/tests/test_engine_facade.rs +++ b/crates/lang-java/tests/test_engine_facade.rs @@ -85,7 +85,7 @@ public class App { assert!(impls[0].path.to_string_lossy().contains("Impl.java")); // 3. Find incoming calls to 'Impl#run' - // With TypeSystem integration, searching for an implementation should find + // With semantic reference checks, searching for an implementation should find // calls to the base method as well. let calls = handle .find_incoming_calls("com.example.Impl#run") diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs index b39ae6f..8f9adbb 100644 --- a/crates/runtime/src/lib.rs +++ b/crates/runtime/src/lib.rs @@ -10,12 +10,12 @@ use std::sync::Arc; pub fn build_default_engine(path: PathBuf) -> Arc { let mut builder = naviscope_core::runtime::orchestrator::NaviscopeEngine::builder(path); - // Register Build Tool Plugins - builder = builder.with_build_tool(Arc::new(naviscope_gradle::GradlePlugin::new())); + // Register Build Tool Caps + builder = builder.with_build_caps(naviscope_gradle::gradle_caps()); - // Register Language Plugins - builder = match naviscope_java::JavaPlugin::new() { - Ok(plugin) => builder.with_language(Arc::new(plugin)), + // Register Language Caps + builder = match naviscope_java::java_caps() { + Ok(caps) => builder.with_language_caps(caps), Err(e) => { tracing::error!("Failed to load Java plugin: {}", e); builder From 39e117888611cd870a197e80d18be0e88475bfcd Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Thu, 12 Feb 2026 05:09:11 +0800 Subject: [PATCH 17/49] refactor(lang-gradle): align plugin layout with cap and resolve modules --- crates/lang-gradle/src/cap/asset.rs | 8 + crates/lang-gradle/src/cap/indexing.rs | 15 ++ crates/lang-gradle/src/cap/matcher.rs | 17 ++ crates/lang-gradle/src/cap/metadata.rs | 38 ++++ crates/lang-gradle/src/cap/mod.rs | 9 + crates/lang-gradle/src/cap/parse.rs | 30 +++ crates/lang-gradle/src/cap/presentation.rs | 44 +++++ crates/lang-gradle/src/cap/registration.rs | 17 ++ crates/lang-gradle/src/lib.rs | 174 +----------------- .../src/{resolver.rs => resolve/build.rs} | 0 crates/lang-gradle/src/resolve/mod.rs | 3 + 11 files changed, 186 insertions(+), 169 deletions(-) create mode 100644 crates/lang-gradle/src/cap/asset.rs create mode 100644 crates/lang-gradle/src/cap/indexing.rs create mode 100644 crates/lang-gradle/src/cap/matcher.rs create mode 100644 crates/lang-gradle/src/cap/metadata.rs create mode 100644 crates/lang-gradle/src/cap/mod.rs create mode 100644 crates/lang-gradle/src/cap/parse.rs create mode 100644 crates/lang-gradle/src/cap/presentation.rs create mode 100644 crates/lang-gradle/src/cap/registration.rs rename crates/lang-gradle/src/{resolver.rs => resolve/build.rs} (100%) create mode 100644 crates/lang-gradle/src/resolve/mod.rs diff --git a/crates/lang-gradle/src/cap/asset.rs b/crates/lang-gradle/src/cap/asset.rs new file mode 100644 index 0000000..84d07f0 --- /dev/null +++ b/crates/lang-gradle/src/cap/asset.rs @@ -0,0 +1,8 @@ +use crate::GradlePlugin; +use naviscope_plugin::{AssetCap, AssetDiscoverer}; + +impl AssetCap for GradlePlugin { + fn global_asset_discoverer(&self) -> Option> { + Some(Box::new(crate::discoverer::GradleCacheDiscoverer::new())) + } +} diff --git a/crates/lang-gradle/src/cap/indexing.rs b/crates/lang-gradle/src/cap/indexing.rs new file mode 100644 index 0000000..010e598 --- /dev/null +++ b/crates/lang-gradle/src/cap/indexing.rs @@ -0,0 +1,15 @@ +use crate::GradlePlugin; +use naviscope_plugin::BuildIndexCap; + +impl BuildIndexCap for GradlePlugin { + fn compile_build( + &self, + files: &[&naviscope_plugin::ParsedFile], + ) -> Result< + (naviscope_plugin::ResolvedUnit, naviscope_plugin::ProjectContext), + naviscope_plugin::BoxError, + > { + let resolver = crate::resolve::GradleResolver::new(); + resolver.compile_build(files) + } +} diff --git a/crates/lang-gradle/src/cap/matcher.rs b/crates/lang-gradle/src/cap/matcher.rs new file mode 100644 index 0000000..b5f3d45 --- /dev/null +++ b/crates/lang-gradle/src/cap/matcher.rs @@ -0,0 +1,17 @@ +use crate::GradlePlugin; +use naviscope_plugin::FileMatcherCap; +use std::path::Path; + +impl FileMatcherCap for GradlePlugin { + fn supports_path(&self, path: &Path) -> bool { + path.file_name() + .and_then(|n| n.to_str()) + .map(|file_name| { + file_name == "build.gradle" + || file_name == "build.gradle.kts" + || file_name == "settings.gradle" + || file_name == "settings.gradle.kts" + }) + .unwrap_or(false) + } +} diff --git a/crates/lang-gradle/src/cap/metadata.rs b/crates/lang-gradle/src/cap/metadata.rs new file mode 100644 index 0000000..c05b13d --- /dev/null +++ b/crates/lang-gradle/src/cap/metadata.rs @@ -0,0 +1,38 @@ +use crate::GradlePlugin; +use naviscope_plugin::{CodecContext, MetadataCodecCap, NodeMetadataCodec}; +use std::sync::Arc; + +impl NodeMetadataCodec for GradlePlugin { + fn encode_metadata( + &self, + metadata: &dyn naviscope_api::models::graph::NodeMetadata, + _ctx: &mut dyn CodecContext, + ) -> Vec { + if let Some(gradle_meta) = metadata + .as_any() + .downcast_ref::() + { + rmp_serde::to_vec(&gradle_meta).unwrap_or_default() + } else { + Vec::new() + } + } + + fn decode_metadata( + &self, + bytes: &[u8], + _ctx: &dyn CodecContext, + ) -> Arc { + if let Ok(element) = rmp_serde::from_slice::(bytes) { + Arc::new(element) + } else { + Arc::new(naviscope_api::models::graph::EmptyMetadata) + } + } +} + +impl MetadataCodecCap for GradlePlugin { + fn metadata_codec(&self) -> Option> { + Some(Arc::new(Self::new())) + } +} diff --git a/crates/lang-gradle/src/cap/mod.rs b/crates/lang-gradle/src/cap/mod.rs new file mode 100644 index 0000000..40b7287 --- /dev/null +++ b/crates/lang-gradle/src/cap/mod.rs @@ -0,0 +1,9 @@ +mod asset; +mod indexing; +mod matcher; +mod metadata; +mod parse; +mod presentation; +mod registration; + +pub use registration::gradle_caps; diff --git a/crates/lang-gradle/src/cap/parse.rs b/crates/lang-gradle/src/cap/parse.rs new file mode 100644 index 0000000..980842e --- /dev/null +++ b/crates/lang-gradle/src/cap/parse.rs @@ -0,0 +1,30 @@ +use crate::GradlePlugin; +use naviscope_plugin::{BuildContent, BuildParseCap, BuildParseResult}; + +impl BuildParseCap for GradlePlugin { + fn parse_build_file( + &self, + source: &str, + ) -> Result> { + if source.contains("include") && (source.contains("'") || source.contains("\"")) { + let settings = + crate::parser::parse_settings(source).unwrap_or_else(|_| crate::model::GradleSettings { + root_project_name: None, + included_projects: Vec::new(), + }); + Ok(BuildParseResult { + content: BuildContent::Metadata( + serde_json::to_value(settings).unwrap_or(serde_json::Value::Null), + ), + }) + } else { + let deps = crate::parser::parse_dependencies(source).unwrap_or_default(); + Ok(BuildParseResult { + content: BuildContent::Metadata( + serde_json::to_value(crate::model::GradleParseResult { dependencies: deps }) + .unwrap_or(serde_json::Value::Null), + ), + }) + } + } +} diff --git a/crates/lang-gradle/src/cap/presentation.rs b/crates/lang-gradle/src/cap/presentation.rs new file mode 100644 index 0000000..0980c8e --- /dev/null +++ b/crates/lang-gradle/src/cap/presentation.rs @@ -0,0 +1,44 @@ +use crate::GradlePlugin; +use naviscope_api::models::graph::{DisplayGraphNode, GraphNode, NodeKind}; +use naviscope_api::models::symbol::FqnReader; +use naviscope_plugin::{NamingConvention, NodePresenter, PresentationCap, StandardNamingConvention}; +use std::sync::Arc; + +impl NodePresenter for GradlePlugin { + fn render_display_node(&self, node: &GraphNode, fqns: &dyn FqnReader) -> DisplayGraphNode { + let display_id = StandardNamingConvention.render_fqn(node.id, fqns); + let mut display = DisplayGraphNode { + id: display_id, + name: fqns.resolve_atom(node.name).to_string(), + kind: node.kind.clone(), + lang: "gradle".to_string(), + source: node.source.clone(), + status: node.status, + location: node.location.as_ref().map(|l| l.to_display(fqns)), + detail: None, + signature: None, + modifiers: vec![], + children: None, + }; + + if let Some(gradle_meta) = node + .metadata + .as_any() + .downcast_ref::() + { + display.detail = gradle_meta.detail_view(fqns); + } + + display + } +} + +impl PresentationCap for GradlePlugin { + fn node_presenter(&self) -> Option> { + Some(Arc::new(Self::new())) + } + + fn symbol_kind(&self, _kind: &NodeKind) -> lsp_types::SymbolKind { + lsp_types::SymbolKind::MODULE + } +} diff --git a/crates/lang-gradle/src/cap/registration.rs b/crates/lang-gradle/src/cap/registration.rs new file mode 100644 index 0000000..21b2be1 --- /dev/null +++ b/crates/lang-gradle/src/cap/registration.rs @@ -0,0 +1,17 @@ +use crate::GradlePlugin; +use naviscope_api::models::BuildTool; +use naviscope_plugin::BuildCaps; +use std::sync::Arc; + +pub fn gradle_caps() -> BuildCaps { + let plugin = Arc::new(GradlePlugin::new()); + BuildCaps { + build_tool: BuildTool::GRADLE, + matcher: plugin.clone(), + parser: plugin.clone(), + indexing: plugin.clone(), + asset: plugin.clone(), + presentation: plugin.clone(), + metadata_codec: plugin, + } +} diff --git a/crates/lang-gradle/src/lib.rs b/crates/lang-gradle/src/lib.rs index a3a969b..9100d68 100644 --- a/crates/lang-gradle/src/lib.rs +++ b/crates/lang-gradle/src/lib.rs @@ -1,183 +1,19 @@ +pub mod cap; pub mod discoverer; pub mod model; pub mod parser; pub mod queries; -pub mod resolver; +pub mod resolve; +pub use cap::gradle_caps; pub use discoverer::GradleCacheDiscoverer; -use naviscope_api::models::BuildTool; -use naviscope_api::models::graph::DisplayGraphNode; -use naviscope_api::models::symbol::FqnReader; -use naviscope_plugin::{ - AssetCap, AssetDiscoverer, BuildCaps, BuildContent, BuildIndexCap, BuildParseCap, - BuildParseResult, CodecContext, FileMatcherCap, MetadataCodecCap, NamingConvention, - NodeMetadataCodec, NodePresenter, PresentationCap, StandardNamingConvention, -}; -use std::path::Path; -use std::sync::Arc; - pub struct GradlePlugin { - resolver: Arc, -} - -impl NodePresenter for GradlePlugin { - fn render_display_node( - &self, - node: &naviscope_api::models::graph::GraphNode, - fqns: &dyn FqnReader, - ) -> DisplayGraphNode { - let display_id = StandardNamingConvention.render_fqn(node.id, fqns); - let mut display = DisplayGraphNode { - id: display_id, - name: fqns.resolve_atom(node.name).to_string(), - kind: node.kind.clone(), - lang: "gradle".to_string(), - source: node.source.clone(), - status: node.status, - location: node.location.as_ref().map(|l| l.to_display(fqns)), - detail: None, - signature: None, - modifiers: vec![], - children: None, - }; - - if let Some(gradle_meta) = node - .metadata - .as_any() - .downcast_ref::() - { - display.detail = gradle_meta.detail_view(fqns); - } - - display - } - -} - -impl NodeMetadataCodec for GradlePlugin { - fn encode_metadata( - &self, - metadata: &dyn naviscope_api::models::graph::NodeMetadata, - _ctx: &mut dyn CodecContext, - ) -> Vec { - if let Some(gradle_meta) = metadata - .as_any() - .downcast_ref::() - { - rmp_serde::to_vec(&gradle_meta).unwrap_or_default() - } else { - Vec::new() - } - } - - fn decode_metadata( - &self, - bytes: &[u8], - _ctx: &dyn CodecContext, - ) -> Arc { - if let Ok(element) = rmp_serde::from_slice::(bytes) { - Arc::new(element) - } else { - Arc::new(naviscope_api::models::graph::EmptyMetadata) - } - } + _private: (), } impl GradlePlugin { pub fn new() -> Self { - Self { - resolver: Arc::new(resolver::GradleResolver::new()), - } - } -} - -impl FileMatcherCap for GradlePlugin { - fn supports_path(&self, path: &Path) -> bool { - path.file_name() - .and_then(|n| n.to_str()) - .map(|file_name| { - file_name == "build.gradle" - || file_name == "build.gradle.kts" - || file_name == "settings.gradle" - || file_name == "settings.gradle.kts" - }) - .unwrap_or(false) - } -} - -impl BuildParseCap for GradlePlugin { - fn parse_build_file( - &self, - source: &str, - ) -> Result> { - if source.contains("include") && (source.contains("'") || source.contains("\"")) { - let settings = - parser::parse_settings(source).unwrap_or_else(|_| model::GradleSettings { - root_project_name: None, - included_projects: Vec::new(), - }); - Ok(BuildParseResult { - content: BuildContent::Metadata( - serde_json::to_value(settings).unwrap_or(serde_json::Value::Null), - ), - }) - } else { - let deps = parser::parse_dependencies(source).unwrap_or_default(); - Ok(BuildParseResult { - content: BuildContent::Metadata( - serde_json::to_value(model::GradleParseResult { dependencies: deps }) - .unwrap_or(serde_json::Value::Null), - ), - }) - } - } - -} - -impl BuildIndexCap for GradlePlugin { - fn compile_build( - &self, - files: &[&naviscope_plugin::ParsedFile], - ) -> Result<(naviscope_plugin::ResolvedUnit, naviscope_plugin::ProjectContext), naviscope_plugin::BoxError> { - self.resolver.compile_build(files) - } -} - -impl AssetCap for GradlePlugin { - fn global_asset_discoverer(&self) -> Option> { - Some(Box::new(crate::discoverer::GradleCacheDiscoverer::new())) - } -} - -impl PresentationCap for GradlePlugin { - fn node_presenter(&self) -> Option> { - Some(Arc::new(Self::new())) - } - - fn symbol_kind( - &self, - _kind: &naviscope_api::models::graph::NodeKind, - ) -> lsp_types::SymbolKind { - lsp_types::SymbolKind::MODULE - } -} - -impl MetadataCodecCap for GradlePlugin { - fn metadata_codec(&self) -> Option> { - Some(Arc::new(Self::new())) - } -} - -pub fn gradle_caps() -> BuildCaps { - let plugin = Arc::new(GradlePlugin::new()); - BuildCaps { - build_tool: BuildTool::GRADLE, - matcher: plugin.clone(), - parser: plugin.clone(), - indexing: plugin.clone(), - asset: plugin.clone(), - presentation: plugin.clone(), - metadata_codec: plugin, + Self { _private: () } } } diff --git a/crates/lang-gradle/src/resolver.rs b/crates/lang-gradle/src/resolve/build.rs similarity index 100% rename from crates/lang-gradle/src/resolver.rs rename to crates/lang-gradle/src/resolve/build.rs diff --git a/crates/lang-gradle/src/resolve/mod.rs b/crates/lang-gradle/src/resolve/mod.rs new file mode 100644 index 0000000..c46cb20 --- /dev/null +++ b/crates/lang-gradle/src/resolve/mod.rs @@ -0,0 +1,3 @@ +pub mod build; + +pub use build::GradleResolver; From 4fb8dd83f0b3154d56addedde45129c3ee20f047 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Thu, 12 Feb 2026 05:09:55 +0800 Subject: [PATCH 18/49] refactor: finalize plugin trait layout and java resolve module split --- crates/lang-java/src/cap/asset.rs | 21 + crates/lang-java/src/cap/indexing.rs | 1 + crates/lang-java/src/cap/matcher.rs | 12 + crates/lang-java/src/cap/metadata.rs | 44 ++ crates/lang-java/src/cap/mod.rs | 10 + crates/lang-java/src/cap/parse.rs | 13 + crates/lang-java/src/cap/presentation.rs | 209 +++++++++ crates/lang-java/src/cap/registration.rs | 18 + crates/lang-java/src/cap/runtime.rs | 41 ++ crates/lang-java/src/lib.rs | 410 +----------------- .../src/{resolver => resolve}/context.rs | 0 .../external/converter.rs | 0 .../src/{resolver => resolve}/external/mod.rs | 0 .../src/{resolver => resolve}/lang.rs | 6 +- .../src/{resolver => resolve}/mod.rs | 15 +- .../src/{resolver => resolve}/semantic.rs | 8 +- .../src/{resolver => resolve}/types.rs | 4 +- crates/lang-java/tests/common/mod.rs | 4 +- crates/lang-java/tests/java_integration.rs | 22 +- crates/lang-java/tests/logic_goto_def.rs | 12 +- crates/lang-java/tests/logic_goto_impl.rs | 6 +- crates/lang-java/tests/logic_goto_ref.rs | 4 +- crates/lang-java/tests/logic_goto_type.rs | 6 +- crates/lang-java/tests/logic_hierarchy.rs | 7 +- crates/plugin/src/cap/indexing.rs | 2 +- crates/plugin/src/cap/metadata_codec.rs | 2 +- crates/plugin/src/cap/presentation.rs | 2 +- crates/plugin/src/converter.rs | 2 +- crates/plugin/src/{ => core}/codec.rs | 2 +- crates/plugin/src/{ => core}/interner.rs | 0 .../plugin/src/{ => core}/metadata_codec.rs | 2 +- crates/plugin/src/core/mod.rs | 9 + crates/plugin/src/{ => core}/presentation.rs | 0 .../src/{resolver.rs => indexing/context.rs} | 0 crates/plugin/src/indexing/mod.rs | 3 + crates/plugin/src/lib.rs | 14 +- crates/plugin/src/model.rs | 2 +- 37 files changed, 438 insertions(+), 475 deletions(-) create mode 100644 crates/lang-java/src/cap/asset.rs create mode 100644 crates/lang-java/src/cap/indexing.rs create mode 100644 crates/lang-java/src/cap/matcher.rs create mode 100644 crates/lang-java/src/cap/metadata.rs create mode 100644 crates/lang-java/src/cap/mod.rs create mode 100644 crates/lang-java/src/cap/parse.rs create mode 100644 crates/lang-java/src/cap/presentation.rs create mode 100644 crates/lang-java/src/cap/registration.rs create mode 100644 crates/lang-java/src/cap/runtime.rs rename crates/lang-java/src/{resolver => resolve}/context.rs (100%) rename crates/lang-java/src/{resolver => resolve}/external/converter.rs (100%) rename crates/lang-java/src/{resolver => resolve}/external/mod.rs (100%) rename crates/lang-java/src/{resolver => resolve}/lang.rs (98%) rename crates/lang-java/src/{resolver => resolve}/mod.rs (97%) rename crates/lang-java/src/{resolver => resolve}/semantic.rs (97%) rename crates/lang-java/src/{resolver => resolve}/types.rs (97%) rename crates/plugin/src/{ => core}/codec.rs (81%) rename crates/plugin/src/{ => core}/interner.rs (100%) rename crates/plugin/src/{ => core}/metadata_codec.rs (90%) create mode 100644 crates/plugin/src/core/mod.rs rename crates/plugin/src/{ => core}/presentation.rs (100%) rename crates/plugin/src/{resolver.rs => indexing/context.rs} (100%) create mode 100644 crates/plugin/src/indexing/mod.rs diff --git a/crates/lang-java/src/cap/asset.rs b/crates/lang-java/src/cap/asset.rs new file mode 100644 index 0000000..6e7ac4b --- /dev/null +++ b/crates/lang-java/src/cap/asset.rs @@ -0,0 +1,21 @@ +use crate::JavaPlugin; +use naviscope_plugin::{AssetCap, AssetDiscoverer, AssetIndexer, AssetSourceLocator}; +use std::sync::Arc; + +impl AssetCap for JavaPlugin { + fn global_asset_discoverer(&self) -> Option> { + Some(Box::new(crate::discoverer::JdkDiscoverer::new())) + } + + fn asset_indexer(&self) -> Option> { + Some(Arc::new(crate::resolve::external::JavaExternalResolver)) + } + + fn asset_source_locator(&self) -> Option> { + Some(Arc::new(crate::resolve::external::JavaExternalResolver)) + } + + fn stub_generator(&self) -> Option> { + Some(Arc::new(crate::resolve::external::JavaExternalResolver)) + } +} diff --git a/crates/lang-java/src/cap/indexing.rs b/crates/lang-java/src/cap/indexing.rs new file mode 100644 index 0000000..5a42d08 --- /dev/null +++ b/crates/lang-java/src/cap/indexing.rs @@ -0,0 +1 @@ +// Source indexing implementation is provided in `crate::resolve::lang`. diff --git a/crates/lang-java/src/cap/matcher.rs b/crates/lang-java/src/cap/matcher.rs new file mode 100644 index 0000000..89598dc --- /dev/null +++ b/crates/lang-java/src/cap/matcher.rs @@ -0,0 +1,12 @@ +use crate::JavaPlugin; +use naviscope_plugin::FileMatcherCap; +use std::path::Path; + +impl FileMatcherCap for JavaPlugin { + fn supports_path(&self, path: &Path) -> bool { + path.extension() + .and_then(|e| e.to_str()) + .map(|ext| ext.eq_ignore_ascii_case("java")) + .unwrap_or(false) + } +} diff --git a/crates/lang-java/src/cap/metadata.rs b/crates/lang-java/src/cap/metadata.rs new file mode 100644 index 0000000..c21ea28 --- /dev/null +++ b/crates/lang-java/src/cap/metadata.rs @@ -0,0 +1,44 @@ +use crate::JavaPlugin; +use naviscope_api::models::graph::EmptyMetadata; +use naviscope_plugin::{CodecContext, MetadataCodecCap, NodeMetadataCodec}; +use std::sync::Arc; + +impl NodeMetadataCodec for JavaPlugin { + fn encode_metadata( + &self, + metadata: &dyn naviscope_api::models::graph::NodeMetadata, + _ctx: &mut dyn CodecContext, + ) -> Vec { + if let Some(java_meta) = metadata + .as_any() + .downcast_ref::() + { + rmp_serde::to_vec(&java_meta).unwrap_or_default() + } else if let Some(java_idx_meta) = metadata + .as_any() + .downcast_ref::() + { + rmp_serde::to_vec(&java_idx_meta).unwrap_or_default() + } else { + Vec::new() + } + } + + fn decode_metadata( + &self, + bytes: &[u8], + _ctx: &dyn CodecContext, + ) -> Arc { + if let Ok(element) = rmp_serde::from_slice::(bytes) { + Arc::new(element) + } else { + Arc::new(EmptyMetadata) + } + } +} + +impl MetadataCodecCap for JavaPlugin { + fn metadata_codec(&self) -> Option> { + Some(Arc::new(self.clone())) + } +} diff --git a/crates/lang-java/src/cap/mod.rs b/crates/lang-java/src/cap/mod.rs new file mode 100644 index 0000000..c0c9cd4 --- /dev/null +++ b/crates/lang-java/src/cap/mod.rs @@ -0,0 +1,10 @@ +mod asset; +mod indexing; +mod matcher; +mod metadata; +mod parse; +mod presentation; +mod registration; +mod runtime; + +pub use registration::java_caps; diff --git a/crates/lang-java/src/cap/parse.rs b/crates/lang-java/src/cap/parse.rs new file mode 100644 index 0000000..260bb55 --- /dev/null +++ b/crates/lang-java/src/cap/parse.rs @@ -0,0 +1,13 @@ +use crate::JavaPlugin; +use naviscope_plugin::LanguageParseCap; +use std::path::Path; + +impl LanguageParseCap for JavaPlugin { + fn parse_language_file( + &self, + source: &str, + path: &Path, + ) -> std::result::Result { + self.parser.parse_file(source, Some(path)) + } +} diff --git a/crates/lang-java/src/cap/presentation.rs b/crates/lang-java/src/cap/presentation.rs new file mode 100644 index 0000000..25a8785 --- /dev/null +++ b/crates/lang-java/src/cap/presentation.rs @@ -0,0 +1,209 @@ +use crate::JavaPlugin; +use lasso::Key; +use naviscope_api::models::graph::{GraphNode, NodeKind}; +use naviscope_api::models::symbol::{FqnReader, Symbol}; +use naviscope_api::models::DisplayGraphNode; +use naviscope_plugin::{NamingConvention, NodePresenter, PresentationCap}; +use std::sync::Arc; + +impl NodePresenter for JavaPlugin { + fn render_display_node(&self, node: &GraphNode, fqns: &dyn FqnReader) -> DisplayGraphNode { + let mut display = DisplayGraphNode { + id: crate::naming::JavaNamingConvention::default().render_fqn(node.id, fqns), + name: fqns.resolve_atom(node.name).to_string(), + kind: node.kind.clone(), + lang: "java".to_string(), + source: node.source.clone(), + status: node.status, + location: node.location.as_ref().map(|l| l.to_display(fqns)), + detail: None, + signature: None, + modifiers: vec![], + children: None, + }; + + let fqn = display.id.as_str(); + let parts: Vec<&str> = fqn.split('.').collect(); + if parts.len() > 1 { + let container = parts[..parts.len() - 1].join("."); + display.detail = Some(format!("*Defined in `{}`*", container)); + } + + if let Some(java_meta) = node + .metadata + .as_any() + .downcast_ref::() + { + match java_meta { + crate::model::JavaNodeMetadata::Class { modifiers_sids } + | crate::model::JavaNodeMetadata::Interface { modifiers_sids } + | crate::model::JavaNodeMetadata::Annotation { modifiers_sids } => { + display.modifiers = modifiers_sids + .iter() + .map(|&s| { + fqns.resolve_atom(Symbol( + lasso::Spur::try_from_usize(s as usize).unwrap(), + )) + .to_string() + }) + .collect(); + let prefix = match node.kind { + NodeKind::Interface => "interface", + NodeKind::Annotation => "@interface", + _ => "class", + }; + display.signature = Some(format!("{} {}", prefix, display.name)); + } + crate::model::JavaNodeMetadata::Method { + modifiers_sids, + return_type, + parameters, + is_constructor, + } => { + display.modifiers = modifiers_sids + .iter() + .map(|&s| { + fqns.resolve_atom(Symbol( + lasso::Spur::try_from_usize(s as usize).unwrap(), + )) + .to_string() + }) + .collect(); + let params_str = parameters + .iter() + .map(|p| { + format!( + "{}: {}", + fqns.resolve_atom(Symbol( + lasso::Spur::try_from_usize(p.name_sid as usize).unwrap(), + )), + crate::model::fmt_type(&p.type_ref) + ) + }) + .collect::>() + .join(", "); + if *is_constructor { + display.signature = Some(format!("{}({})", display.name, params_str)); + } else { + display.signature = Some(format!( + "{}({}) -> {}", + display.name, + params_str, + crate::model::fmt_type(return_type) + )); + } + } + crate::model::JavaNodeMetadata::Field { + modifiers_sids, + type_ref, + } => { + display.modifiers = modifiers_sids + .iter() + .map(|&s| { + fqns.resolve_atom(Symbol( + lasso::Spur::try_from_usize(s as usize).unwrap(), + )) + .to_string() + }) + .collect(); + display.signature = Some(format!( + "{}: {}", + display.name, + crate::model::fmt_type(type_ref) + )); + } + _ => {} + } + } else if let Some(java_idx_meta) = node + .metadata + .as_any() + .downcast_ref::() + { + match java_idx_meta { + crate::model::JavaIndexMetadata::Class { modifiers } + | crate::model::JavaIndexMetadata::Interface { modifiers } + | crate::model::JavaIndexMetadata::Annotation { modifiers } => { + display.modifiers = modifiers.clone(); + let prefix = match node.kind { + NodeKind::Interface => "interface", + NodeKind::Annotation => "@interface", + _ => "class", + }; + display.signature = Some(format!("{} {}", prefix, display.name)); + } + crate::model::JavaIndexMetadata::Method { + modifiers, + return_type, + parameters, + is_constructor, + } => { + display.modifiers = modifiers.clone(); + let params_str = parameters + .iter() + .map(|p| { + format!( + "{}: {}", + p.name, + crate::model::fmt_type_uninterned(&p.type_ref) + ) + }) + .collect::>() + .join(", "); + if *is_constructor { + display.signature = Some(format!("{}({})", display.name, params_str)); + } else { + display.signature = Some(format!( + "{}({}) -> {}", + display.name, + params_str, + crate::model::fmt_type_uninterned(return_type) + )); + } + } + crate::model::JavaIndexMetadata::Field { + modifiers, + type_ref, + } => { + display.modifiers = modifiers.clone(); + display.signature = Some(format!( + "{}: {}", + display.name, + crate::model::fmt_type_uninterned(type_ref) + )); + } + crate::model::JavaIndexMetadata::Enum { modifiers, .. } => { + display.modifiers = modifiers.clone(); + display.signature = Some(format!("enum {}", display.name)); + } + _ => {} + } + } + + display + } +} + +impl PresentationCap for JavaPlugin { + fn naming_convention(&self) -> Option> { + Some(Arc::new(crate::naming::JavaNamingConvention::default())) + } + + fn node_presenter(&self) -> Option> { + Some(Arc::new(self.clone())) + } + + fn symbol_kind(&self, kind: &NodeKind) -> lsp_types::SymbolKind { + use lsp_types::SymbolKind; + match kind { + 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, + NodeKind::Package => SymbolKind::PACKAGE, + _ => SymbolKind::VARIABLE, + } + } +} diff --git a/crates/lang-java/src/cap/registration.rs b/crates/lang-java/src/cap/registration.rs new file mode 100644 index 0000000..75039b1 --- /dev/null +++ b/crates/lang-java/src/cap/registration.rs @@ -0,0 +1,18 @@ +use crate::JavaPlugin; +use naviscope_api::models::Language; +use naviscope_plugin::{LanguageCaps, SemanticCap}; +use std::sync::Arc; + +pub fn java_caps() -> std::result::Result> { + let plugin = Arc::new(JavaPlugin::new()?); + Ok(LanguageCaps { + language: Language::JAVA, + matcher: plugin.clone(), + parser: plugin.clone(), + semantic: plugin.clone() as Arc, + indexing: plugin.clone(), + asset: plugin.clone(), + presentation: plugin.clone(), + metadata_codec: plugin, + }) +} diff --git a/crates/lang-java/src/cap/runtime.rs b/crates/lang-java/src/cap/runtime.rs new file mode 100644 index 0000000..e37db18 --- /dev/null +++ b/crates/lang-java/src/cap/runtime.rs @@ -0,0 +1,41 @@ +use crate::JavaPlugin; +use naviscope_plugin::{LspSyntaxService, ReferenceCheckService}; + +impl LspSyntaxService for JavaPlugin { + fn parse(&self, source: &str, old_tree: Option<&tree_sitter::Tree>) -> Option { + crate::lsp::JavaLspService::new(self.parser.clone()).parse(source, old_tree) + } + + fn extract_symbols( + &self, + tree: &tree_sitter::Tree, + source: &str, + ) -> Vec { + crate::lsp::JavaLspService::new(self.parser.clone()).extract_symbols(tree, source) + } + + fn find_occurrences( + &self, + source: &str, + tree: &tree_sitter::Tree, + target: &naviscope_api::models::SymbolResolution, + ) -> Vec { + crate::lsp::JavaLspService::new(self.parser.clone()) + .find_occurrences(source, tree, target) + } +} + +impl ReferenceCheckService for JavaPlugin { + fn is_reference_to( + &self, + graph: &dyn naviscope_plugin::CodeGraph, + candidate: &naviscope_api::models::SymbolResolution, + target: &naviscope_api::models::SymbolResolution, + ) -> bool { + self.type_system.is_reference_to(graph, candidate, target) + } + + fn is_subtype(&self, graph: &dyn naviscope_plugin::CodeGraph, sub: &str, sup: &str) -> bool { + self.type_system.is_subtype(graph, sub, sup) + } +} diff --git a/crates/lang-java/src/lib.rs b/crates/lang-java/src/lib.rs index fe3d7a3..f791dd3 100644 --- a/crates/lang-java/src/lib.rs +++ b/crates/lang-java/src/lib.rs @@ -1,3 +1,4 @@ +pub mod cap; pub mod discoverer; pub mod inference; pub mod jdk; @@ -6,432 +7,31 @@ pub mod model; pub mod naming; pub mod parser; pub mod queries; -pub mod resolver; +pub mod resolve; +pub use cap::java_caps; pub use discoverer::JdkDiscoverer; -use lasso::Key; -use naviscope_api::models::graph::{EmptyMetadata, GraphNode, NodeKind}; -use naviscope_api::models::symbol::{FqnReader, Symbol}; -use naviscope_api::models::{DisplayGraphNode, Language}; -use naviscope_plugin::{ - AssetCap, AssetDiscoverer, AssetIndexer, AssetSourceLocator, CodecContext, - FileMatcherCap, LanguageCaps, LanguageParseCap, MetadataCodecCap, NodeMetadataCodec, - NodePresenter, PresentationCap, ProjectContext, ReferenceCheckService, SemanticCap, - SourceIndexCap, SymbolQueryService, SymbolResolveService, LspSyntaxService, NamingConvention, - ParsedFile, ResolvedUnit, -}; -use std::path::Path; use std::sync::Arc; #[derive(Clone)] pub struct JavaPlugin { - parser: Arc, - resolver: Arc, - type_system: Arc, -} - -impl NodePresenter for JavaPlugin { - fn render_display_node(&self, node: &GraphNode, fqns: &dyn FqnReader) -> DisplayGraphNode { - let mut display = DisplayGraphNode { - id: crate::naming::JavaNamingConvention::default().render_fqn(node.id, fqns), - name: fqns.resolve_atom(node.name).to_string(), - kind: node.kind.clone(), - lang: "java".to_string(), - source: node.source.clone(), - status: node.status, - location: node.location.as_ref().map(|l| l.to_display(fqns)), - detail: None, - signature: None, - modifiers: vec![], - children: None, - }; - - let fqn = display.id.as_str(); - let parts: Vec<&str> = fqn.split('.').collect(); - if parts.len() > 1 { - let container = parts[..parts.len() - 1].join("."); - display.detail = Some(format!("*Defined in `{}`*", container)); - } - - // Real-time calculation from JavaNodeMetadata - if let Some(java_meta) = node - .metadata - .as_any() - .downcast_ref::() - { - match java_meta { - crate::model::JavaNodeMetadata::Class { modifiers_sids } - | crate::model::JavaNodeMetadata::Interface { modifiers_sids } - | crate::model::JavaNodeMetadata::Annotation { modifiers_sids } => { - display.modifiers = modifiers_sids - .iter() - .map(|&s| { - fqns.resolve_atom(Symbol( - lasso::Spur::try_from_usize(s as usize).unwrap(), - )) - .to_string() - }) - .collect(); - let prefix = match node.kind { - NodeKind::Interface => "interface", - NodeKind::Annotation => "@interface", - _ => "class", - }; - display.signature = Some(format!("{} {}", prefix, display.name)); - } - crate::model::JavaNodeMetadata::Method { - modifiers_sids, - return_type, - parameters, - is_constructor, - } => { - display.modifiers = modifiers_sids - .iter() - .map(|&s| { - fqns.resolve_atom(Symbol( - lasso::Spur::try_from_usize(s as usize).unwrap(), - )) - .to_string() - }) - .collect(); - let params_str = parameters - .iter() - .map(|p| { - format!( - "{}: {}", - fqns.resolve_atom(Symbol( - lasso::Spur::try_from_usize(p.name_sid as usize).unwrap(), - )), - crate::model::fmt_type(&p.type_ref) - ) - }) - .collect::>() - .join(", "); - if *is_constructor { - display.signature = Some(format!("{}({})", display.name, params_str)); - } else { - display.signature = Some(format!( - "{}({}) -> {}", - display.name, - params_str, - crate::model::fmt_type(return_type) - )); - } - } - crate::model::JavaNodeMetadata::Field { - modifiers_sids, - type_ref, - } => { - display.modifiers = modifiers_sids - .iter() - .map(|&s| { - fqns.resolve_atom(Symbol( - lasso::Spur::try_from_usize(s as usize).unwrap(), - )) - .to_string() - }) - .collect(); - display.signature = Some(format!( - "{}: {}", - display.name, - crate::model::fmt_type(type_ref) - )); - } - _ => {} - } - } else if let Some(java_idx_meta) = node - .metadata - .as_any() - .downcast_ref::() - { - // Real-time calculation from JavaIndexMetadata (Uninterned) - match java_idx_meta { - crate::model::JavaIndexMetadata::Class { modifiers } - | crate::model::JavaIndexMetadata::Interface { modifiers } - | crate::model::JavaIndexMetadata::Annotation { modifiers } => { - display.modifiers = modifiers.clone(); - let prefix = match node.kind { - NodeKind::Interface => "interface", - NodeKind::Annotation => "@interface", - _ => "class", - }; - display.signature = Some(format!("{} {}", prefix, display.name)); - } - crate::model::JavaIndexMetadata::Method { - modifiers, - return_type, - parameters, - is_constructor, - } => { - display.modifiers = modifiers.clone(); - let params_str = parameters - .iter() - .map(|p| { - format!( - "{}: {}", - p.name, - crate::model::fmt_type_uninterned(&p.type_ref) - ) - }) - .collect::>() - .join(", "); - if *is_constructor { - display.signature = Some(format!("{}({})", display.name, params_str)); - } else { - display.signature = Some(format!( - "{}({}) -> {}", - display.name, - params_str, - crate::model::fmt_type_uninterned(return_type) - )); - } - } - crate::model::JavaIndexMetadata::Field { - modifiers, - type_ref, - } => { - display.modifiers = modifiers.clone(); - display.signature = Some(format!( - "{}: {}", - display.name, - crate::model::fmt_type_uninterned(type_ref) - )); - } - crate::model::JavaIndexMetadata::Enum { modifiers, .. } => { - display.modifiers = modifiers.clone(); - display.signature = Some(format!("enum {}", display.name)); - } - _ => {} - } - } - - display - } - -} - -impl NodeMetadataCodec for JavaPlugin { - fn encode_metadata( - &self, - metadata: &dyn naviscope_api::models::graph::NodeMetadata, - _ctx: &mut dyn CodecContext, - ) -> Vec { - if let Some(java_meta) = metadata - .as_any() - .downcast_ref::() - { - rmp_serde::to_vec(&java_meta).unwrap_or_default() - } else if let Some(java_idx_meta) = metadata - .as_any() - .downcast_ref::() - { - rmp_serde::to_vec(&java_idx_meta).unwrap_or_default() - } else { - Vec::new() - } - } - - fn decode_metadata( - &self, - bytes: &[u8], - _ctx: &dyn CodecContext, - ) -> Arc { - if let Ok(element) = rmp_serde::from_slice::(bytes) { - Arc::new(element) - } else { - Arc::new(EmptyMetadata) - } - } + pub(crate) parser: Arc, + pub(crate) type_system: Arc, } impl JavaPlugin { pub fn new() -> std::result::Result> { - // Register metadata deserializer for Java naviscope_plugin::register_metadata_deserializer( "java", crate::model::JavaIndexMetadata::deserialize_for_cache, ); let parser = Arc::new(parser::JavaParser::new()?); - let resolver = Arc::new(resolver::JavaResolver { - parser: (*parser).clone(), - }); let type_system = Arc::new(lsp::type_system::JavaTypeSystem::new()); Ok(Self { parser, - resolver, type_system, }) } } - -impl FileMatcherCap for JavaPlugin { - fn supports_path(&self, path: &Path) -> bool { - path.extension() - .and_then(|e| e.to_str()) - .map(|ext| ext.eq_ignore_ascii_case("java")) - .unwrap_or(false) - } -} - -impl LanguageParseCap for JavaPlugin { - fn parse_language_file( - &self, - source: &str, - path: &Path, - ) -> std::result::Result { - self.parser.parse_file(source, Some(path)) - } -} - -impl SymbolResolveService for JavaPlugin { - fn resolve_at( - &self, - tree: &tree_sitter::Tree, - source: &str, - line: usize, - byte_col: usize, - index: &dyn naviscope_plugin::CodeGraph, - ) -> Option { - self.resolver.resolve_at(tree, source, line, byte_col, index) - } -} - -impl SymbolQueryService for JavaPlugin { - fn find_matches( - &self, - index: &dyn naviscope_plugin::CodeGraph, - res: &naviscope_api::models::SymbolResolution, - ) -> Vec { - self.resolver.find_matches(index, res) - } - - fn resolve_type_of( - &self, - index: &dyn naviscope_plugin::CodeGraph, - res: &naviscope_api::models::SymbolResolution, - ) -> Vec { - self.resolver.resolve_type_of(index, res) - } - - fn find_implementations( - &self, - index: &dyn naviscope_plugin::CodeGraph, - res: &naviscope_api::models::SymbolResolution, - ) -> Vec { - self.resolver.find_implementations(index, res) - } -} - -impl LspSyntaxService for JavaPlugin { - fn parse(&self, source: &str, old_tree: Option<&tree_sitter::Tree>) -> Option { - crate::lsp::JavaLspService::new(self.parser.clone()).parse(source, old_tree) - } - - fn extract_symbols( - &self, - tree: &tree_sitter::Tree, - source: &str, - ) -> Vec { - crate::lsp::JavaLspService::new(self.parser.clone()).extract_symbols(tree, source) - } - - fn find_occurrences( - &self, - source: &str, - tree: &tree_sitter::Tree, - target: &naviscope_api::models::SymbolResolution, - ) -> Vec { - crate::lsp::JavaLspService::new(self.parser.clone()) - .find_occurrences(source, tree, target) - } -} - -impl ReferenceCheckService for JavaPlugin { - fn is_reference_to( - &self, - graph: &dyn naviscope_plugin::CodeGraph, - candidate: &naviscope_api::models::SymbolResolution, - target: &naviscope_api::models::SymbolResolution, - ) -> bool { - self.type_system.is_reference_to(graph, candidate, target) - } - - fn is_subtype(&self, graph: &dyn naviscope_plugin::CodeGraph, sub: &str, sup: &str) -> bool { - self.type_system.is_subtype(graph, sub, sup) - } -} - -impl SourceIndexCap for JavaPlugin { - fn compile_source( - &self, - file: &ParsedFile, - context: &ProjectContext, - ) -> std::result::Result { - self.resolver.compile_source(file, context) - } -} - -impl AssetCap for JavaPlugin { - fn global_asset_discoverer(&self) -> Option> { - Some(Box::new(crate::discoverer::JdkDiscoverer::new())) - } - - fn asset_indexer(&self) -> Option> { - Some(Arc::new(crate::resolver::external::JavaExternalResolver)) - } - - fn asset_source_locator(&self) -> Option> { - Some(Arc::new(crate::resolver::external::JavaExternalResolver)) - } - - fn stub_generator(&self) -> Option> { - Some(Arc::new(crate::resolver::external::JavaExternalResolver)) - } -} - -impl PresentationCap for JavaPlugin { - fn naming_convention(&self) -> Option> { - Some(Arc::new(crate::naming::JavaNamingConvention::default())) - } - - fn node_presenter(&self) -> Option> { - Some(Arc::new(self.clone())) - } - - fn symbol_kind(&self, kind: &NodeKind) -> lsp_types::SymbolKind { - use lsp_types::SymbolKind; - match kind { - 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, - NodeKind::Package => SymbolKind::PACKAGE, - _ => SymbolKind::VARIABLE, - } - } -} - -impl MetadataCodecCap for JavaPlugin { - fn metadata_codec(&self) -> Option> { - Some(Arc::new(self.clone())) - } -} - -pub fn java_caps() -> std::result::Result> { - let plugin = Arc::new(JavaPlugin::new()?); - Ok(LanguageCaps { - language: Language::JAVA, - matcher: plugin.clone(), - parser: plugin.clone(), - semantic: plugin.clone() as Arc, - indexing: plugin.clone(), - asset: plugin.clone(), - presentation: plugin.clone(), - metadata_codec: plugin, - }) -} diff --git a/crates/lang-java/src/resolver/context.rs b/crates/lang-java/src/resolve/context.rs similarity index 100% rename from crates/lang-java/src/resolver/context.rs rename to crates/lang-java/src/resolve/context.rs diff --git a/crates/lang-java/src/resolver/external/converter.rs b/crates/lang-java/src/resolve/external/converter.rs similarity index 100% rename from crates/lang-java/src/resolver/external/converter.rs rename to crates/lang-java/src/resolve/external/converter.rs diff --git a/crates/lang-java/src/resolver/external/mod.rs b/crates/lang-java/src/resolve/external/mod.rs similarity index 100% rename from crates/lang-java/src/resolver/external/mod.rs rename to crates/lang-java/src/resolve/external/mod.rs diff --git a/crates/lang-java/src/resolver/lang.rs b/crates/lang-java/src/resolve/lang.rs similarity index 98% rename from crates/lang-java/src/resolver/lang.rs rename to crates/lang-java/src/resolve/lang.rs index f08b23b..c93a1fe 100644 --- a/crates/lang-java/src/resolver/lang.rs +++ b/crates/lang-java/src/resolve/lang.rs @@ -1,8 +1,8 @@ use crate::inference::adapters::HeuristicAdapter; use crate::inference::{TypeProvider, TypeResolutionContext}; use crate::model::JavaIndexMetadata; -use crate::resolver::JavaResolver; -use crate::resolver::context::ResolutionContext; +use crate::JavaPlugin; +use crate::resolve::context::ResolutionContext; use naviscope_api::models::graph::{EdgeType, GraphEdge, NodeKind}; use naviscope_api::models::symbol::{NodeId, SymbolResolution, TypeRef}; use naviscope_plugin::{ @@ -10,7 +10,7 @@ use naviscope_plugin::{ }; use std::sync::Arc; -impl SourceIndexCap for JavaResolver { +impl SourceIndexCap for JavaPlugin { fn compile_source( &self, file: &ParsedFile, diff --git a/crates/lang-java/src/resolver/mod.rs b/crates/lang-java/src/resolve/mod.rs similarity index 97% rename from crates/lang-java/src/resolver/mod.rs rename to crates/lang-java/src/resolve/mod.rs index 543e2e7..3aabedd 100644 --- a/crates/lang-java/src/resolver/mod.rs +++ b/crates/lang-java/src/resolve/mod.rs @@ -7,22 +7,11 @@ pub mod semantic; pub mod types; use crate::inference::adapters::CodeGraphTypeSystem; -use crate::parser::JavaParser; +use crate::JavaPlugin; use context::ResolutionContext; use naviscope_api::models::{SymbolResolution, TypeRef}; -#[derive(Clone)] -pub struct JavaResolver { - pub parser: JavaParser, -} - -impl JavaResolver { - pub fn new() -> Self { - Self { - parser: JavaParser::new().expect("Failed to initialize JavaParser"), - } - } - +impl JavaPlugin { /// Helper to find enclosing class using ScopeManager fn find_enclosing_class_via_scope( &self, diff --git a/crates/lang-java/src/resolver/semantic.rs b/crates/lang-java/src/resolve/semantic.rs similarity index 97% rename from crates/lang-java/src/resolver/semantic.rs rename to crates/lang-java/src/resolve/semantic.rs index 6e355dd..920e271 100644 --- a/crates/lang-java/src/resolver/semantic.rs +++ b/crates/lang-java/src/resolve/semantic.rs @@ -1,14 +1,14 @@ use crate::inference::adapters::CodeGraphTypeSystem; use crate::inference::{InheritanceProvider, MemberProvider, TypeProvider, TypeResolutionContext}; -use crate::resolver::JavaResolver; -use crate::resolver::context::ResolutionContext; +use crate::JavaPlugin; +use crate::resolve::context::ResolutionContext; use naviscope_api::models::graph::EdgeType; use naviscope_api::models::symbol::{FqnId, matches_intent}; use naviscope_api::models::{SymbolIntent, SymbolResolution, TypeRef}; use naviscope_plugin::{CodeGraph, NamingConvention, SymbolQueryService, SymbolResolveService}; use tree_sitter::Tree; -impl SymbolResolveService for JavaResolver { +impl SymbolResolveService for JavaPlugin { fn resolve_at( &self, tree: &Tree, @@ -36,7 +36,7 @@ impl SymbolResolveService for JavaResolver { } -impl SymbolQueryService for JavaResolver { +impl SymbolQueryService for JavaPlugin { fn find_matches(&self, index: &dyn CodeGraph, resolution: &SymbolResolution) -> Vec { match resolution { SymbolResolution::Local(_, _) => vec![], diff --git a/crates/lang-java/src/resolver/types.rs b/crates/lang-java/src/resolve/types.rs similarity index 97% rename from crates/lang-java/src/resolver/types.rs rename to crates/lang-java/src/resolve/types.rs index f61c83c..2a4fa0b 100644 --- a/crates/lang-java/src/resolver/types.rs +++ b/crates/lang-java/src/resolve/types.rs @@ -1,8 +1,8 @@ use crate::inference::{TypeProvider, TypeResolutionContext}; -use crate::resolver::JavaResolver; +use crate::JavaPlugin; use naviscope_api::models::TypeRef; -impl JavaResolver { +impl JavaPlugin { pub(crate) fn resolve_type_ref( &self, type_ref: &TypeRef, diff --git a/crates/lang-java/tests/common/mod.rs b/crates/lang-java/tests/common/mod.rs index 4495c26..0a55099 100644 --- a/crates/lang-java/tests/common/mod.rs +++ b/crates/lang-java/tests/common/mod.rs @@ -1,7 +1,7 @@ use naviscope_api::models::Language; use naviscope_core::ingest::builder::CodeGraphBuilder; use naviscope_java::parser::JavaParser; -use naviscope_java::resolver::JavaResolver; +use naviscope_java::JavaPlugin; use naviscope_plugin::{ GraphOp, ParsedContent, ParsedFile, ProjectContext, SourceFile, SourceIndexCap, }; @@ -43,7 +43,7 @@ pub fn setup_java_test_graph( } // Phase 2: Resolve (using JavaResolver source-index implementation) - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let context = ProjectContext::new(); // Uses default V2 context let mut all_ops = Vec::new(); diff --git a/crates/lang-java/tests/java_integration.rs b/crates/lang-java/tests/java_integration.rs index 918fa08..f7f0cea 100644 --- a/crates/lang-java/tests/java_integration.rs +++ b/crates/lang-java/tests/java_integration.rs @@ -2,7 +2,7 @@ mod common; use common::{offset_to_point, setup_java_test_graph}; use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; -use naviscope_java::resolver::JavaResolver; +use naviscope_java::JavaPlugin; #[test] fn test_cross_file_resolution() { @@ -18,7 +18,7 @@ fn test_cross_file_resolution() { ]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); // Test resolving 'A' in 'A a = new A();' let b_content = &trees[1].1; @@ -70,7 +70,7 @@ fn test_inheritance_and_implementations() { ]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let i_content = &trees[0].1; let i_tree = &trees[0].2; @@ -112,7 +112,7 @@ fn test_inner_class_resolution() { ]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let client_content = &trees[1].1; let client_tree = &trees[1].2; @@ -156,7 +156,7 @@ fn test_chained_calls_resolution() { ]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let main_content = &trees[3].1; let main_tree = &trees[3].2; @@ -204,7 +204,7 @@ fn test_lambda_parameter_resolution() { )]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let content = &trees[0].1; let tree = &trees[0].2; @@ -244,7 +244,7 @@ fn test_lambda_explicit_type_resolution() { ]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let content = &trees[1].1; let tree = &trees[1].2; @@ -280,7 +280,7 @@ fn test_lambda_heuristic_type_inference() { ]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let content = &trees[1].1; let tree = &trees[1].2; @@ -324,7 +324,7 @@ public class DefaultApplicationArguments { )]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let content = &trees[0].1; let tree = &trees[0].2; @@ -397,7 +397,7 @@ public class DefaultApplicationArguments { ]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let tree = &trees[0].2; let source_content = &trees[0].1; @@ -447,7 +447,7 @@ fn test_field_method_call_resolution() { ]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let a_content = &trees[0].1; let a_tree = &trees[0].2; diff --git a/crates/lang-java/tests/logic_goto_def.rs b/crates/lang-java/tests/logic_goto_def.rs index 3dba5bf..e5378f1 100644 --- a/crates/lang-java/tests/logic_goto_def.rs +++ b/crates/lang-java/tests/logic_goto_def.rs @@ -4,7 +4,7 @@ use common::{offset_to_point, setup_java_test_graph}; use naviscope_core::features::CodeGraphLike; use naviscope_core::ingest::parser::SymbolResolution; use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; -use naviscope_java::resolver::JavaResolver; +use naviscope_java::JavaPlugin; #[test] fn test_goto_definition_local() { @@ -13,7 +13,7 @@ fn test_goto_definition_local() { "public class Test { void main() { int x = 1; int y = x + 1; } }", )]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let content = &trees[0].1; let tree = &trees[0].2; @@ -48,7 +48,7 @@ fn test_goto_definition_cross_file() { ), ]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let b_content = &trees[1].1; let b_tree = &trees[1].2; @@ -95,7 +95,7 @@ fn test_goto_definition_shadowing() { "public class Test { int x = 0; void m() { int x = 1; x = 2; } }", )]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let content = &trees[0].1; let tree = &trees[0].2; @@ -123,7 +123,7 @@ fn test_goto_definition_constructor() { ("B.java", "public class B { A a = new A(); }"), ]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let b_content = &trees[1].1; let b_tree = &trees[1].2; @@ -156,7 +156,7 @@ fn test_goto_definition_static() { ("B.java", "public class B { int x = A.VAL; }"), ]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let b_content = &trees[1].1; let b_tree = &trees[1].2; diff --git a/crates/lang-java/tests/logic_goto_impl.rs b/crates/lang-java/tests/logic_goto_impl.rs index 273ea0f..38e832f 100644 --- a/crates/lang-java/tests/logic_goto_impl.rs +++ b/crates/lang-java/tests/logic_goto_impl.rs @@ -3,7 +3,7 @@ mod common; use common::{offset_to_point, setup_java_test_graph}; use naviscope_core::features::CodeGraphLike; use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; -use naviscope_java::resolver::JavaResolver; +use naviscope_java::JavaPlugin; #[test] fn test_goto_implementation_interface() { @@ -19,7 +19,7 @@ fn test_goto_implementation_interface() { ), ]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let base_content = &trees[0].1; let base_tree = &trees[0].2; @@ -60,7 +60,7 @@ fn test_goto_implementation_method() { ), ]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let base_content = &trees[0].1; let base_tree = &trees[0].2; diff --git a/crates/lang-java/tests/logic_goto_ref.rs b/crates/lang-java/tests/logic_goto_ref.rs index 5296a21..ee3302a 100644 --- a/crates/lang-java/tests/logic_goto_ref.rs +++ b/crates/lang-java/tests/logic_goto_ref.rs @@ -2,7 +2,7 @@ mod common; use common::{offset_to_point, setup_java_test_graph}; use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; -use naviscope_java::resolver::JavaResolver; +use naviscope_java::JavaPlugin; #[test] fn test_goto_references_method() { @@ -12,7 +12,7 @@ fn test_goto_references_method() { ("C.java", "public class C { void m2(A a) { a.target(); } }"), ]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let a_content = &trees[0].1; let a_tree = &trees[0].2; diff --git a/crates/lang-java/tests/logic_goto_type.rs b/crates/lang-java/tests/logic_goto_type.rs index cb69434..a85d15f 100644 --- a/crates/lang-java/tests/logic_goto_type.rs +++ b/crates/lang-java/tests/logic_goto_type.rs @@ -3,7 +3,7 @@ mod common; use common::{offset_to_point, setup_java_test_graph}; use naviscope_core::features::CodeGraphLike; use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; -use naviscope_java::resolver::JavaResolver; +use naviscope_java::JavaPlugin; #[test] fn test_goto_type_definition_variable() { @@ -15,7 +15,7 @@ fn test_goto_type_definition_variable() { ), ]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let client_content = &trees[1].1; let client_tree = &trees[1].2; @@ -52,7 +52,7 @@ fn test_goto_type_definition_method_return() { ), ]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let service_content = &trees[1].1; let service_tree = &trees[1].2; diff --git a/crates/lang-java/tests/logic_hierarchy.rs b/crates/lang-java/tests/logic_hierarchy.rs index 508cae0..8135158 100644 --- a/crates/lang-java/tests/logic_hierarchy.rs +++ b/crates/lang-java/tests/logic_hierarchy.rs @@ -6,7 +6,6 @@ use naviscope_core::features::discovery::DiscoveryEngine; use naviscope_core::ingest::parser::SymbolResolution; use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; use naviscope_java::JavaPlugin; -use naviscope_java::resolver::JavaResolver; #[test] fn test_call_hierarchy_incoming() { @@ -20,7 +19,7 @@ fn test_call_hierarchy_incoming() { }", )]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let content = &trees[0].1; let tree = &trees[0].2; @@ -86,7 +85,7 @@ fn test_call_hierarchy_outgoing() { }", )]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let content = &trees[0].1; let tree = &trees[0].2; @@ -155,7 +154,7 @@ fn test_call_hierarchy_recursion() { }", )]; let (index, trees) = setup_java_test_graph(files); - let resolver = JavaResolver::new(); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); let content = &trees[0].1; let tree = &trees[0].2; diff --git a/crates/plugin/src/cap/indexing.rs b/crates/plugin/src/cap/indexing.rs index e76308c..4015cdb 100644 --- a/crates/plugin/src/cap/indexing.rs +++ b/crates/plugin/src/cap/indexing.rs @@ -1,6 +1,6 @@ use crate::asset::BoxError; +use crate::indexing::ProjectContext; use crate::model::ParsedFile; -use crate::resolver::ProjectContext; use crate::ResolvedUnit; pub trait SourceIndexCap: Send + Sync { diff --git a/crates/plugin/src/cap/metadata_codec.rs b/crates/plugin/src/cap/metadata_codec.rs index a5d001e..a59aac0 100644 --- a/crates/plugin/src/cap/metadata_codec.rs +++ b/crates/plugin/src/cap/metadata_codec.rs @@ -1,4 +1,4 @@ -use crate::metadata_codec::NodeMetadataCodec; +use crate::core::NodeMetadataCodec; use std::sync::Arc; pub trait MetadataCodecCap: Send + Sync { diff --git a/crates/plugin/src/cap/presentation.rs b/crates/plugin/src/cap/presentation.rs index 878dc73..5636081 100644 --- a/crates/plugin/src/cap/presentation.rs +++ b/crates/plugin/src/cap/presentation.rs @@ -1,5 +1,5 @@ use crate::naming::NamingConvention; -use crate::presentation::NodePresenter; +use crate::core::NodePresenter; use naviscope_api::models::graph::NodeKind; use std::sync::Arc; diff --git a/crates/plugin/src/converter.rs b/crates/plugin/src/converter.rs index cfd6766..6f8b104 100644 --- a/crates/plugin/src/converter.rs +++ b/crates/plugin/src/converter.rs @@ -1,4 +1,4 @@ -use crate::interner::FqnInterner; +use crate::core::FqnInterner; use naviscope_api::models::graph::{DisplayGraphNode, DisplaySymbolLocation, GraphNode}; use naviscope_api::models::symbol::InternedLocation; use std::sync::Arc; diff --git a/crates/plugin/src/codec.rs b/crates/plugin/src/core/codec.rs similarity index 81% rename from crates/plugin/src/codec.rs rename to crates/plugin/src/core/codec.rs index 4f4b445..4a66841 100644 --- a/crates/plugin/src/codec.rs +++ b/crates/plugin/src/core/codec.rs @@ -1,4 +1,4 @@ -use crate::interner::FqnInterner; +use crate::core::FqnInterner; pub trait CodecContext: Send + Sync { fn interner(&mut self) -> &mut dyn FqnInterner; diff --git a/crates/plugin/src/interner.rs b/crates/plugin/src/core/interner.rs similarity index 100% rename from crates/plugin/src/interner.rs rename to crates/plugin/src/core/interner.rs diff --git a/crates/plugin/src/metadata_codec.rs b/crates/plugin/src/core/metadata_codec.rs similarity index 90% rename from crates/plugin/src/metadata_codec.rs rename to crates/plugin/src/core/metadata_codec.rs index 68b1847..fc98096 100644 --- a/crates/plugin/src/metadata_codec.rs +++ b/crates/plugin/src/core/metadata_codec.rs @@ -1,4 +1,4 @@ -use crate::codec::CodecContext; +use crate::core::CodecContext; use naviscope_api::models::graph::{NodeMetadata}; use std::sync::Arc; diff --git a/crates/plugin/src/core/mod.rs b/crates/plugin/src/core/mod.rs new file mode 100644 index 0000000..8a67f3c --- /dev/null +++ b/crates/plugin/src/core/mod.rs @@ -0,0 +1,9 @@ +mod codec; +mod interner; +mod metadata_codec; +mod presentation; + +pub use codec::*; +pub use interner::*; +pub use metadata_codec::*; +pub use presentation::*; diff --git a/crates/plugin/src/presentation.rs b/crates/plugin/src/core/presentation.rs similarity index 100% rename from crates/plugin/src/presentation.rs rename to crates/plugin/src/core/presentation.rs diff --git a/crates/plugin/src/resolver.rs b/crates/plugin/src/indexing/context.rs similarity index 100% rename from crates/plugin/src/resolver.rs rename to crates/plugin/src/indexing/context.rs diff --git a/crates/plugin/src/indexing/mod.rs b/crates/plugin/src/indexing/mod.rs new file mode 100644 index 0000000..1bb1ca5 --- /dev/null +++ b/crates/plugin/src/indexing/mod.rs @@ -0,0 +1,3 @@ +mod context; + +pub use context::*; diff --git a/crates/plugin/src/lib.rs b/crates/plugin/src/lib.rs index 9f5b6c8..4e48d48 100644 --- a/crates/plugin/src/lib.rs +++ b/crates/plugin/src/lib.rs @@ -1,28 +1,22 @@ pub mod asset; pub mod cap; -pub mod codec; +pub mod core; pub mod converter; pub mod graph; -pub mod interner; -pub mod metadata_codec; +pub mod indexing; pub mod model; pub mod naming; -pub mod presentation; pub mod registration; -pub mod resolver; pub mod typing; pub mod utils; pub use asset::*; pub use cap::*; -pub use codec::*; +pub use core::*; pub use converter::*; pub use graph::*; -pub use interner::*; -pub use metadata_codec::*; +pub use indexing::*; pub use model::*; pub use naming::{NamingConvention, StandardNamingConvention}; -pub use presentation::*; pub use registration::*; -pub use resolver::*; pub use typing::*; diff --git a/crates/plugin/src/model.rs b/crates/plugin/src/model.rs index 7115105..8867153 100644 --- a/crates/plugin/src/model.rs +++ b/crates/plugin/src/model.rs @@ -1,4 +1,4 @@ -use crate::interner::SymbolInterner; +use crate::core::SymbolInterner; use naviscope_api::models::graph::{ DisplaySymbolLocation, EdgeType, EmptyMetadata, NodeKind, NodeMetadata, NodeSource, ResolutionStatus, From 84f008ab4c4f853d36c51329708ed6ef5e178ff2 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Thu, 12 Feb 2026 05:31:50 +0800 Subject: [PATCH 19/49] refactor(api): unify result model and update navigation/watch contracts --- crates/api/src/cache.rs | 3 +- crates/api/src/error.rs | 16 +++ crates/api/src/graph.rs | 18 ++- crates/api/src/lib.rs | 4 +- crates/api/src/lifecycle.rs | 21 ++-- crates/api/src/models/fqn.rs | 16 +++ crates/api/src/models/mod.rs | 2 + crates/api/src/models/symbol.rs | 13 +-- crates/api/src/navigation.rs | 10 +- crates/api/src/semantic.rs | 55 +++------ crates/cli/src/shell/completer.rs | 32 ++--- crates/cli/src/shell/context.rs | 7 +- crates/cli/src/shell/handlers.rs | 8 +- crates/cli/src/shell/mod.rs | 21 +++- crates/cli/src/watch.rs | 3 +- crates/core/src/cache/stub_cache.rs | 5 +- crates/core/src/facade/graph.rs | 12 +- crates/core/src/facade/lifecycle.rs | 43 ++++--- crates/core/src/facade/navigation.rs | 15 ++- crates/core/src/facade/semantic.rs | 149 +++++++++++++----------- crates/core/src/features/navigation.rs | 52 ++++++--- crates/core/src/runtime/orchestrator.rs | 37 +++--- crates/core/tests/engine_api.rs | 2 +- crates/lsp/src/indexer.rs | 2 +- crates/runtime/src/lib.rs | 11 +- 25 files changed, 307 insertions(+), 250 deletions(-) create mode 100644 crates/api/src/error.rs create mode 100644 crates/api/src/models/fqn.rs diff --git a/crates/api/src/cache.rs b/crates/api/src/cache.rs index ab1f4a7..e5820b0 100644 --- a/crates/api/src/cache.rs +++ b/crates/api/src/cache.rs @@ -1,3 +1,4 @@ +use crate::ApiResult; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; @@ -41,5 +42,5 @@ pub trait StubCacheManager: Send + Sync { fn inspect_asset(&self, hash_prefix: &str) -> Option; /// Clear all cached data - fn clear(&self) -> Result<(), String>; + fn clear(&self) -> ApiResult<()>; } diff --git a/crates/api/src/error.rs b/crates/api/src/error.rs new file mode 100644 index 0000000..680a953 --- /dev/null +++ b/crates/api/src/error.rs @@ -0,0 +1,16 @@ +#[derive(Debug, thiserror::Error)] +pub enum ApiError { + #[error("Unsupported capability: {capability} for language {language}")] + UnsupportedCapability { + capability: &'static str, + language: String, + }, + #[error("Not found: {0}")] + NotFound(String), + #[error("Invalid argument: {0}")] + InvalidArgument(String), + #[error("Internal error: {0}")] + Internal(String), +} + +pub type ApiResult = std::result::Result; diff --git a/crates/api/src/graph.rs b/crates/api/src/graph.rs index 424bbf2..85fb53a 100644 --- a/crates/api/src/graph.rs +++ b/crates/api/src/graph.rs @@ -1,14 +1,7 @@ +use crate::ApiResult; pub use crate::models::graph::{GraphQuery, QueryResult}; use async_trait::async_trait; -#[derive(Debug, thiserror::Error)] -pub enum GraphError { - #[error("Internal error: {0}")] - Internal(String), -} - -pub type Result = std::result::Result; - #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] pub struct GraphStats { pub node_count: usize, @@ -17,9 +10,12 @@ pub struct GraphStats { #[async_trait] pub trait GraphService: Send + Sync { - async fn query(&self, query: &GraphQuery) -> Result; - async fn get_stats(&self) -> Result; + async fn query(&self, query: &GraphQuery) -> ApiResult; + async fn get_stats(&self) -> ApiResult; /// Get a fully hydrated display node by its FQN. - async fn get_node_display(&self, fqn: &str) -> Result>; + async fn get_node_display( + &self, + fqn: &str, + ) -> ApiResult>; } diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index dab2b33..9625043 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -1,4 +1,5 @@ pub mod cache; +pub mod error; pub mod graph; pub mod lifecycle; pub mod models; @@ -7,8 +8,9 @@ pub mod semantic; // Re-export commonly used types pub use cache::{CacheInspectResult, CacheStats, CachedAssetSummary, StubCacheManager}; +pub use error::{ApiError, ApiResult}; pub use graph::GraphService; -pub use lifecycle::EngineLifecycle; +pub use lifecycle::{EngineLifecycle, EngineWatchHandle}; pub use models::*; pub use navigation::NavigationService; pub use semantic::{CallHierarchyAnalyzer, ReferenceAnalyzer, SymbolInfoProvider, SymbolNavigator}; diff --git a/crates/api/src/lifecycle.rs b/crates/api/src/lifecycle.rs index 344798e..68f1bcd 100644 --- a/crates/api/src/lifecycle.rs +++ b/crates/api/src/lifecycle.rs @@ -1,30 +1,27 @@ +use crate::ApiResult; use async_trait::async_trait; -#[derive(Debug, thiserror::Error)] -pub enum EngineError { - #[error("Internal error: {0}")] - Internal(String), +pub trait EngineWatchHandle: Send + Sync { + fn stop(&self); } -pub type EngineResult = std::result::Result; - #[async_trait] pub trait EngineLifecycle: Send + Sync { /// Rebuild the index from scratch - async fn rebuild(&self) -> EngineResult<()>; + async fn rebuild(&self) -> ApiResult<()>; /// Load the index from disk - async fn load(&self) -> EngineResult; + async fn load(&self) -> ApiResult; /// Save the index to disk - async fn save(&self) -> EngineResult<()>; + async fn save(&self) -> ApiResult<()>; /// Refresh the index (find new files, etc.) - async fn refresh(&self) -> EngineResult<()>; + async fn refresh(&self) -> ApiResult<()>; /// Watch for filesystem changes - async fn watch(&self) -> EngineResult<()>; + async fn start_watch(&self) -> ApiResult>; /// Clear the index for the current project - async fn clear_index(&self) -> EngineResult<()>; + async fn clear_index(&self) -> ApiResult<()>; } diff --git a/crates/api/src/models/fqn.rs b/crates/api/src/models/fqn.rs new file mode 100644 index 0000000..dd32df0 --- /dev/null +++ b/crates/api/src/models/fqn.rs @@ -0,0 +1,16 @@ +use super::graph::NodeKind; +use super::symbol::{FqnId, Symbol}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, JsonSchema)] +pub struct FqnNode { + pub parent: Option, + pub name: Symbol, + pub kind: NodeKind, +} + +pub trait FqnReader { + fn resolve_node(&self, id: FqnId) -> Option; + fn resolve_atom(&self, atom: Symbol) -> &str; +} diff --git a/crates/api/src/models/mod.rs b/crates/api/src/models/mod.rs index e37c9e1..b2b6137 100644 --- a/crates/api/src/models/mod.rs +++ b/crates/api/src/models/mod.rs @@ -1,8 +1,10 @@ +pub mod fqn; pub mod graph; pub mod language; pub mod symbol; pub mod util; +pub use fqn::*; pub use graph::*; pub use language::*; pub use symbol::*; diff --git a/crates/api/src/models/symbol.rs b/crates/api/src/models/symbol.rs index 10b239f..6be573a 100644 --- a/crates/api/src/models/symbol.rs +++ b/crates/api/src/models/symbol.rs @@ -1,3 +1,4 @@ +pub use super::fqn::{FqnNode, FqnReader}; use super::graph::{DisplayGraphNode, NodeKind}; use super::language::Language; use schemars::JsonSchema; @@ -85,18 +86,6 @@ impl JsonSchema for FqnId { } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Hash, JsonSchema)] -pub struct FqnNode { - pub parent: Option, - pub name: Symbol, - pub kind: NodeKind, -} - -pub trait FqnReader { - fn resolve_node(&self, id: FqnId) -> Option; - fn resolve_atom(&self, atom: Symbol) -> &str; -} - #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, JsonSchema)] pub struct Range { pub start_line: usize, diff --git a/crates/api/src/navigation.rs b/crates/api/src/navigation.rs index 2781720..b4e110b 100644 --- a/crates/api/src/navigation.rs +++ b/crates/api/src/navigation.rs @@ -1,3 +1,4 @@ +use crate::error::ApiResult; use async_trait::async_trait; /// Result of resolving a user-provided path to a node FQN. @@ -53,8 +54,13 @@ pub trait NavigationService: Send + Sync { /// service.resolve_path("../OtherClass", Some("com.example.MyClass")).await /// // => Found("com.example.OtherClass") /// ``` - async fn resolve_path(&self, target: &str, current_context: Option<&str>) -> ResolveResult; + async fn resolve_path( + &self, + target: &str, + current_context: Option<&str>, + ) -> ApiResult; /// Get completion candidates for a prefix. - async fn get_completion_candidates(&self, prefix: &str) -> Vec; + async fn get_completion_candidates(&self, prefix: &str, limit: usize) + -> ApiResult>; } diff --git a/crates/api/src/semantic.rs b/crates/api/src/semantic.rs index 560fc70..3be995c 100644 --- a/crates/api/src/semantic.rs +++ b/crates/api/src/semantic.rs @@ -1,21 +1,9 @@ +use crate::ApiResult; use crate::models::{ CallHierarchyIncomingCall, CallHierarchyOutgoingCall, DisplayGraphNode, PositionContext, ReferenceQuery, SymbolLocation, SymbolQuery, SymbolResolution, }; use async_trait::async_trait; -use thiserror::Error; - -#[derive(Error, Debug)] -pub enum SemanticError { - #[error("Symbol not found: {0}")] - SymbolNotFound(String), - #[error("Internal error: {0}")] - Internal(String), - #[error("Language not supported: {0}")] - UnsupportedLanguage(String), -} - -pub type SemanticResult = std::result::Result; // ============================================================================ // Core Semantic Traits - Balanced Granularity @@ -26,68 +14,51 @@ pub type SemanticResult = std::result::Result; #[async_trait] pub trait SymbolNavigator: Send + Sync { /// Resolve the symbol at a specific position in the source code. - async fn resolve_symbol_at( - &self, - ctx: &PositionContext, - ) -> SemanticResult>; + async fn resolve_symbol_at(&self, ctx: &PositionContext) + -> ApiResult>; /// Find all definition locations for a given symbol query. - async fn find_definitions(&self, query: &SymbolQuery) -> SemanticResult>; + async fn find_definitions(&self, query: &SymbolQuery) -> ApiResult>; /// Find type definition locations (e.g., the class definition of a variable's type). - async fn find_type_definitions( - &self, - query: &SymbolQuery, - ) -> SemanticResult>; + async fn find_type_definitions(&self, query: &SymbolQuery) -> ApiResult>; /// Find all implementation locations (e.g., classes implementing an interface). - async fn find_implementations( - &self, - query: &SymbolQuery, - ) -> SemanticResult>; + async fn find_implementations(&self, query: &SymbolQuery) -> ApiResult>; /// Find occurrences of a symbol for document highlighting. - async fn find_highlights( - &self, - ctx: &PositionContext, - ) -> SemanticResult>; + async fn find_highlights(&self, ctx: &PositionContext) -> ApiResult>; } /// Reference analysis: find all usages of a symbol. #[async_trait] pub trait ReferenceAnalyzer: Send + Sync { /// Find all reference locations for a given reference query. - async fn find_references(&self, query: &ReferenceQuery) -> SemanticResult>; + async fn find_references(&self, query: &ReferenceQuery) -> ApiResult>; } /// Call hierarchy analysis: incoming and outgoing calls. #[async_trait] pub trait CallHierarchyAnalyzer: Send + Sync { /// Find all incoming calls (callers) to the specified function/method. - async fn find_incoming_calls( - &self, - fqn: &str, - ) -> SemanticResult>; + async fn find_incoming_calls(&self, fqn: &str) -> ApiResult>; /// Find all outgoing calls (callees) from the specified function/method. - async fn find_outgoing_calls( - &self, - fqn: &str, - ) -> SemanticResult>; + async fn find_outgoing_calls(&self, fqn: &str) -> ApiResult>; } /// Symbol metadata provider: detailed information about symbols. #[async_trait] pub trait SymbolInfoProvider: Send + Sync { /// Get detailed information about a symbol by its FQN. - async fn get_symbol_info(&self, fqn: &str) -> SemanticResult>; + async fn get_symbol_info(&self, fqn: &str) -> ApiResult>; /// Get all symbols defined in a specific document. - async fn get_document_symbols(&self, uri: &str) -> SemanticResult>; + async fn get_document_symbols(&self, uri: &str) -> ApiResult>; /// Get the language of a specific document. async fn get_language_for_document( &self, uri: &str, - ) -> SemanticResult>; + ) -> ApiResult>; } diff --git a/crates/cli/src/shell/completer.rs b/crates/cli/src/shell/completer.rs index de8083c..bd2484a 100644 --- a/crates/cli/src/shell/completer.rs +++ b/crates/cli/src/shell/completer.rs @@ -72,27 +72,29 @@ impl<'a> Completer for NaviscopeCompleter<'a> { tokio::task::block_in_place(|| { self.context .rt_handle - .block_on(nav_service.get_completion_candidates(last_word)) + .block_on(nav_service.get_completion_candidates(last_word, 50)) }) } else { self.context .rt_handle - .block_on(nav_service.get_completion_candidates(last_word)) + .block_on(nav_service.get_completion_candidates(last_word, 50)) }; - for fqn in matches { - suggestions.push(Suggestion { - value: fqn, - description: None, - style: None, - extra: None, - span: reedline::Span { - start: span_start, - end: pos, - }, - append_whitespace: true, - match_indices: None, - }); + if let Ok(matches) = matches { + for fqn in matches { + suggestions.push(Suggestion { + value: fqn, + description: None, + style: None, + extra: None, + span: reedline::Span { + start: span_start, + end: pos, + }, + append_whitespace: true, + match_indices: None, + }); + } } } diff --git a/crates/cli/src/shell/context.rs b/crates/cli/src/shell/context.rs index 1e9d52a..95c8434 100644 --- a/crates/cli/src/shell/context.rs +++ b/crates/cli/src/shell/context.rs @@ -50,11 +50,11 @@ impl ShellContext { } /// Resolves a user input path using the NavigationService API. - pub fn resolve_node(&self, target: &str) -> ResolveResult { + pub fn resolve_node(&self, target: &str) -> Result> { let nav_service: &dyn NavigationService = self.engine.as_ref(); let current_context = self.current_fqn(); - if tokio::runtime::Handle::try_current().is_ok() { + let result = if tokio::runtime::Handle::try_current().is_ok() { tokio::task::block_in_place(|| { self.rt_handle .block_on(nav_service.resolve_path(target, current_context.as_deref())) @@ -62,6 +62,7 @@ impl ShellContext { } else { self.rt_handle .block_on(nav_service.resolve_path(target, current_context.as_deref())) - } + }; + Ok(result?) } } diff --git a/crates/cli/src/shell/handlers.rs b/crates/cli/src/shell/handlers.rs index 34fcc9b..8a69869 100644 --- a/crates/cli/src/shell/handlers.rs +++ b/crates/cli/src/shell/handlers.rs @@ -18,7 +18,7 @@ impl CommandHandler for CdHandler { context: &mut ShellContext, ) -> Result> { if let ShellCommand::Cd { path } = cmd { - match context.resolve_node(path) { + 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); @@ -48,7 +48,7 @@ impl CommandHandler for CatHandler { ) -> Result> { if let ShellCommand::Cat { target } = cmd { // First resolve the target to a concrete FQN - let fqn = match context.resolve_node(target) { + let fqn = match context.resolve_node(target)? { ResolveResult::Found(f) => f, ResolveResult::Ambiguous(candidates) => { let mut msg = @@ -102,7 +102,7 @@ impl CommandHandler for GenericQueryHandler { all, } => { resolved_target_fqn = match context.resolve_node(target) { - ResolveResult::Found(f) => Some(f), + Ok(ResolveResult::Found(f)) => Some(f), _ => Some(target.clone()), }; ShellCommand::Ls { @@ -120,7 +120,7 @@ impl CommandHandler for GenericQueryHandler { edge_types, } => { resolved_target_fqn = match context.resolve_node(target) { - ResolveResult::Found(f) => Some(f), + Ok(ResolveResult::Found(f)) => Some(f), _ => Some(target.clone()), }; ShellCommand::Deps { diff --git a/crates/cli/src/shell/mod.rs b/crates/cli/src/shell/mod.rs index 03edcb1..351973a 100644 --- a/crates/cli/src/shell/mod.rs +++ b/crates/cli/src/shell/mod.rs @@ -48,16 +48,25 @@ impl ReplServer { self.initialize_index().await?; // Start watcher (spawns background task on the runtime) - if let Err(e) = self.context.engine.watch().await { - error!("Failed to start file watcher: {}", e); - } else { - info!("File watcher started."); - } + let watch_handle = match self.context.engine.start_watch().await { + Ok(handle) => { + info!("File watcher started."); + Some(handle) + } + Err(e) => { + error!("Failed to start file watcher: {}", e); + None + } + }; println!("Type 'help' for commands."); let line_editor = self.setup_line_editor()?; - self.run_loop(line_editor) + let run_result = self.run_loop(line_editor); + if let Some(handle) = watch_handle { + handle.stop(); + } + run_result } async fn initialize_index(&self) -> Result<(), Box> { diff --git a/crates/cli/src/watch.rs b/crates/cli/src/watch.rs index cec0a3c..8688c77 100644 --- a/crates/cli/src/watch.rs +++ b/crates/cli/src/watch.rs @@ -9,12 +9,13 @@ pub async fn run(path: PathBuf) -> Result<(), Box> { info!("Initial indexing complete."); // Start background watcher via trait - engine.watch().await?; + let watch_handle = engine.start_watch().await?; info!("File watcher started. Ready for changes."); info!("Press Ctrl+C to stop."); // Keep the main thread alive tokio::signal::ctrl_c().await?; + watch_handle.stop(); info!("Watcher stopped."); Ok(()) diff --git a/crates/core/src/cache/stub_cache.rs b/crates/core/src/cache/stub_cache.rs index 5d42df0..e765d12 100644 --- a/crates/core/src/cache/stub_cache.rs +++ b/crates/core/src/cache/stub_cache.rs @@ -135,6 +135,7 @@ pub struct GlobalStubCache { } use naviscope_api::cache::{CacheInspectResult, CacheStats, CachedAssetSummary, StubCacheManager}; +use naviscope_api::{ApiError, ApiResult}; impl StubCacheManager for GlobalStubCache { fn stats(&self) -> CacheStats { @@ -149,8 +150,8 @@ impl StubCacheManager for GlobalStubCache { self.inspect_asset(hash_prefix) } - fn clear(&self) -> Result<(), String> { - self.clear().map_err(|e| e.to_string()) + fn clear(&self) -> ApiResult<()> { + self.clear().map_err(|e| ApiError::Internal(e.to_string())) } } diff --git a/crates/core/src/facade/graph.rs b/crates/core/src/facade/graph.rs index 0020a34..a4d9473 100644 --- a/crates/core/src/facade/graph.rs +++ b/crates/core/src/facade/graph.rs @@ -2,11 +2,11 @@ use super::EngineHandle; use crate::error::NaviscopeError; use crate::features::query::QueryEngine; use async_trait::async_trait; -use naviscope_api::{graph, models}; +use naviscope_api::{ApiError, ApiResult, graph, models}; #[async_trait] impl graph::GraphService for EngineHandle { - async fn query(&self, query: &models::GraphQuery) -> graph::Result { + async fn query(&self, query: &models::GraphQuery) -> ApiResult { let graph = self.graph().await; let query_clone = query.clone(); let handle = self.clone(); @@ -20,8 +20,8 @@ impl graph::GraphService for EngineHandle { }, ) .await - .map_err(|e| graph::GraphError::Internal(e.to_string()))? - .map_err(|e| graph::GraphError::Internal(e.to_string()))?; + .map_err(|e| ApiError::Internal(e.to_string()))? + .map_err(|e| ApiError::Internal(e.to_string()))?; Ok(models::QueryResult { nodes: result.nodes, @@ -29,7 +29,7 @@ impl graph::GraphService for EngineHandle { }) } - async fn get_stats(&self) -> graph::Result { + async fn get_stats(&self) -> ApiResult { let graph = self.graph().await; Ok(graph::GraphStats { node_count: graph.topology().node_count(), @@ -37,7 +37,7 @@ impl graph::GraphService for EngineHandle { }) } - async fn get_node_display(&self, fqn: &str) -> graph::Result> { + async fn get_node_display(&self, fqn: &str) -> ApiResult> { let query = models::GraphQuery::Cat { fqn: fqn.to_string(), }; diff --git a/crates/core/src/facade/lifecycle.rs b/crates/core/src/facade/lifecycle.rs index 0246e5d..073ade5 100644 --- a/crates/core/src/facade/lifecycle.rs +++ b/crates/core/src/facade/lifecycle.rs @@ -1,50 +1,65 @@ use super::EngineHandle; use crate::error::NaviscopeError; use async_trait::async_trait; -use naviscope_api::lifecycle::{EngineError, EngineLifecycle}; +use naviscope_api::lifecycle::{EngineLifecycle, EngineWatchHandle}; +use naviscope_api::{ApiError, ApiResult}; +use std::sync::Arc; + +struct WatchHandle { + token: tokio_util::sync::CancellationToken, +} + +impl EngineWatchHandle for WatchHandle { + fn stop(&self) { + self.token.cancel(); + } +} #[async_trait] impl EngineLifecycle for EngineHandle { - async fn rebuild(&self) -> naviscope_api::lifecycle::EngineResult<()> { + async fn rebuild(&self) -> ApiResult<()> { self.engine .rebuild() .await - .map_err(|e| EngineError::Internal(e.to_string())) + .map_err(|e| ApiError::Internal(e.to_string())) } - async fn load(&self) -> naviscope_api::lifecycle::EngineResult { + async fn load(&self) -> ApiResult { self.engine .load() .await - .map_err(|e| EngineError::Internal(e.to_string())) + .map_err(|e| ApiError::Internal(e.to_string())) } - async fn save(&self) -> naviscope_api::lifecycle::EngineResult<()> { + async fn save(&self) -> ApiResult<()> { self.engine .save() .await - .map_err(|e| EngineError::Internal(e.to_string())) + .map_err(|e| ApiError::Internal(e.to_string())) } - async fn refresh(&self) -> naviscope_api::lifecycle::EngineResult<()> { + async fn refresh(&self) -> ApiResult<()> { self.engine .refresh() .await - .map_err(|e| EngineError::Internal(e.to_string())) + .map_err(|e| ApiError::Internal(e.to_string())) } - async fn watch(&self) -> naviscope_api::lifecycle::EngineResult<()> { + async fn start_watch(&self) -> ApiResult> { + let watch_token = tokio_util::sync::CancellationToken::new(); self.engine .clone() - .watch() + .start_watch_with_token(watch_token.clone()) .await - .map_err(|e: NaviscopeError| EngineError::Internal(e.to_string())) + .map_err(|e: NaviscopeError| ApiError::Internal(e.to_string()))?; + + Ok(Arc::new(WatchHandle { token: watch_token })) } - async fn clear_index(&self) -> naviscope_api::lifecycle::EngineResult<()> { + async fn clear_index(&self) -> ApiResult<()> { self.engine .clear_project_index() .await - .map_err(|e: NaviscopeError| EngineError::Internal(e.to_string())) + .map_err(|e: NaviscopeError| ApiError::Internal(e.to_string())) } } diff --git a/crates/core/src/facade/navigation.rs b/crates/core/src/facade/navigation.rs index 13948db..8de01aa 100644 --- a/crates/core/src/facade/navigation.rs +++ b/crates/core/src/facade/navigation.rs @@ -1,21 +1,30 @@ use crate::facade::EngineHandle; use crate::features::navigation::NavigationEngine; use async_trait::async_trait; +use naviscope_api::ApiResult; use naviscope_api::navigation::{NavigationService, ResolveResult}; #[async_trait] impl NavigationService for EngineHandle { - async fn resolve_path(&self, target: &str, current_context: Option<&str>) -> ResolveResult { + async fn resolve_path( + &self, + target: &str, + current_context: Option<&str>, + ) -> ApiResult { let graph = self.graph().await; let conventions = (*self.naming_conventions()).clone(); let engine = NavigationEngine::new(&graph, conventions); engine.resolve_path(target, current_context) } - async fn get_completion_candidates(&self, prefix: &str) -> Vec { + async fn get_completion_candidates( + &self, + prefix: &str, + limit: usize, + ) -> ApiResult> { let graph = self.graph().await; let conventions = (*self.naming_conventions()).clone(); let engine = NavigationEngine::new(&graph, conventions); - engine.get_completion_candidates(prefix) + engine.get_completion_candidates(prefix, limit) } } diff --git a/crates/core/src/facade/semantic.rs b/crates/core/src/facade/semantic.rs index 3e90af6..0d1126b 100644 --- a/crates/core/src/facade/semantic.rs +++ b/crates/core/src/facade/semantic.rs @@ -4,14 +4,14 @@ use crate::features::discovery::DiscoveryEngine; use crate::util::utf16_col_to_byte_col; use async_trait::async_trait; use naviscope_api::graph::GraphService; +use naviscope_api::{ApiError, ApiResult}; use naviscope_api::models::{ CallHierarchyIncomingCall, CallHierarchyOutgoingCall, DisplayGraphNode, Language, NodeKind, PositionContext, Range, ReferenceQuery, SymbolLocation, SymbolQuery, SymbolResolution, }; use naviscope_api::semantic::{ - CallHierarchyAnalyzer, ReferenceAnalyzer, SemanticError, SemanticResult, SymbolInfoProvider, - SymbolNavigator, + CallHierarchyAnalyzer, ReferenceAnalyzer, SymbolInfoProvider, SymbolNavigator, }; use std::collections::{HashMap, HashSet}; use std::fs; @@ -23,7 +23,7 @@ impl SymbolNavigator for EngineHandle { async fn resolve_symbol_at( &self, ctx: &PositionContext, - ) -> SemanticResult> { + ) -> ApiResult> { let uri_str = &ctx.uri; let path = if uri_str.starts_with("file://") { PathBuf::from(uri_str.strip_prefix("file://").unwrap()) @@ -33,18 +33,23 @@ impl SymbolNavigator for EngineHandle { let (semantic, _lang) = match self.get_services_for_path(&path) { Some(x) => x, - None => return Ok(None), + None => { + return Err(ApiError::UnsupportedCapability { + capability: "semantic.resolve_symbol_at", + language: "unknown".to_string(), + }); + } }; let content = if let Some(c) = &ctx.content { c.clone() } else { - fs::read_to_string(&path).map_err(|e| SemanticError::Internal(e.to_string()))? + fs::read_to_string(&path).map_err(|e| ApiError::Internal(e.to_string()))? }; let tree = semantic .parse(&content, None) - .ok_or_else(|| SemanticError::Internal("Failed to parse".into()))?; + .ok_or_else(|| ApiError::Internal("Failed to parse".into()))?; let byte_col = utf16_col_to_byte_col(&content, ctx.line as usize, ctx.char as usize); @@ -53,7 +58,7 @@ impl SymbolNavigator for EngineHandle { Ok(semantic.resolve_at(&tree, &content, ctx.line as usize, byte_col, &graph)) } - async fn find_highlights(&self, ctx: &PositionContext) -> SemanticResult> { + async fn find_highlights(&self, ctx: &PositionContext) -> ApiResult> { let uri_str = &ctx.uri; let path = if uri_str.starts_with("file://") { PathBuf::from(uri_str.strip_prefix("file://").unwrap()) @@ -63,18 +68,23 @@ impl SymbolNavigator for EngineHandle { let (semantic, _) = match self.get_services_for_path(&path) { Some(x) => x, - None => return Ok(vec![]), + None => { + return Err(ApiError::UnsupportedCapability { + capability: "semantic.find_highlights", + language: "unknown".to_string(), + }); + } }; let content = if let Some(c) = &ctx.content { c.clone() } else { - fs::read_to_string(&path).map_err(|e| SemanticError::Internal(e.to_string()))? + fs::read_to_string(&path).map_err(|e| ApiError::Internal(e.to_string()))? }; let tree = semantic .parse(&content, None) - .ok_or_else(|| SemanticError::Internal("Failed to parse".into()))?; + .ok_or_else(|| ApiError::Internal("Failed to parse".into()))?; let res = match self.resolve_symbol_at(ctx).await? { Some(r) => r, @@ -84,10 +94,15 @@ impl SymbolNavigator for EngineHandle { Ok(semantic.find_occurrences(&content, &tree, &res)) } - async fn find_definitions(&self, query: &SymbolQuery) -> SemanticResult> { + async fn find_definitions(&self, query: &SymbolQuery) -> ApiResult> { let resolver = match self.get_semantic_resolver(query.language.clone()) { Some(r) => r, - None => return Ok(vec![]), + None => { + return Err(ApiError::UnsupportedCapability { + capability: "semantic.find_definitions", + language: query.language.to_string(), + }); + } }; let graph = self.graph().await; @@ -112,13 +127,15 @@ impl SymbolNavigator for EngineHandle { Ok(locations) } - async fn find_type_definitions( - &self, - query: &SymbolQuery, - ) -> SemanticResult> { + async fn find_type_definitions(&self, query: &SymbolQuery) -> ApiResult> { let resolver = match self.get_semantic_resolver(query.language.clone()) { Some(r) => r, - None => return Ok(vec![]), + None => { + return Err(ApiError::UnsupportedCapability { + capability: "semantic.find_type_definitions", + language: query.language.to_string(), + }); + } }; let graph = self.graph().await; @@ -145,13 +162,15 @@ impl SymbolNavigator for EngineHandle { Ok(locations) } - async fn find_implementations( - &self, - query: &SymbolQuery, - ) -> SemanticResult> { + async fn find_implementations(&self, query: &SymbolQuery) -> ApiResult> { let resolver = match self.get_semantic_resolver(query.language.clone()) { Some(r) => r, - None => return Ok(vec![]), + None => { + return Err(ApiError::UnsupportedCapability { + capability: "semantic.find_implementations", + language: query.language.to_string(), + }); + } }; let graph = self.graph().await; let matches = resolver.find_implementations(&graph, &query.resolution); @@ -178,10 +197,15 @@ impl SymbolNavigator for EngineHandle { #[async_trait] impl ReferenceAnalyzer for EngineHandle { - async fn find_references(&self, query: &ReferenceQuery) -> SemanticResult> { + async fn find_references(&self, query: &ReferenceQuery) -> ApiResult> { let resolver = match self.get_semantic_resolver(query.language.clone()) { Some(r) => r, - None => return Ok(vec![]), + None => { + return Err(ApiError::UnsupportedCapability { + capability: "semantic.find_references", + language: query.language.to_string(), + }); + } }; let graph = self.graph().await; @@ -210,11 +234,10 @@ impl ReferenceAnalyzer for EngineHandle { let conventions_clone = conventions.clone(); tasks.spawn(async move { - let (semantic, _file_lang) = - match handle.get_services_for_path(&path) { - Some(x) => x, - None => return Vec::new(), - }; + let (semantic, _file_lang) = match handle.get_services_for_path(&path) { + Some(x) => x, + None => return Vec::new(), + }; let content = match fs::read_to_string(&path) { Ok(c) => c, @@ -229,12 +252,7 @@ impl ReferenceAnalyzer for EngineHandle { Err(_) => return Vec::new(), }; - let locations = discovery.scan_file( - semantic.as_ref(), - &content, - &resolution, - &uri, - ); + let locations = discovery.scan_file(semantic.as_ref(), &content, &resolution, &uri); locations .into_iter() @@ -299,10 +317,7 @@ impl ReferenceAnalyzer for EngineHandle { #[async_trait] impl CallHierarchyAnalyzer for EngineHandle { - async fn find_incoming_calls( - &self, - fqn: &str, - ) -> SemanticResult> { + async fn find_incoming_calls(&self, fqn: &str) -> ApiResult> { let graph = self.graph().await; let mut target_indices = graph.find_matches_by_fqn(fqn); @@ -351,11 +366,10 @@ impl CallHierarchyAnalyzer for EngineHandle { let conventions_clone = conventions.clone(); tasks.spawn(async move { - let (semantic, _file_lang) = - match handle.get_services_for_path(&path) { - Some(x) => x, - None => return vec![], - }; + let (semantic, _file_lang) = match handle.get_services_for_path(&path) { + Some(x) => x, + None => return vec![], + }; let content = match fs::read_to_string(&path) { Ok(c) => c, @@ -370,12 +384,7 @@ impl CallHierarchyAnalyzer for EngineHandle { }; // Verification - discovery.scan_file( - semantic.as_ref(), - &content, - &res, - &uri, - ) + discovery.scan_file(semantic.as_ref(), &content, &res, &uri) }); } @@ -426,7 +435,7 @@ impl CallHierarchyAnalyzer for EngineHandle { if let Some(display_node) = self .get_node_display(&fqn_str) .await - .map_err(|e| SemanticError::Internal(e.to_string()))? + .map_err(|e| ApiError::Internal(e.to_string()))? { results.push(CallHierarchyIncomingCall { from: display_node, @@ -438,10 +447,7 @@ impl CallHierarchyAnalyzer for EngineHandle { Ok(results) } - async fn find_outgoing_calls( - &self, - fqn: &str, - ) -> SemanticResult> { + async fn find_outgoing_calls(&self, fqn: &str) -> ApiResult> { let graph = self.graph().await; let conventions = (*self.naming_conventions()).clone(); let node_idx = match graph.find_node(fqn) { @@ -453,24 +459,23 @@ impl CallHierarchyAnalyzer for EngineHandle { let symbols = graph.symbols(); let path_str = node .path(symbols) - .ok_or_else(|| SemanticError::Internal("Node has no path".into()))?; + .ok_or_else(|| ApiError::Internal("Node has no path".into()))?; let path = PathBuf::from(path_str); let range = node .range() - .ok_or_else(|| SemanticError::Internal("Node has no range".into()))?; + .ok_or_else(|| ApiError::Internal("Node has no range".into()))?; let (semantic, _lang) = self .get_services_for_path(&path) - .ok_or_else(|| SemanticError::Internal("No services for file".into()))?; + .ok_or_else(|| ApiError::Internal("No services for file".into()))?; - let content = - fs::read_to_string(&path).map_err(|e| SemanticError::Internal(e.to_string()))?; + let content = fs::read_to_string(&path).map_err(|e| ApiError::Internal(e.to_string()))?; // Micro-level scanning: extract method body and find all calls let tree = semantic .parse(&content, None) - .ok_or_else(|| SemanticError::Internal("Failed to parse".into()))?; + .ok_or_else(|| ApiError::Internal("Failed to parse".into()))?; let mut outgoing_calls: HashMap> = HashMap::new(); @@ -538,7 +543,7 @@ impl CallHierarchyAnalyzer for EngineHandle { if let Some(display_node) = self .get_node_display(&fqn_str) .await - .map_err(|e| SemanticError::Internal(e.to_string()))? + .map_err(|e| ApiError::Internal(e.to_string()))? { results.push(CallHierarchyOutgoingCall { to: display_node, @@ -553,13 +558,13 @@ impl CallHierarchyAnalyzer for EngineHandle { #[async_trait] impl SymbolInfoProvider for EngineHandle { - async fn get_symbol_info(&self, fqn: &str) -> SemanticResult> { + async fn get_symbol_info(&self, fqn: &str) -> ApiResult> { self.get_node_display(fqn) .await - .map_err(|e| SemanticError::Internal(e.to_string())) + .map_err(|e| ApiError::Internal(e.to_string())) } - async fn get_document_symbols(&self, uri: &str) -> SemanticResult> { + async fn get_document_symbols(&self, uri: &str) -> ApiResult> { let path = if uri.starts_with("file://") { PathBuf::from(uri.strip_prefix("file://").unwrap()) } else { @@ -568,22 +573,26 @@ impl SymbolInfoProvider for EngineHandle { let (semantic, _lang) = match self.get_services_for_path(&path) { Some(x) => x, - None => return Ok(vec![]), + None => { + return Err(ApiError::UnsupportedCapability { + capability: "semantic.get_document_symbols", + language: "unknown".to_string(), + }); + } }; - let content = - fs::read_to_string(&path).map_err(|e| SemanticError::Internal(e.to_string()))?; + let content = fs::read_to_string(&path).map_err(|e| ApiError::Internal(e.to_string()))?; let tree = semantic .parse(&content, None) - .ok_or_else(|| SemanticError::Internal("Failed to parse".into()))?; + .ok_or_else(|| ApiError::Internal("Failed to parse".into()))?; let symbols = semantic.extract_symbols(&tree, &content); Ok(symbols) } - async fn get_language_for_document(&self, uri: &str) -> SemanticResult> { + async fn get_language_for_document(&self, uri: &str) -> ApiResult> { let path = if uri.starts_with("file://") { PathBuf::from(uri.strip_prefix("file://").unwrap()) } else { diff --git a/crates/core/src/features/navigation.rs b/crates/core/src/features/navigation.rs index 55f1180..6a53296 100644 --- a/crates/core/src/features/navigation.rs +++ b/crates/core/src/features/navigation.rs @@ -1,30 +1,42 @@ use super::CodeGraphLike; use crate::model::{EdgeType, NodeKind}; use naviscope_api::navigation::ResolveResult; +use naviscope_api::{ApiError, ApiResult}; /// NavigationEngine provides logic for resolving fuzzy/relative paths within a graph. pub struct NavigationEngine<'a> { graph: &'a dyn CodeGraphLike, - naming_conventions: std::collections::HashMap>, + naming_conventions: + std::collections::HashMap>, } impl<'a> NavigationEngine<'a> { pub fn new( graph: &'a dyn CodeGraphLike, - naming_conventions: std::collections::HashMap>, + naming_conventions: std::collections::HashMap< + String, + std::sync::Arc, + >, ) -> Self { Self { graph, naming_conventions, } } - - fn get_convention(&self, node: &crate::model::GraphNode) -> Option<&dyn naviscope_plugin::NamingConvention> { + + fn get_convention( + &self, + node: &crate::model::GraphNode, + ) -> Option<&dyn naviscope_plugin::NamingConvention> { let lang_str = self.graph.symbols().resolve(&node.lang.0); self.naming_conventions.get(lang_str).map(|c| c.as_ref()) } - pub fn resolve_path(&self, target: &str, current_context: Option<&str>) -> ResolveResult { + pub fn resolve_path( + &self, + target: &str, + current_context: Option<&str>, + ) -> ApiResult { // 1. Handle special paths ("/" or "root") if target == "/" || target == "root" { let project_nodes: Vec<_> = self @@ -43,9 +55,9 @@ impl<'a> NavigationEngine<'a> { .collect(); return match project_nodes.len() { - 1 => ResolveResult::Found(project_nodes[0].clone()), - 0 => ResolveResult::Found("".to_string()), - _ => ResolveResult::Ambiguous(project_nodes), + 1 => Ok(ResolveResult::Found(project_nodes[0].clone())), + 0 => Err(ApiError::NotFound("project root node".to_string())), + _ => Ok(ResolveResult::Ambiguous(project_nodes)), }; } @@ -67,18 +79,20 @@ impl<'a> NavigationEngine<'a> { if let Some(parent_node) = self.graph.topology().node_weight(parent_idx) { let convention = self.get_convention(parent_node); - return ResolveResult::Found(self.graph.render_fqn(parent_node, convention)); + return Ok(ResolveResult::Found( + self.graph.render_fqn(parent_node, convention), + )); } } } } } - return ResolveResult::NotFound; + return Ok(ResolveResult::NotFound); } // 3. Try exact match (absolute FQN) if self.graph.find_node(target).is_some() { - return ResolveResult::Found(target.to_string()); + return Ok(ResolveResult::Found(target.to_string())); } // 4. Try relative path from current context @@ -90,7 +104,7 @@ impl<'a> NavigationEngine<'a> { }; let joined = format!("{}{}{}", current_fqn, separator, target); if self.graph.find_node(&joined).is_some() { - return ResolveResult::Found(joined); + return Ok(ResolveResult::Found(joined)); } } @@ -152,15 +166,16 @@ impl<'a> NavigationEngine<'a> { .collect() }; - match candidates.len() { + Ok(match candidates.len() { 0 => ResolveResult::NotFound, 1 => ResolveResult::Found(candidates[0].clone()), _ => ResolveResult::Ambiguous(candidates), - } + }) } - pub fn get_completion_candidates(&self, prefix: &str) -> Vec { - self.graph + pub fn get_completion_candidates(&self, prefix: &str, limit: usize) -> ApiResult> { + let candidates = self + .graph .fqn_map() .keys() .filter_map(|&fid| { @@ -174,7 +189,8 @@ impl<'a> NavigationEngine<'a> { } None }) - .take(50) // Reasonable limit for candidates - .collect() + .take(limit) + .collect(); + Ok(candidates) } } diff --git a/crates/core/src/runtime/orchestrator.rs b/crates/core/src/runtime/orchestrator.rs index bb4e49d..23099c5 100644 --- a/crates/core/src/runtime/orchestrator.rs +++ b/crates/core/src/runtime/orchestrator.rs @@ -262,11 +262,10 @@ impl NaviscopeEngine { let build_caps = self.build_caps.clone(); // Load in blocking pool - let graph_opt = tokio::task::spawn_blocking(move || { - Self::load_from_disk(&path, lang_caps, build_caps) - }) - .await - .map_err(|e| NaviscopeError::Internal(e.to_string()))??; + let graph_opt = + tokio::task::spawn_blocking(move || Self::load_from_disk(&path, lang_caps, build_caps)) + .await + .map_err(|e| NaviscopeError::Internal(e.to_string()))??; if let Some(graph) = graph_opt { // Atomically update current @@ -302,13 +301,7 @@ impl NaviscopeEngine { let stub_tx = self.stub_tx.clone(); let (new_graph, stubs) = tokio::task::spawn_blocking(move || { - Self::build_index( - &project_root, - build_caps, - lang_caps, - stub_tx, - global_routes, - ) + Self::build_index(&project_root, build_caps, lang_caps, stub_tx, global_routes) }) .await .map_err(|e| NaviscopeError::Internal(e.to_string()))??; @@ -381,9 +374,8 @@ impl NaviscopeEngine { let (build_files, source_files): (Vec<_>, Vec<_>) = scan_results.into_iter().partition(|f| f.is_build()); - let resolver = - IndexResolver::with_caps((*build_caps).clone(), (*lang_caps).clone()) - .with_stubbing(StubbingManager::new(stub_tx.clone())); + let resolver = IndexResolver::with_caps((*build_caps).clone(), (*lang_caps).clone()) + .with_stubbing(StubbingManager::new(stub_tx.clone())); // 2. Phase 1: Heavy Build Resolution (Global Context) let mut project_context_inner = crate::ingest::resolver::ProjectContext::new(); @@ -473,8 +465,12 @@ impl NaviscopeEngine { self.update_files(paths).await } - /// Watch for filesystem changes and update incrementally - pub async fn watch(self: Arc) -> Result<()> { + /// Watch for filesystem changes and update incrementally. + /// The watcher task exits when `cancel_token` is cancelled. + pub async fn start_watch_with_token( + self: Arc, + cancel_token: tokio_util::sync::CancellationToken, + ) -> Result<()> { use crate::runtime::watcher::Watcher; use std::collections::HashSet; use std::time::Duration; @@ -484,7 +480,6 @@ impl NaviscopeEngine { Watcher::new(&root).map_err(|e| NaviscopeError::Internal(e.to_string()))?; let engine_weak = Arc::downgrade(&self); - let cancel_token = self.cancel_token.clone(); tokio::spawn(async move { tracing::info!("Started watching {}", root.display()); @@ -533,6 +528,12 @@ impl NaviscopeEngine { Ok(()) } + /// Backward-compatible helper that uses the engine-wide cancellation token. + pub async fn watch(self: Arc) -> Result<()> { + let cancel_token = self.cancel_token.clone(); + self.start_watch_with_token(cancel_token).await + } + /// Start the background stubbing worker fn spawn_stub_worker( &self, diff --git a/crates/core/tests/engine_api.rs b/crates/core/tests/engine_api.rs index af5b30f..7ef5088 100644 --- a/crates/core/tests/engine_api.rs +++ b/crates/core/tests/engine_api.rs @@ -44,7 +44,7 @@ async fn test_engine_handle_query() { limit: 5, }; - let result: naviscope_api::graph::Result = + let result: naviscope_api::ApiResult = handle.query(&query).await; assert!(result.is_ok()); diff --git a/crates/lsp/src/indexer.rs b/crates/lsp/src/indexer.rs index 247ddc2..21b1644 100644 --- a/crates/lsp/src/indexer.rs +++ b/crates/lsp/src/indexer.rs @@ -58,7 +58,7 @@ pub fn spawn_indexer( client.log_message(MessageType::INFO, stats_msg).await; // 2. Setup file watcher - if let Err(e) = engine.watch().await { + if let Err(e) = engine.start_watch().await { client .log_message( MessageType::ERROR, diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs index 8f9adbb..7f0702c 100644 --- a/crates/runtime/src/lib.rs +++ b/crates/runtime/src/lib.rs @@ -1,5 +1,5 @@ use naviscope_api::NaviscopeEngine; -use naviscope_api::lifecycle::EngineResult; +use naviscope_api::{ApiError, ApiResult}; use std::path::PathBuf; use std::sync::Arc; @@ -37,12 +37,9 @@ pub fn init_logging(component: &str, to_stderr: bool) -> Option { } /// Utility to clear all indices stored on the local system. -pub fn clear_all_indices() -> EngineResult<()> { - naviscope_core::runtime::orchestrator::NaviscopeEngine::clear_all_indices().map_err( - |e: naviscope_core::error::NaviscopeError| { - naviscope_api::lifecycle::EngineError::Internal(e.to_string()) - }, - ) +pub fn clear_all_indices() -> ApiResult<()> { + naviscope_core::runtime::orchestrator::NaviscopeEngine::clear_all_indices() + .map_err(|e: naviscope_core::error::NaviscopeError| ApiError::Internal(e.to_string())) } /// Get the global stub cache manager. From 5cf71817fbac0e6d0baf6f80d03f94cc947fc792 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Thu, 12 Feb 2026 05:33:08 +0800 Subject: [PATCH 20/49] style(fmt): format workspace --- crates/cli/src/shell/view.rs | 5 +---- crates/core/src/facade/mod.rs | 5 ++++- crates/core/src/features/discovery.rs | 2 +- crates/core/src/ingest/resolver/engine.rs | 5 ++++- crates/core/tests/global_assets.rs | 9 +++++---- crates/core/tests/indexing.rs | 10 ++++++++-- crates/core/tests/semantic_traits.rs | 8 +++++--- crates/lang-gradle/src/cap/indexing.rs | 5 ++++- crates/lang-gradle/src/cap/parse.rs | 7 ++++--- crates/lang-gradle/src/cap/presentation.rs | 4 +++- crates/lang-java/src/cap/presentation.rs | 2 +- crates/lang-java/src/cap/runtime.rs | 9 ++++++--- crates/lang-java/src/lsp/type_system.rs | 2 +- crates/lang-java/src/resolve/lang.rs | 2 +- crates/lang-java/src/resolve/mod.rs | 2 +- crates/lang-java/src/resolve/semantic.rs | 3 +-- crates/lang-java/src/resolve/types.rs | 2 +- crates/lang-java/tests/common/mod.rs | 2 +- crates/lang-java/tests/java_integration.rs | 2 +- crates/lang-java/tests/logic_goto_def.rs | 2 +- crates/lang-java/tests/logic_goto_impl.rs | 2 +- crates/lang-java/tests/logic_goto_ref.rs | 2 +- crates/lang-java/tests/logic_goto_type.rs | 2 +- crates/lang-java/tests/logic_hierarchy.rs | 2 +- crates/lsp/src/hierarchy.rs | 12 ++++++++++-- crates/plugin/src/cap/indexing.rs | 13 ++++++++++--- crates/plugin/src/cap/parse.rs | 3 ++- crates/plugin/src/cap/presentation.rs | 2 +- crates/plugin/src/cap/runtime.rs | 15 ++++++++++++--- crates/plugin/src/core/metadata_codec.rs | 2 +- crates/plugin/src/lib.rs | 4 ++-- crates/plugin/src/typing.rs | 2 +- 32 files changed, 97 insertions(+), 52 deletions(-) diff --git a/crates/cli/src/shell/view.rs b/crates/cli/src/shell/view.rs index 3380ccd..9574a9a 100644 --- a/crates/cli/src/shell/view.rs +++ b/crates/cli/src/shell/view.rs @@ -20,10 +20,7 @@ pub struct ShellNodeViewShort { } impl ShellNodeView { - pub fn from_node( - node: &DisplayGraphNode, - relation: Option, - ) -> Self { + pub fn from_node(node: &DisplayGraphNode, relation: Option) -> Self { let location = node .location .as_ref() diff --git a/crates/core/src/facade/mod.rs b/crates/core/src/facade/mod.rs index 9dd8b54..25c5bc3 100644 --- a/crates/core/src/facade/mod.rs +++ b/crates/core/src/facade/mod.rs @@ -74,7 +74,10 @@ impl EngineHandle { pub fn get_services_for_path( &self, path: &std::path::Path, - ) -> Option<(Arc, crate::model::source::Language)> { + ) -> Option<( + Arc, + crate::model::source::Language, + )> { let lang = self.get_language_for_path(path)?; let resolver = self.get_semantic_resolver(lang.clone())?; Some((resolver, lang)) diff --git a/crates/core/src/features/discovery.rs b/crates/core/src/features/discovery.rs index db8086c..12f7478 100644 --- a/crates/core/src/features/discovery.rs +++ b/crates/core/src/features/discovery.rs @@ -1,7 +1,7 @@ use super::CodeGraphLike; -use naviscope_plugin::SemanticCap; use lsp_types::{Location, Url}; pub use naviscope_api::models::SymbolResolution; +use naviscope_plugin::SemanticCap; use std::collections::HashSet; /// DiscoveryEngine bridges Meso-level graph knowledge with Micro-level file scanning. diff --git a/crates/core/src/ingest/resolver/engine.rs b/crates/core/src/ingest/resolver/engine.rs index 288ec3f..dc0eea1 100644 --- a/crates/core/src/ingest/resolver/engine.rs +++ b/crates/core/src/ingest/resolver/engine.rs @@ -246,7 +246,10 @@ impl IndexResolver { let source_results: Vec> = source_files .par_iter() .map(|file| { - let caps = self.lang_caps.iter().find(|c| c.matcher.supports_path(file.path())); + let caps = self + .lang_caps + .iter() + .find(|c| c.matcher.supports_path(file.path())); if let Some(c) = caps { c.indexing diff --git a/crates/core/tests/global_assets.rs b/crates/core/tests/global_assets.rs index f5db1f0..52d822a 100644 --- a/crates/core/tests/global_assets.rs +++ b/crates/core/tests/global_assets.rs @@ -16,12 +16,13 @@ async fn test_global_asset_scan_produces_routes() { let routes = engine.global_asset_routes(); - assert!( - scan.total_assets >= scan.indexed_assets + scan.skipped_assets + scan.failed_assets - ); + assert!(scan.total_assets >= scan.indexed_assets + scan.skipped_assets + scan.failed_assets); if scan.total_assets > 0 { - assert!(scan.total_prefixes > 0, "Expected some prefixes to be indexed"); + assert!( + scan.total_prefixes > 0, + "Expected some prefixes to be indexed" + ); assert!(!routes.is_empty(), "Expected routes to be populated"); } } diff --git a/crates/core/tests/indexing.rs b/crates/core/tests/indexing.rs index 01dafbf..59ec78c 100644 --- a/crates/core/tests/indexing.rs +++ b/crates/core/tests/indexing.rs @@ -106,9 +106,15 @@ async fn test_update_files_persistence_integration() { .with_build_caps(mock_build_caps()) .build(); - engine.update_files(vec![build_gradle.clone()]).await.unwrap(); + engine + .update_files(vec![build_gradle.clone()]) + .await + .unwrap(); let graph = engine.snapshot().await; - assert!(graph.node_count() > 0, "Project node should exist after first indexing"); + assert!( + graph.node_count() > 0, + "Project node should exist after first indexing" + ); engine.update_files(vec![build_gradle]).await.unwrap(); let graph = engine.snapshot().await; diff --git a/crates/core/tests/semantic_traits.rs b/crates/core/tests/semantic_traits.rs index 9d454c0..32b14e1 100644 --- a/crates/core/tests/semantic_traits.rs +++ b/crates/core/tests/semantic_traits.rs @@ -8,8 +8,8 @@ use naviscope_core::runtime::orchestrator::NaviscopeEngine as CoreEngine; use naviscope_plugin::{ AssetCap, CodecContext, FileMatcherCap, GlobalParseResult, LanguageCaps, LanguageParseCap, LspSyntaxService, MetadataCodecCap, NamingConvention, NodeMetadataCodec, NodePresenter, - ParsedContent, ParsedFile, PresentationCap, ProjectContext, ReferenceCheckService, ResolvedUnit, - SemanticCap, SourceIndexCap, StandardNamingConvention, SymbolQueryService, + ParsedContent, ParsedFile, PresentationCap, ProjectContext, ReferenceCheckService, + ResolvedUnit, SemanticCap, SourceIndexCap, StandardNamingConvention, SymbolQueryService, SymbolResolveService, }; use std::path::Path; @@ -131,7 +131,9 @@ impl SymbolQueryService for MockCap { impl LspSyntaxService for MockCap { fn parse(&self, source: &str, old_tree: Option<&Tree>) -> Option { let mut parser = tree_sitter::Parser::new(); - parser.set_language(&tree_sitter_java::LANGUAGE.into()).ok()?; + parser + .set_language(&tree_sitter_java::LANGUAGE.into()) + .ok()?; parser.parse(source, old_tree) } diff --git a/crates/lang-gradle/src/cap/indexing.rs b/crates/lang-gradle/src/cap/indexing.rs index 010e598..a3bafc6 100644 --- a/crates/lang-gradle/src/cap/indexing.rs +++ b/crates/lang-gradle/src/cap/indexing.rs @@ -6,7 +6,10 @@ impl BuildIndexCap for GradlePlugin { &self, files: &[&naviscope_plugin::ParsedFile], ) -> Result< - (naviscope_plugin::ResolvedUnit, naviscope_plugin::ProjectContext), + ( + naviscope_plugin::ResolvedUnit, + naviscope_plugin::ProjectContext, + ), naviscope_plugin::BoxError, > { let resolver = crate::resolve::GradleResolver::new(); diff --git a/crates/lang-gradle/src/cap/parse.rs b/crates/lang-gradle/src/cap/parse.rs index 980842e..2f5ad06 100644 --- a/crates/lang-gradle/src/cap/parse.rs +++ b/crates/lang-gradle/src/cap/parse.rs @@ -7,11 +7,12 @@ impl BuildParseCap for GradlePlugin { source: &str, ) -> Result> { if source.contains("include") && (source.contains("'") || source.contains("\"")) { - let settings = - crate::parser::parse_settings(source).unwrap_or_else(|_| crate::model::GradleSettings { + let settings = crate::parser::parse_settings(source).unwrap_or_else(|_| { + crate::model::GradleSettings { root_project_name: None, included_projects: Vec::new(), - }); + } + }); Ok(BuildParseResult { content: BuildContent::Metadata( serde_json::to_value(settings).unwrap_or(serde_json::Value::Null), diff --git a/crates/lang-gradle/src/cap/presentation.rs b/crates/lang-gradle/src/cap/presentation.rs index 0980c8e..3a51406 100644 --- a/crates/lang-gradle/src/cap/presentation.rs +++ b/crates/lang-gradle/src/cap/presentation.rs @@ -1,7 +1,9 @@ use crate::GradlePlugin; use naviscope_api::models::graph::{DisplayGraphNode, GraphNode, NodeKind}; use naviscope_api::models::symbol::FqnReader; -use naviscope_plugin::{NamingConvention, NodePresenter, PresentationCap, StandardNamingConvention}; +use naviscope_plugin::{ + NamingConvention, NodePresenter, PresentationCap, StandardNamingConvention, +}; use std::sync::Arc; impl NodePresenter for GradlePlugin { diff --git a/crates/lang-java/src/cap/presentation.rs b/crates/lang-java/src/cap/presentation.rs index 25a8785..c49d2d8 100644 --- a/crates/lang-java/src/cap/presentation.rs +++ b/crates/lang-java/src/cap/presentation.rs @@ -1,8 +1,8 @@ use crate::JavaPlugin; use lasso::Key; +use naviscope_api::models::DisplayGraphNode; use naviscope_api::models::graph::{GraphNode, NodeKind}; use naviscope_api::models::symbol::{FqnReader, Symbol}; -use naviscope_api::models::DisplayGraphNode; use naviscope_plugin::{NamingConvention, NodePresenter, PresentationCap}; use std::sync::Arc; diff --git a/crates/lang-java/src/cap/runtime.rs b/crates/lang-java/src/cap/runtime.rs index e37db18..0c5fe79 100644 --- a/crates/lang-java/src/cap/runtime.rs +++ b/crates/lang-java/src/cap/runtime.rs @@ -2,7 +2,11 @@ use crate::JavaPlugin; use naviscope_plugin::{LspSyntaxService, ReferenceCheckService}; impl LspSyntaxService for JavaPlugin { - fn parse(&self, source: &str, old_tree: Option<&tree_sitter::Tree>) -> Option { + fn parse( + &self, + source: &str, + old_tree: Option<&tree_sitter::Tree>, + ) -> Option { crate::lsp::JavaLspService::new(self.parser.clone()).parse(source, old_tree) } @@ -20,8 +24,7 @@ impl LspSyntaxService for JavaPlugin { tree: &tree_sitter::Tree, target: &naviscope_api::models::SymbolResolution, ) -> Vec { - crate::lsp::JavaLspService::new(self.parser.clone()) - .find_occurrences(source, tree, target) + crate::lsp::JavaLspService::new(self.parser.clone()).find_occurrences(source, tree, target) } } diff --git a/crates/lang-java/src/lsp/type_system.rs b/crates/lang-java/src/lsp/type_system.rs index f8d9072..0ade5b4 100644 --- a/crates/lang-java/src/lsp/type_system.rs +++ b/crates/lang-java/src/lsp/type_system.rs @@ -1,6 +1,6 @@ use naviscope_api::models::SymbolResolution; -use naviscope_plugin::graph::CodeGraph; use naviscope_plugin::ReferenceCheckService; +use naviscope_plugin::graph::CodeGraph; pub struct JavaTypeSystem; diff --git a/crates/lang-java/src/resolve/lang.rs b/crates/lang-java/src/resolve/lang.rs index c93a1fe..37e89d5 100644 --- a/crates/lang-java/src/resolve/lang.rs +++ b/crates/lang-java/src/resolve/lang.rs @@ -1,7 +1,7 @@ +use crate::JavaPlugin; use crate::inference::adapters::HeuristicAdapter; use crate::inference::{TypeProvider, TypeResolutionContext}; use crate::model::JavaIndexMetadata; -use crate::JavaPlugin; use crate::resolve::context::ResolutionContext; use naviscope_api::models::graph::{EdgeType, GraphEdge, NodeKind}; use naviscope_api::models::symbol::{NodeId, SymbolResolution, TypeRef}; diff --git a/crates/lang-java/src/resolve/mod.rs b/crates/lang-java/src/resolve/mod.rs index 3aabedd..9d80bea 100644 --- a/crates/lang-java/src/resolve/mod.rs +++ b/crates/lang-java/src/resolve/mod.rs @@ -6,8 +6,8 @@ pub mod lang; pub mod semantic; pub mod types; -use crate::inference::adapters::CodeGraphTypeSystem; use crate::JavaPlugin; +use crate::inference::adapters::CodeGraphTypeSystem; use context::ResolutionContext; use naviscope_api::models::{SymbolResolution, TypeRef}; diff --git a/crates/lang-java/src/resolve/semantic.rs b/crates/lang-java/src/resolve/semantic.rs index 920e271..1edef2f 100644 --- a/crates/lang-java/src/resolve/semantic.rs +++ b/crates/lang-java/src/resolve/semantic.rs @@ -1,6 +1,6 @@ +use crate::JavaPlugin; use crate::inference::adapters::CodeGraphTypeSystem; use crate::inference::{InheritanceProvider, MemberProvider, TypeProvider, TypeResolutionContext}; -use crate::JavaPlugin; use crate::resolve::context::ResolutionContext; use naviscope_api::models::graph::EdgeType; use naviscope_api::models::symbol::{FqnId, matches_intent}; @@ -33,7 +33,6 @@ impl SymbolResolveService for JavaPlugin { self.resolve_symbol_internal(&context) } - } impl SymbolQueryService for JavaPlugin { diff --git a/crates/lang-java/src/resolve/types.rs b/crates/lang-java/src/resolve/types.rs index 2a4fa0b..a0a0fdb 100644 --- a/crates/lang-java/src/resolve/types.rs +++ b/crates/lang-java/src/resolve/types.rs @@ -1,5 +1,5 @@ -use crate::inference::{TypeProvider, TypeResolutionContext}; use crate::JavaPlugin; +use crate::inference::{TypeProvider, TypeResolutionContext}; use naviscope_api::models::TypeRef; impl JavaPlugin { diff --git a/crates/lang-java/tests/common/mod.rs b/crates/lang-java/tests/common/mod.rs index 0a55099..fa63919 100644 --- a/crates/lang-java/tests/common/mod.rs +++ b/crates/lang-java/tests/common/mod.rs @@ -1,7 +1,7 @@ use naviscope_api::models::Language; use naviscope_core::ingest::builder::CodeGraphBuilder; -use naviscope_java::parser::JavaParser; use naviscope_java::JavaPlugin; +use naviscope_java::parser::JavaParser; use naviscope_plugin::{ GraphOp, ParsedContent, ParsedFile, ProjectContext, SourceFile, SourceIndexCap, }; diff --git a/crates/lang-java/tests/java_integration.rs b/crates/lang-java/tests/java_integration.rs index f7f0cea..50ef612 100644 --- a/crates/lang-java/tests/java_integration.rs +++ b/crates/lang-java/tests/java_integration.rs @@ -1,8 +1,8 @@ mod common; use common::{offset_to_point, setup_java_test_graph}; -use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; use naviscope_java::JavaPlugin; +use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; #[test] fn test_cross_file_resolution() { diff --git a/crates/lang-java/tests/logic_goto_def.rs b/crates/lang-java/tests/logic_goto_def.rs index e5378f1..1df078d 100644 --- a/crates/lang-java/tests/logic_goto_def.rs +++ b/crates/lang-java/tests/logic_goto_def.rs @@ -3,8 +3,8 @@ mod common; use common::{offset_to_point, setup_java_test_graph}; use naviscope_core::features::CodeGraphLike; use naviscope_core::ingest::parser::SymbolResolution; -use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; use naviscope_java::JavaPlugin; +use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; #[test] fn test_goto_definition_local() { diff --git a/crates/lang-java/tests/logic_goto_impl.rs b/crates/lang-java/tests/logic_goto_impl.rs index 38e832f..1760cf2 100644 --- a/crates/lang-java/tests/logic_goto_impl.rs +++ b/crates/lang-java/tests/logic_goto_impl.rs @@ -2,8 +2,8 @@ mod common; use common::{offset_to_point, setup_java_test_graph}; use naviscope_core::features::CodeGraphLike; -use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; use naviscope_java::JavaPlugin; +use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; #[test] fn test_goto_implementation_interface() { diff --git a/crates/lang-java/tests/logic_goto_ref.rs b/crates/lang-java/tests/logic_goto_ref.rs index ee3302a..d699846 100644 --- a/crates/lang-java/tests/logic_goto_ref.rs +++ b/crates/lang-java/tests/logic_goto_ref.rs @@ -1,8 +1,8 @@ mod common; use common::{offset_to_point, setup_java_test_graph}; -use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; use naviscope_java::JavaPlugin; +use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; #[test] fn test_goto_references_method() { diff --git a/crates/lang-java/tests/logic_goto_type.rs b/crates/lang-java/tests/logic_goto_type.rs index a85d15f..2b68b18 100644 --- a/crates/lang-java/tests/logic_goto_type.rs +++ b/crates/lang-java/tests/logic_goto_type.rs @@ -2,8 +2,8 @@ mod common; use common::{offset_to_point, setup_java_test_graph}; use naviscope_core::features::CodeGraphLike; -use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; use naviscope_java::JavaPlugin; +use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; #[test] fn test_goto_type_definition_variable() { diff --git a/crates/lang-java/tests/logic_hierarchy.rs b/crates/lang-java/tests/logic_hierarchy.rs index 8135158..1bdb415 100644 --- a/crates/lang-java/tests/logic_hierarchy.rs +++ b/crates/lang-java/tests/logic_hierarchy.rs @@ -4,8 +4,8 @@ use common::{offset_to_point, setup_java_test_graph}; use naviscope_core::features::CodeGraphLike; use naviscope_core::features::discovery::DiscoveryEngine; use naviscope_core::ingest::parser::SymbolResolution; -use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; use naviscope_java::JavaPlugin; +use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; #[test] fn test_call_hierarchy_incoming() { diff --git a/crates/lsp/src/hierarchy.rs b/crates/lsp/src/hierarchy.rs index d7bed58..404ee97 100644 --- a/crates/lsp/src/hierarchy.rs +++ b/crates/lsp/src/hierarchy.rs @@ -85,7 +85,11 @@ pub async fn incoming_calls( let lsp_calls: Vec = calls .into_iter() .map(|item| { - let loc = item.from.location.as_ref().expect("Caller must have location"); + let loc = item + .from + .location + .as_ref() + .expect("Caller must have location"); let lsp_range = Range { start: Position::new(loc.range.start_line as u32, loc.range.start_col as u32), end: Position::new(loc.range.end_line as u32, loc.range.end_col as u32), @@ -140,7 +144,11 @@ pub async fn outgoing_calls( let lsp_calls: Vec = calls .into_iter() .map(|item| { - let loc = item.to.location.as_ref().expect("Callee must have location"); + let loc = item + .to + .location + .as_ref() + .expect("Callee must have location"); let lsp_range = Range { start: Position::new(loc.range.start_line as u32, loc.range.start_col as u32), end: Position::new(loc.range.end_line as u32, loc.range.end_col as u32), diff --git a/crates/plugin/src/cap/indexing.rs b/crates/plugin/src/cap/indexing.rs index 4015cdb..7b80e0d 100644 --- a/crates/plugin/src/cap/indexing.rs +++ b/crates/plugin/src/cap/indexing.rs @@ -1,12 +1,19 @@ +use crate::ResolvedUnit; use crate::asset::BoxError; use crate::indexing::ProjectContext; use crate::model::ParsedFile; -use crate::ResolvedUnit; pub trait SourceIndexCap: Send + Sync { - fn compile_source(&self, file: &ParsedFile, context: &ProjectContext) -> Result; + fn compile_source( + &self, + file: &ParsedFile, + context: &ProjectContext, + ) -> Result; } pub trait BuildIndexCap: Send + Sync { - fn compile_build(&self, files: &[&ParsedFile]) -> Result<(ResolvedUnit, ProjectContext), BoxError>; + fn compile_build( + &self, + files: &[&ParsedFile], + ) -> Result<(ResolvedUnit, ProjectContext), BoxError>; } diff --git a/crates/plugin/src/cap/parse.rs b/crates/plugin/src/cap/parse.rs index d8afb35..bc39f75 100644 --- a/crates/plugin/src/cap/parse.rs +++ b/crates/plugin/src/cap/parse.rs @@ -3,7 +3,8 @@ use crate::model::{BuildParseResult, GlobalParseResult}; use std::path::Path; pub trait LanguageParseCap: Send + Sync { - fn parse_language_file(&self, source: &str, path: &Path) -> Result; + fn parse_language_file(&self, source: &str, path: &Path) + -> Result; } pub trait BuildParseCap: Send + Sync { diff --git a/crates/plugin/src/cap/presentation.rs b/crates/plugin/src/cap/presentation.rs index 5636081..276205a 100644 --- a/crates/plugin/src/cap/presentation.rs +++ b/crates/plugin/src/cap/presentation.rs @@ -1,5 +1,5 @@ -use crate::naming::NamingConvention; use crate::core::NodePresenter; +use crate::naming::NamingConvention; use naviscope_api::models::graph::NodeKind; use std::sync::Arc; diff --git a/crates/plugin/src/cap/runtime.rs b/crates/plugin/src/cap/runtime.rs index 53ceb1d..43abcd2 100644 --- a/crates/plugin/src/cap/runtime.rs +++ b/crates/plugin/src/cap/runtime.rs @@ -1,7 +1,7 @@ use crate::graph::CodeGraph; +use naviscope_api::models::SymbolResolution; use naviscope_api::models::graph::DisplayGraphNode; use naviscope_api::models::symbol::{FqnId, Range}; -use naviscope_api::models::SymbolResolution; use tree_sitter::Tree; pub trait SymbolResolveService: Send + Sync { @@ -17,7 +17,11 @@ pub trait SymbolResolveService: Send + Sync { pub trait SymbolQueryService: Send + Sync { fn find_matches(&self, index: &dyn CodeGraph, res: &SymbolResolution) -> Vec; - fn resolve_type_of(&self, index: &dyn CodeGraph, res: &SymbolResolution) -> Vec; + fn resolve_type_of( + &self, + index: &dyn CodeGraph, + res: &SymbolResolution, + ) -> Vec; fn find_implementations(&self, index: &dyn CodeGraph, res: &SymbolResolution) -> Vec; } @@ -94,6 +98,11 @@ pub trait SemanticCap: } impl SemanticCap for T where - T: SymbolResolveService + SymbolQueryService + LspSyntaxService + ReferenceCheckService + Send + Sync + T: SymbolResolveService + + SymbolQueryService + + LspSyntaxService + + ReferenceCheckService + + Send + + Sync { } diff --git a/crates/plugin/src/core/metadata_codec.rs b/crates/plugin/src/core/metadata_codec.rs index fc98096..b6117b3 100644 --- a/crates/plugin/src/core/metadata_codec.rs +++ b/crates/plugin/src/core/metadata_codec.rs @@ -1,5 +1,5 @@ use crate::core::CodecContext; -use naviscope_api::models::graph::{NodeMetadata}; +use naviscope_api::models::graph::NodeMetadata; use std::sync::Arc; pub trait NodeMetadataCodec: Send + Sync { diff --git a/crates/plugin/src/lib.rs b/crates/plugin/src/lib.rs index 4e48d48..ffe006d 100644 --- a/crates/plugin/src/lib.rs +++ b/crates/plugin/src/lib.rs @@ -1,7 +1,7 @@ pub mod asset; pub mod cap; -pub mod core; pub mod converter; +pub mod core; pub mod graph; pub mod indexing; pub mod model; @@ -12,8 +12,8 @@ pub mod utils; pub use asset::*; pub use cap::*; -pub use core::*; pub use converter::*; +pub use core::*; pub use graph::*; pub use indexing::*; pub use model::*; diff --git a/crates/plugin/src/typing.rs b/crates/plugin/src/typing.rs index 3533d04..8c01d1e 100644 --- a/crates/plugin/src/typing.rs +++ b/crates/plugin/src/typing.rs @@ -1,7 +1,7 @@ use crate::cap::ReferenceCheckService; use crate::graph::{CodeGraph, Direction}; -use naviscope_api::models::graph::EdgeType; use naviscope_api::models::SymbolResolution; +use naviscope_api::models::graph::EdgeType; pub struct NoOpReferenceCheckService; From 56bf1efaf286dfae94ca9f5ee8e27cab51c61c5a Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Thu, 12 Feb 2026 05:38:48 +0800 Subject: [PATCH 21/49] refactor(core): rename plugin bridge module and update internal imports --- crates/core/src/{plugin => bridge}/mod.rs | 0 crates/core/src/facade/mod.rs | 4 ++-- crates/core/src/features/query.rs | 2 +- crates/core/src/lib.rs | 2 +- crates/core/src/model/graph.rs | 2 +- crates/core/src/model/storage/converter.rs | 2 +- crates/core/src/runtime/orchestrator.rs | 4 ++-- 7 files changed, 8 insertions(+), 8 deletions(-) rename crates/core/src/{plugin => bridge}/mod.rs (100%) diff --git a/crates/core/src/plugin/mod.rs b/crates/core/src/bridge/mod.rs similarity index 100% rename from crates/core/src/plugin/mod.rs rename to crates/core/src/bridge/mod.rs diff --git a/crates/core/src/facade/mod.rs b/crates/core/src/facade/mod.rs index 25c5bc3..2d81985 100644 --- a/crates/core/src/facade/mod.rs +++ b/crates/core/src/facade/mod.rs @@ -53,14 +53,14 @@ impl EngineHandle { pub fn get_node_presenter( &self, language: crate::model::source::Language, - ) -> Option> { + ) -> Option> { self.engine.get_resolver().get_node_presenter(language) } pub fn get_metadata_codec( &self, language: crate::model::source::Language, - ) -> Option> { + ) -> Option> { self.engine.get_resolver().get_metadata_codec(language) } diff --git a/crates/core/src/features/query.rs b/crates/core/src/features/query.rs index 350ba27..bf7e3d7 100644 --- a/crates/core/src/features/query.rs +++ b/crates/core/src/features/query.rs @@ -1,7 +1,7 @@ use crate::error::{NaviscopeError, Result}; use crate::model::source::Language; use crate::model::{DisplayGraphNode, EdgeType, NodeKind}; -use crate::plugin::NodePresenter; +use crate::bridge::NodePresenter; pub use naviscope_api::models::{GraphQuery, QueryResult, QueryResultEdge}; use petgraph::Direction as PetDirection; use regex::RegexBuilder; diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index a32e007..dfc7f94 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,4 +1,5 @@ pub mod asset; +pub mod bridge; pub mod cache; pub mod error; pub mod logging; @@ -8,7 +9,6 @@ pub mod facade; pub mod features; pub mod ingest; pub mod model; -pub mod plugin; pub mod runtime; // FQN types are now exported from model module diff --git a/crates/core/src/model/graph.rs b/crates/core/src/model/graph.rs index 6f3b114..3f7fb78 100644 --- a/crates/core/src/model/graph.rs +++ b/crates/core/src/model/graph.rs @@ -9,7 +9,7 @@ use crate::features::CodeGraphLike; use crate::model::FqnManager; use crate::model::source::SourceFile; use crate::model::{GraphEdge, GraphNode}; -use crate::plugin::NodeMetadataCodec; +use crate::bridge::NodeMetadataCodec; use lasso::ThreadedRodeo; use naviscope_api::models::symbol::{FqnId, FqnReader, Symbol}; use petgraph::stable_graph::{NodeIndex, StableDiGraph}; diff --git a/crates/core/src/model/storage/converter.rs b/crates/core/src/model/storage/converter.rs index 0a8cb8a..6a5d3df 100644 --- a/crates/core/src/model/storage/converter.rs +++ b/crates/core/src/model/storage/converter.rs @@ -1,7 +1,7 @@ use super::model::*; use crate::model::graph::{CodeGraphInner, FileEntry}; use crate::model::{EmptyMetadata, GraphNode, InternedLocation, NodeMetadata}; -use crate::plugin::NodeMetadataCodec; +use crate::bridge::NodeMetadataCodec; use lasso::{Key, Spur, ThreadedRodeo}; use naviscope_api::models::symbol::{FqnId, Symbol}; use petgraph::stable_graph::NodeIndex; diff --git a/crates/core/src/runtime/orchestrator.rs b/crates/core/src/runtime/orchestrator.rs index 23099c5..94b5994 100644 --- a/crates/core/src/runtime/orchestrator.rs +++ b/crates/core/src/runtime/orchestrator.rs @@ -693,7 +693,7 @@ impl NaviscopeEngine { let bytes = std::fs::read(path)?; - let get_codec = |lang: &str| -> Option> { + let get_codec = |lang: &str| -> Option> { for caps in lang_caps.iter() { if caps.language.as_str() == lang { return caps.metadata_codec.metadata_codec(); @@ -745,7 +745,7 @@ impl NaviscopeEngine { std::fs::create_dir_all(parent)?; } - let get_codec = |lang: &str| -> Option> { + let get_codec = |lang: &str| -> Option> { for caps in lang_caps.iter() { if caps.language.as_str() == lang { return caps.metadata_codec.metadata_codec(); From d3c711be6de312dd636c609b64df920991ed1d5a Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Thu, 12 Feb 2026 05:51:38 +0800 Subject: [PATCH 22/49] refactor(docs): enhance plugin contracts and architecture descriptions for clarity --- README.md | 64 +++++++++++++++++++++++++++++++++- docs/architecture/crate-map.md | 4 +-- docs/plugins/contracts.md | 58 +++++++++++++++++++++--------- 3 files changed, 107 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 43ccf7d..5c4a5d5 100644 --- a/README.md +++ b/README.md @@ -106,12 +106,74 @@ Naviscope is built on a **layered crate architecture** that separates concerns a - **Interface Layer** (`naviscope-cli`, `naviscope-lsp`, `naviscope-mcp`): Entry points for different use cases (CLI shell, LSP for editors, MCP for AI agents). - **Runtime Layer** (`naviscope-runtime`): Orchestrates the engine assembly, registering language plugins and providing a unified factory. - **Language Layer** (`naviscope-java`, `naviscope-gradle`): Language-specific implementations that implement the standard plugin contracts. -- **Plugin Layer** (`naviscope-plugin`): Defines the standard contracts and traits (`LanguagePlugin`, `BuildToolPlugin`, `Resolver`) effectively decoupling the core engine from specific language logic. +- **Plugin Layer** (`naviscope-plugin`): Defines capability traits (parse/indexing/runtime/asset/presentation/metadata) that decouple Core from language-specific implementations. - **Core Layer** (`naviscope-core`): The heart of the system - graph storage, indexing, file scanning, and persistence. It consumes the plugin traits to process files. - **API Layer** (`naviscope-api`): Common traits and models shared across all crates, ensuring a consistent interface. The core is a language-agnostic graph structure populated by language-specific strategies (currently Java/Gradle via Tree-sitter), exposing a unified query engine to both AI agents and developer tools. +### Trait Organization + +Naviscope's trait design is split into two layers: engine-facing API traits (`naviscope-api`) and language capability traits (`naviscope-plugin`). + +```mermaid +graph TB + subgraph A["Layer 1: Engine API (`naviscope-api`)"] + APIComposite["API Composite
NaviscopeEngine"] + APIServices["API Services
GraphService | NavigationService
SymbolNavigator | ReferenceAnalyzer
CallHierarchyAnalyzer | SymbolInfoProvider
EngineLifecycle | StubCacheManager"] + end + + subgraph B["Layer 2: Runtime Semantics (`naviscope-plugin`)"] + RuntimeSemantic["Runtime Semantic Cap
SemanticCap"] + RuntimeServices["Semantic Services
SymbolResolveService | SymbolQueryService
LspSyntaxService | ReferenceCheckService"] + end + + subgraph C["Layer 3: Parse & Index (`naviscope-plugin`)"] + ParseCap["Parse Cap
FileMatcherCap | LanguageParseCap | BuildParseCap"] + IndexCap["Index Cap
SourceIndexCap | BuildIndexCap | ProjectContext"] + end + + subgraph D["Layer 4: Asset & Output (`naviscope-plugin`)"] + AssetCap["Asset Cap"] + PresentationCap["Presentation Cap"] + MetadataCodecCap["Metadata Codec Cap"] + end + + APIComposite --> APIServices + APIServices --> RuntimeSemantic + RuntimeSemantic --> RuntimeServices + + RuntimeServices --> ParseCap + RuntimeServices --> IndexCap + ParseCap --> IndexCap + + IndexCap --> AssetCap + RuntimeServices --> PresentationCap + RuntimeServices --> MetadataCodecCap +``` + +#### `naviscope-api` service traits + +- `GraphService`: graph query, stats, and node display retrieval. +- `NavigationService`: CLI-style path resolution and completion. +- `SymbolNavigator`: resolve/go-to-definition/type-definition/implementation/highlights. +- `ReferenceAnalyzer`: find references. +- `CallHierarchyAnalyzer`: incoming/outgoing calls. +- `SymbolInfoProvider`: symbol info, document symbols, language detection. +- `EngineLifecycle`: rebuild/load/save/refresh/watch/clear. +- `StubCacheManager`: cache stats/scan/inspect/clear. +- `NaviscopeEngine`: composite trait that bundles all service traits above. + +#### `naviscope-plugin` capability traits + +- File routing and parsing: `FileMatcherCap`, `LanguageParseCap`, `BuildParseCap`. +- Indexing: `SourceIndexCap`, `BuildIndexCap`. +- Runtime semantics: `SymbolResolveService`, `SymbolQueryService`, `LspSyntaxService`, `ReferenceCheckService` (grouped by `SemanticCap`). +- Asset integration: `AssetCap` (discover/index/source-locate/stub-generate). +- Output shaping: `PresentationCap`, `MetadataCodecCap`. + +This split keeps runtime interfaces stable for clients while allowing per-language capability composition internally. + ### 🔍 Reference Discovery Strategy Naviscope uses a **two-phase reference discovery** approach for optimal performance: diff --git a/docs/architecture/crate-map.md b/docs/architecture/crate-map.md index 5206475..1bc2078 100644 --- a/docs/architecture/crate-map.md +++ b/docs/architecture/crate-map.md @@ -37,8 +37,8 @@ graph TD ``` ## What Each Layer Provides -- **API**: shared models, identifiers, and common traits used across crates. -- **Plugin**: contracts for language/build tool integrations; keeps Core independent. +- **API**: shared models plus engine service traits (`GraphService`, `NavigationService`, semantic traits, `EngineLifecycle`) and the composite `NaviscopeEngine`. +- **Plugin**: capability traits (`*Cap` + runtime semantic services) for language/build integrations; keeps Core independent. - **Core**: graph storage, indexing, persistence, and asset services. - **Runtime**: orchestration, lifecycle, background tasks, and query serving. - **Language/Build**: concrete strategies (Java parsing, Gradle structure resolution). diff --git a/docs/plugins/contracts.md b/docs/plugins/contracts.md index a9821f9..f60d6b4 100644 --- a/docs/plugins/contracts.md +++ b/docs/plugins/contracts.md @@ -1,27 +1,53 @@ # Plugin Contracts ## Purpose -Plugin contracts define how language and build-tool integrations attach their logic to the core engine without introducing hard dependencies. +`naviscope-plugin` defines capability-oriented contracts that let language/build integrations plug into Core without hard dependencies on concrete crates. -## LanguagePlugin Responsibilities -- Provide parsing and semantic analysis -- Emit nodes and edges into the graph -- Optionally expose an external resolver for stubs and sources +## Capability Groups -## BuildToolPlugin Responsibilities -- Resolve module structure -- Provide dependency metadata -- Avoid executing build tools where possible +### 1. File Matching and Parsing +- `FileMatcherCap`: decides whether a plugin handles a path. +- `LanguageParseCap`: parses language source files. +- `BuildParseCap`: parses build-tool files. -## ExternalResolver Flow +### 2. Index Construction +- `SourceIndexCap`: compiles one source file into `ResolvedUnit`. +- `BuildIndexCap`: compiles build files into `(ResolvedUnit, ProjectContext)`. +- `ProjectContext`: shared indexing context (for module/path mappings and cross-file coordination). + +### 3. Runtime Semantics +- `SymbolResolveService`: resolve symbol at position. +- `SymbolQueryService`: match symbols, infer types, find implementations. +- `LspSyntaxService`: incremental parse, symbol extraction, occurrence discovery. +- `ReferenceCheckService`: semantic reference/subtype checks. +- `SemanticCap`: composite runtime semantic contract above. + +### 4. Asset and Stub Integration +- `AssetCap`: optional entry for: + - asset discoverer + - asset indexer + - source locator + - stub generator + +### 5. Presentation and Metadata +- `PresentationCap`: naming convention, node presentation, symbol kind mapping. +- `MetadataCodecCap`: pluggable metadata encode/decode strategy. + +## Registration Shape +Language crates register capabilities via `LanguageCaps`, selectively wiring only the capabilities they support. Missing capabilities should degrade gracefully through trait defaults. + +## Runtime Flow ```mermaid flowchart LR - Index[Asset Index] --> Route[Routes] - Route --> Stub[Stub Generation] - Stub --> Graph[Graph Update] + Match[FileMatcherCap] --> Parse[LanguageParseCap / BuildParseCap] + Parse --> Index[SourceIndexCap / BuildIndexCap] + Index --> Graph[Core Graph Updates] + Graph --> Runtime[SemanticCap Services] + Asset[AssetCap] --> Graph + Present[PresentationCap + MetadataCodecCap] --> Runtime ``` ## Compatibility Notes -- Traits are stable and versioned -- Metadata serialization is forward-compatible -- Default implementations should degrade gracefully +- Prefer additive trait evolution (new default methods / new optional caps). +- Keep metadata codecs backward-compatible by versioning serialized payloads. +- Keep capability boundaries stable so runtime wiring remains predictable. From 8a64504c4c89af959fb578f6681e29c2416d9066 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Thu, 12 Feb 2026 14:01:12 +0800 Subject: [PATCH 23/49] migrate java external class parsing from cafebabe to ristretto --- Cargo.lock | 32 +++--- Cargo.toml | 2 +- crates/lang-java/Cargo.toml | 2 +- .../src/resolve/external/converter.rs | 107 ++++++++++-------- crates/lang-java/src/resolve/external/mod.rs | 37 ++++-- 5 files changed, 100 insertions(+), 80 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9b99797..c21274b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,6 +26,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -276,16 +277,6 @@ dependencies = [ "libbz2-rs-sys", ] -[[package]] -name = "cafebabe" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9dc1dc4d3a0a7a314b5dcaad4077be139cfa3fcaa8d237218dedc6b71835872" -dependencies = [ - "bitflags 1.3.2", - "cesu8", -] - [[package]] name = "cc" version = "1.2.55" @@ -298,12 +289,6 @@ dependencies = [ "shlex", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cfg-if" version = "1.0.4" @@ -1541,7 +1526,6 @@ dependencies = [ name = "naviscope-java" version = "0.5.5" dependencies = [ - "cafebabe", "dirs", "lasso", "lsp-types", @@ -1550,6 +1534,7 @@ dependencies = [ "naviscope-plugin", "petgraph", "regex", + "ristretto_classfile", "ristretto_jimage", "rmp-serde", "serde", @@ -2045,6 +2030,19 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +[[package]] +name = "ristretto_classfile" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda62b37a0ea624b0217f5dc6cd8abcf2a580dda5834b74b47aec276f91559ba" +dependencies = [ + "ahash", + "bitflags 2.10.0", + "byteorder", + "indexmap", + "thiserror 2.0.18", +] + [[package]] name = "ristretto_jimage" version = "0.28.0" diff --git a/Cargo.toml b/Cargo.toml index e532622..59e28bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -66,6 +66,6 @@ tree-sitter-java = "0.23.5" tree-sitter-groovy = "0.1.2" mimalloc = "0.1" tempfile = "3.10" -cafebabe = "0.9.0" zip = "7.3.0" ristretto_jimage = "0.28.0" +ristretto_classfile = "0.28.0" diff --git a/crates/lang-java/Cargo.toml b/crates/lang-java/Cargo.toml index edefb90..3a3e997 100644 --- a/crates/lang-java/Cargo.toml +++ b/crates/lang-java/Cargo.toml @@ -15,9 +15,9 @@ lsp-types = { workspace = true } tree-sitter-java = { workspace = true } rmp-serde = { workspace = true } lasso = { workspace = true } -cafebabe = { workspace = true } zip = { workspace = true } ristretto_jimage = { workspace = true } +ristretto_classfile = { workspace = true } dirs = { workspace = true } regex = { workspace = true } diff --git a/crates/lang-java/src/resolve/external/converter.rs b/crates/lang-java/src/resolve/external/converter.rs index e5093bf..2f98e21 100644 --- a/crates/lang-java/src/resolve/external/converter.rs +++ b/crates/lang-java/src/resolve/external/converter.rs @@ -1,50 +1,58 @@ -use cafebabe::descriptors::{FieldDescriptor, FieldType, MethodDescriptor, ReturnDescriptor}; use naviscope_api::models::TypeRef; +use ristretto_classfile::{BaseType, ClassAccessFlags, FieldAccessFlags, FieldType, MethodAccessFlags}; pub struct JavaTypeConverter; impl JavaTypeConverter { - pub fn convert_field(desc: &FieldDescriptor) -> TypeRef { - let mut tr = Self::convert_type(&desc.field_type); - if desc.dimensions > 0 { - tr = TypeRef::Array { - element: Box::new(tr), - dimensions: desc.dimensions as usize, - }; - } - tr + pub fn convert_field(ty: &FieldType) -> TypeRef { + Self::convert_type(ty) } - pub fn convert_method(desc: &MethodDescriptor) -> (TypeRef, Vec) { - let return_type = match &desc.return_type { - ReturnDescriptor::Void => TypeRef::Raw("void".to_string()), - ReturnDescriptor::Return(field_desc) => Self::convert_field(field_desc), + pub fn convert_method( + descriptor: &str, + ) -> Result<(TypeRef, Vec), ristretto_classfile::Error> { + let (params, ret) = FieldType::parse_method_descriptor(descriptor)?; + let return_type = match ret { + None => TypeRef::Raw("void".to_string()), + Some(field_type) => Self::convert_field(&field_type), }; - let parameters = desc - .parameters + let parameters = params .iter() .enumerate() - .map(|(i, field_desc)| crate::model::JavaParameter { + .map(|(i, field_type)| crate::model::JavaParameter { name: format!("arg{}", i), - type_ref: Self::convert_field(field_desc), + type_ref: Self::convert_field(field_type), }) .collect(); - (return_type, parameters) + Ok((return_type, parameters)) } pub fn convert_type(ty: &FieldType) -> TypeRef { match ty { - FieldType::Byte => TypeRef::Raw("byte".to_string()), - FieldType::Char => TypeRef::Raw("char".to_string()), - FieldType::Double => TypeRef::Raw("double".to_string()), - FieldType::Float => TypeRef::Raw("float".to_string()), - FieldType::Integer => TypeRef::Raw("int".to_string()), - FieldType::Long => TypeRef::Raw("long".to_string()), - FieldType::Short => TypeRef::Raw("short".to_string()), - FieldType::Boolean => TypeRef::Raw("boolean".to_string()), + FieldType::Base(BaseType::Byte) => TypeRef::Raw("byte".to_string()), + FieldType::Base(BaseType::Char) => TypeRef::Raw("char".to_string()), + FieldType::Base(BaseType::Double) => TypeRef::Raw("double".to_string()), + FieldType::Base(BaseType::Float) => TypeRef::Raw("float".to_string()), + FieldType::Base(BaseType::Int) => TypeRef::Raw("int".to_string()), + FieldType::Base(BaseType::Long) => TypeRef::Raw("long".to_string()), + FieldType::Base(BaseType::Short) => TypeRef::Raw("short".to_string()), + FieldType::Base(BaseType::Boolean) => TypeRef::Raw("boolean".to_string()), FieldType::Object(name) => TypeRef::Id(name.replace('/', ".")), + FieldType::Array(component) => { + let mut dimensions = 1usize; + let mut current = component.as_ref(); + while let FieldType::Array(inner) = current { + dimensions += 1; + current = inner.as_ref(); + } + + TypeRef::Array { + element: Box::new(Self::convert_type(current)), + dimensions, + } + } } } } @@ -52,75 +60,74 @@ impl JavaTypeConverter { pub struct JavaModifierConverter; impl JavaModifierConverter { - pub fn parse_class(flags: cafebabe::ClassAccessFlags) -> Vec { + pub fn parse_class(flags: ClassAccessFlags) -> Vec { let mut mods = Vec::new(); - if flags.contains(cafebabe::ClassAccessFlags::PUBLIC) { + if flags.contains(ClassAccessFlags::PUBLIC) { mods.push("public".into()); } - if flags.contains(cafebabe::ClassAccessFlags::FINAL) { + if flags.contains(ClassAccessFlags::FINAL) { mods.push("final".into()); } - if flags.contains(cafebabe::ClassAccessFlags::ABSTRACT) - && !flags.contains(cafebabe::ClassAccessFlags::INTERFACE) + if flags.contains(ClassAccessFlags::ABSTRACT) && !flags.contains(ClassAccessFlags::INTERFACE) { mods.push("abstract".into()); } mods } - pub fn parse_field(flags: cafebabe::FieldAccessFlags) -> Vec { + pub fn parse_field(flags: FieldAccessFlags) -> Vec { let mut mods = Vec::new(); - if flags.contains(cafebabe::FieldAccessFlags::PUBLIC) { + if flags.contains(FieldAccessFlags::PUBLIC) { mods.push("public".into()); } - if flags.contains(cafebabe::FieldAccessFlags::PRIVATE) { + if flags.contains(FieldAccessFlags::PRIVATE) { mods.push("private".into()); } - if flags.contains(cafebabe::FieldAccessFlags::PROTECTED) { + if flags.contains(FieldAccessFlags::PROTECTED) { mods.push("protected".into()); } - if flags.contains(cafebabe::FieldAccessFlags::STATIC) { + if flags.contains(FieldAccessFlags::STATIC) { mods.push("static".into()); } - if flags.contains(cafebabe::FieldAccessFlags::FINAL) { + if flags.contains(FieldAccessFlags::FINAL) { mods.push("final".into()); } - if flags.contains(cafebabe::FieldAccessFlags::VOLATILE) { + if flags.contains(FieldAccessFlags::VOLATILE) { mods.push("volatile".into()); } - if flags.contains(cafebabe::FieldAccessFlags::TRANSIENT) { + if flags.contains(FieldAccessFlags::TRANSIENT) { mods.push("transient".into()); } mods } - pub fn parse_method(flags: cafebabe::MethodAccessFlags) -> Vec { + pub fn parse_method(flags: MethodAccessFlags) -> Vec { let mut mods = Vec::new(); - if flags.contains(cafebabe::MethodAccessFlags::PUBLIC) { + if flags.contains(MethodAccessFlags::PUBLIC) { mods.push("public".into()); } - if flags.contains(cafebabe::MethodAccessFlags::PRIVATE) { + if flags.contains(MethodAccessFlags::PRIVATE) { mods.push("private".into()); } - if flags.contains(cafebabe::MethodAccessFlags::PROTECTED) { + if flags.contains(MethodAccessFlags::PROTECTED) { mods.push("protected".into()); } - if flags.contains(cafebabe::MethodAccessFlags::STATIC) { + if flags.contains(MethodAccessFlags::STATIC) { mods.push("static".into()); } - if flags.contains(cafebabe::MethodAccessFlags::FINAL) { + if flags.contains(MethodAccessFlags::FINAL) { mods.push("final".into()); } - if flags.contains(cafebabe::MethodAccessFlags::SYNCHRONIZED) { + if flags.contains(MethodAccessFlags::SYNCHRONIZED) { mods.push("synchronized".into()); } - if flags.contains(cafebabe::MethodAccessFlags::NATIVE) { + if flags.contains(MethodAccessFlags::NATIVE) { mods.push("native".into()); } - if flags.contains(cafebabe::MethodAccessFlags::ABSTRACT) { + if flags.contains(MethodAccessFlags::ABSTRACT) { mods.push("abstract".into()); } - if flags.contains(cafebabe::MethodAccessFlags::STRICT) { + if flags.contains(MethodAccessFlags::STRICT) { mods.push("strictfp".into()); } mods diff --git a/crates/lang-java/src/resolve/external/mod.rs b/crates/lang-java/src/resolve/external/mod.rs index ced6d3c..636015a 100644 --- a/crates/lang-java/src/resolve/external/mod.rs +++ b/crates/lang-java/src/resolve/external/mod.rs @@ -2,10 +2,11 @@ use naviscope_plugin::{ AssetEntry, AssetIndexer, AssetSource, AssetSourceLocator, GlobalParseResult, IndexNode, StubGenerator, }; +use ristretto_classfile::{ClassAccessFlags, ClassFile}; use ristretto_jimage::Image; use std::collections::HashSet; use std::fs::File; -use std::io::Read; +use std::io::{Cursor, Read}; use std::path::{Path, PathBuf}; use std::sync::Arc; use zip::ZipArchive; @@ -207,24 +208,24 @@ impl JavaExternalResolver { } }; - let class = - cafebabe::parse_class(&bytes).map_err(|e| format!("Failed to parse class: {:?}", e))?; + let class = ClassFile::from_bytes(&mut Cursor::new(bytes)) + .map_err(|e| format!("Failed to parse class: {e:?}"))?; if member_parts.is_empty() { let name = fqn.split('.').last().unwrap_or(fqn).to_string(); let kind = if class .access_flags - .contains(cafebabe::ClassAccessFlags::INTERFACE) + .contains(ClassAccessFlags::INTERFACE) { naviscope_api::models::graph::NodeKind::Interface } else if class .access_flags - .contains(cafebabe::ClassAccessFlags::ANNOTATION) + .contains(ClassAccessFlags::ANNOTATION) { naviscope_api::models::graph::NodeKind::Annotation } else if class .access_flags - .contains(cafebabe::ClassAccessFlags::ENUM) + .contains(ClassAccessFlags::ENUM) { naviscope_api::models::graph::NodeKind::Enum } else { @@ -250,8 +251,13 @@ impl JavaExternalResolver { let member_name = member_parts.join("."); for field in &class.fields { - if field.name == member_name { - let type_ref = JavaTypeConverter::convert_field(&field.descriptor); + let field_name = class + .constant_pool + .try_get_utf8(field.name_index) + .map_err(|e| format!("Failed to parse field name: {e:?}"))?; + + if field_name == member_name { + let type_ref = JavaTypeConverter::convert_field(&field.field_type); let modifiers = JavaModifierConverter::parse_field(field.access_flags); let metadata = crate::model::JavaIndexMetadata::Field { modifiers, @@ -271,9 +277,18 @@ impl JavaExternalResolver { } for method in &class.methods { - if method.name == member_name { - let (return_type, parameters) = - JavaTypeConverter::convert_method(&method.descriptor); + let method_name = class + .constant_pool + .try_get_utf8(method.name_index) + .map_err(|e| format!("Failed to parse method name: {e:?}"))?; + + if method_name == member_name { + let method_descriptor = class + .constant_pool + .try_get_utf8(method.descriptor_index) + .map_err(|e| format!("Failed to parse method descriptor: {e:?}"))?; + let (return_type, parameters) = JavaTypeConverter::convert_method(method_descriptor) + .map_err(|e| format!("Failed to parse method signature: {e:?}"))?; let modifiers = JavaModifierConverter::parse_method(method.access_flags); let metadata = crate::model::JavaIndexMetadata::Method { modifiers, From ddd94e5c006418deda9e2b40f6225d91f298ad3e Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Thu, 12 Feb 2026 15:31:52 +0800 Subject: [PATCH 24/49] lang-java: implement sprint A generic substitution pipeline --- crates/lang-java/src/cap/presentation.rs | 8 +- .../lang-java/src/inference/adapters/graph.rs | 62 ++++- .../lang-java/src/inference/strategy/field.rs | 46 +++- .../lang-java/src/inference/strategy/local.rs | 27 +- .../src/inference/strategy/method.rs | 57 ++++- crates/lang-java/src/model.rs | 22 +- crates/lang-java/src/parser/ast/entities.rs | 68 ++++- crates/lang-java/src/parser/ast/metadata.rs | 8 +- crates/lang-java/src/resolve/external/mod.rs | 5 +- crates/lang-java/tests/inference_mock_test.rs | 235 ++++++++++++++++++ crates/lang-java/tests/repro_modifiers.rs | 6 +- 11 files changed, 513 insertions(+), 31 deletions(-) diff --git a/crates/lang-java/src/cap/presentation.rs b/crates/lang-java/src/cap/presentation.rs index c49d2d8..427fab2 100644 --- a/crates/lang-java/src/cap/presentation.rs +++ b/crates/lang-java/src/cap/presentation.rs @@ -35,8 +35,8 @@ impl NodePresenter for JavaPlugin { .downcast_ref::() { match java_meta { - crate::model::JavaNodeMetadata::Class { modifiers_sids } - | crate::model::JavaNodeMetadata::Interface { modifiers_sids } + crate::model::JavaNodeMetadata::Class { modifiers_sids, .. } + | crate::model::JavaNodeMetadata::Interface { modifiers_sids, .. } | crate::model::JavaNodeMetadata::Annotation { modifiers_sids } => { display.modifiers = modifiers_sids .iter() @@ -120,8 +120,8 @@ impl NodePresenter for JavaPlugin { .downcast_ref::() { match java_idx_meta { - crate::model::JavaIndexMetadata::Class { modifiers } - | crate::model::JavaIndexMetadata::Interface { modifiers } + crate::model::JavaIndexMetadata::Class { modifiers, .. } + | crate::model::JavaIndexMetadata::Interface { modifiers, .. } | crate::model::JavaIndexMetadata::Annotation { modifiers } => { display.modifiers = modifiers.clone(); let prefix = match node.kind { diff --git a/crates/lang-java/src/inference/adapters/graph.rs b/crates/lang-java/src/inference/adapters/graph.rs index dcff6db..3313d35 100644 --- a/crates/lang-java/src/inference/adapters/graph.rs +++ b/crates/lang-java/src/inference/adapters/graph.rs @@ -4,12 +4,16 @@ use naviscope_api::models::TypeRef; use naviscope_api::models::graph::{EdgeType, NodeKind, NodeMetadata}; -use naviscope_api::models::symbol::FqnId; +use naviscope_api::models::symbol::{FqnId, Symbol}; use naviscope_plugin::{CodeGraph, Direction}; +use lasso::Key; use std::sync::Arc; use crate::inference::{InheritanceProvider, MemberProvider, TypeProvider}; -use crate::inference::{MemberInfo, MemberKind, TypeInfo, TypeKind, TypeResolutionContext}; +use crate::inference::{ + MemberInfo, MemberKind, TypeInfo, TypeKind, TypeResolutionContext, +}; +use crate::inference::core::types::TypeParameter; use crate::model::{JavaIndexMetadata, JavaNodeMetadata}; /// Adapter that implements JavaTypeSystem using CodeGraph. @@ -38,8 +42,8 @@ impl<'a> CodeGraphTypeSystem<'a> { fn extract_modifiers(&self, metadata: &Arc) -> Vec { if let Some(java_meta) = metadata.as_any().downcast_ref::() { return match java_meta { - JavaIndexMetadata::Class { modifiers } => modifiers.clone(), - JavaIndexMetadata::Interface { modifiers } => modifiers.clone(), + JavaIndexMetadata::Class { modifiers, .. } => modifiers.clone(), + JavaIndexMetadata::Interface { modifiers, .. } => modifiers.clone(), JavaIndexMetadata::Enum { modifiers, .. } => modifiers.clone(), JavaIndexMetadata::Method { modifiers, .. } => modifiers.clone(), JavaIndexMetadata::Field { modifiers, .. } => modifiers.clone(), @@ -49,6 +53,54 @@ impl<'a> CodeGraphTypeSystem<'a> { vec![] } + fn extract_type_parameters(&self, metadata: &Arc) -> Vec { + if let Some(java_meta) = metadata.as_any().downcast_ref::() { + let names: Vec = match java_meta { + JavaNodeMetadata::Class { + type_parameters_sids, + .. + } + | JavaNodeMetadata::Interface { + type_parameters_sids, + .. + } => type_parameters_sids + .iter() + .filter_map(|sid| lasso::Spur::try_from_usize(*sid as usize)) + .map(|spur| self.graph.fqns().resolve_atom(Symbol(spur)).to_string()) + .collect(), + _ => vec![], + }; + return names + .into_iter() + .map(|name| TypeParameter { + name, + bounds: vec![], + }) + .collect(); + } + + if let Some(java_meta) = metadata.as_any().downcast_ref::() { + let names: Vec = match java_meta { + JavaIndexMetadata::Class { + type_parameters, .. + } + | JavaIndexMetadata::Interface { + type_parameters, .. + } => type_parameters.clone(), + _ => vec![], + }; + return names + .into_iter() + .map(|name| TypeParameter { + name, + bounds: vec![], + }) + .collect(); + } + + vec![] + } + /// Extract type ref from metadata. fn extract_type_from_metadata(&self, metadata: &Arc) -> TypeRef { if let Some(java_meta) = metadata.as_any().downcast_ref::() { @@ -133,7 +185,7 @@ impl<'a> TypeProvider for CodeGraphTypeSystem<'a> { fqn: fqn.to_string(), kind: self.node_kind_to_type_kind(&node.kind), modifiers: self.extract_modifiers(&node.metadata), - type_parameters: vec![], + type_parameters: self.extract_type_parameters(&node.metadata), }) } diff --git a/crates/lang-java/src/inference/strategy/field.rs b/crates/lang-java/src/inference/strategy/field.rs index 7dc54f4..1eeff70 100644 --- a/crates/lang-java/src/inference/strategy/field.rs +++ b/crates/lang-java/src/inference/strategy/field.rs @@ -1,7 +1,9 @@ //! Field access inference. use super::{InferStrategy, infer_expression}; +use crate::inference::core::unification::Substitution; use crate::inference::InferContext; +use crate::inference::TypeRefExt; use naviscope_api::models::TypeRef; use tree_sitter::Node; @@ -51,13 +53,47 @@ impl FieldAccessInfer { let receiver_type = infer_expression(&receiver, ctx)?; // Get the FQN from the receiver type - let type_fqn = match &receiver_type { - TypeRef::Id(fqn) => fqn.clone(), - _ => return None, - }; + let type_fqn = receiver_type.as_fqn()?; // Look up the field in the type hierarchy let members = ctx.ts.find_member_in_hierarchy(&type_fqn, field_name); - members.first().cloned() + let mut member = members.first().cloned()?; + + self.apply_receiver_substitution(&mut member, &receiver_type, ctx); + Some(member) + } + + fn apply_receiver_substitution( + &self, + member: &mut crate::inference::MemberInfo, + receiver_type: &TypeRef, + ctx: &InferContext, + ) { + let (base_fqn, receiver_args) = match receiver_type { + TypeRef::Generic { base, args } => { + let Some(base_fqn) = base.as_fqn() else { + return; + }; + (base_fqn, args) + } + _ => return, + }; + + let Some(type_info) = ctx.ts.get_type_info(&base_fqn) else { + return; + }; + + if type_info.type_parameters.is_empty() + || type_info.type_parameters.len() != receiver_args.len() + { + return; + } + + let mut subst = Substitution::new(); + for (param, arg) in type_info.type_parameters.iter().zip(receiver_args.iter()) { + subst.insert(param.name.clone(), arg.clone()); + } + + member.type_ref = subst.apply(&member.type_ref); } } diff --git a/crates/lang-java/src/inference/strategy/local.rs b/crates/lang-java/src/inference/strategy/local.rs index 43cc2d2..26536d0 100644 --- a/crates/lang-java/src/inference/strategy/local.rs +++ b/crates/lang-java/src/inference/strategy/local.rs @@ -69,8 +69,31 @@ pub fn parse_type_node(node: &Node, ctx: &InferContext) -> Option { } // Generic type like List "generic_type" => { - // For now, just get the raw type (ignore type args) - node.child(0).and_then(|c| parse_type_node(&c, ctx)) + let base_node = node.child_by_field_name("type").or_else(|| node.child(0))?; + let base = parse_type_node(&base_node, ctx)?; + + let mut args = Vec::new(); + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() != "type_arguments" { + continue; + } + + let mut args_cursor = child.walk(); + for arg in child.children(&mut args_cursor) { + if !arg.is_named() { + continue; + } + if let Some(parsed) = parse_type_node(&arg, ctx) { + args.push(parsed); + } + } + } + + Some(TypeRef::Generic { + base: Box::new(base), + args, + }) } // Array type "array_type" => { diff --git a/crates/lang-java/src/inference/strategy/method.rs b/crates/lang-java/src/inference/strategy/method.rs index c9905c5..4c5d61c 100644 --- a/crates/lang-java/src/inference/strategy/method.rs +++ b/crates/lang-java/src/inference/strategy/method.rs @@ -1,7 +1,9 @@ //! Method invocation inference. use super::{InferStrategy, infer_expression}; +use crate::inference::core::unification::Substitution; use crate::inference::InferContext; +use crate::inference::TypeRefExt; use naviscope_api::models::TypeRef; use tree_sitter::Node; @@ -59,10 +61,7 @@ impl MethodCallInfer { }; // Get the FQN from the receiver type - let type_fqn = match &receiver_type { - TypeRef::Id(fqn) => fqn.clone(), - _ => return None, - }; + let type_fqn = receiver_type.as_fqn()?; // Get argument types let mut arg_types = Vec::new(); @@ -81,8 +80,58 @@ impl MethodCallInfer { // Look up the method candidates in the type hierarchy let candidates = ctx.ts.find_member_in_hierarchy(&type_fqn, method_name); + if candidates.is_empty() { + return None; + } + + let candidates = self.apply_receiver_substitution(candidates, &receiver_type, ctx); // Resolve the best match among candidates ctx.ts.resolve_method(&candidates, &arg_types) } + + fn apply_receiver_substitution( + &self, + candidates: Vec, + receiver_type: &TypeRef, + ctx: &InferContext, + ) -> Vec { + let (base_fqn, receiver_args) = match receiver_type { + TypeRef::Generic { base, args } => { + let Some(base_fqn) = base.as_fqn() else { + return candidates; + }; + (base_fqn, args) + } + _ => return candidates, + }; + + let Some(type_info) = ctx.ts.get_type_info(&base_fqn) else { + return candidates; + }; + + if type_info.type_parameters.is_empty() + || type_info.type_parameters.len() != receiver_args.len() + { + return candidates; + } + + let mut subst = Substitution::new(); + for (param, arg) in type_info.type_parameters.iter().zip(receiver_args.iter()) { + subst.insert(param.name.clone(), arg.clone()); + } + + candidates + .into_iter() + .map(|mut member| { + member.type_ref = subst.apply(&member.type_ref); + if let Some(params) = &mut member.parameters { + for p in params { + p.type_ref = subst.apply(&p.type_ref); + } + } + member + }) + .collect() + } } diff --git a/crates/lang-java/src/model.rs b/crates/lang-java/src/model.rs index 185306e..a6108f0 100644 --- a/crates/lang-java/src/model.rs +++ b/crates/lang-java/src/model.rs @@ -9,9 +9,11 @@ use std::sync::Arc; pub enum JavaIndexMetadata { Class { modifiers: Vec, + type_parameters: Vec, }, Interface { modifiers: Vec, + type_parameters: Vec, }, Enum { modifiers: Vec, @@ -52,9 +54,11 @@ impl IndexMetadata for JavaIndexMetadata { pub enum JavaNodeMetadata { Class { modifiers_sids: Vec, + type_parameters_sids: Vec, }, Interface { modifiers_sids: Vec, + type_parameters_sids: Vec, }, Enum { modifiers_sids: Vec, @@ -95,11 +99,25 @@ impl JavaIndexMetadata { pub fn to_storage(&self, ctx: &mut dyn SymbolInterner) -> JavaNodeMetadata { match self { - JavaIndexMetadata::Class { modifiers } => JavaNodeMetadata::Class { + JavaIndexMetadata::Class { + modifiers, + type_parameters, + } => JavaNodeMetadata::Class { modifiers_sids: modifiers.iter().map(|s| ctx.intern_str(s)).collect(), + type_parameters_sids: type_parameters + .iter() + .map(|s| ctx.intern_str(s)) + .collect(), }, - JavaIndexMetadata::Interface { modifiers } => JavaNodeMetadata::Interface { + JavaIndexMetadata::Interface { + modifiers, + type_parameters, + } => JavaNodeMetadata::Interface { modifiers_sids: modifiers.iter().map(|s| ctx.intern_str(s)).collect(), + type_parameters_sids: type_parameters + .iter() + .map(|s| ctx.intern_str(s)) + .collect(), }, JavaIndexMetadata::Enum { modifiers, diff --git a/crates/lang-java/src/parser/ast/entities.rs b/crates/lang-java/src/parser/ast/entities.rs index 63670c7..7a41c31 100644 --- a/crates/lang-java/src/parser/ast/entities.rs +++ b/crates/lang-java/src/parser/ast/entities.rs @@ -145,8 +145,14 @@ impl JavaParser { relations: &mut Vec, ) -> JavaIndexMetadata { match kind { - KIND_LABEL_CLASS => JavaIndexMetadata::Class { modifiers: vec![] }, - KIND_LABEL_INTERFACE => JavaIndexMetadata::Interface { modifiers: vec![] }, + KIND_LABEL_CLASS => JavaIndexMetadata::Class { + modifiers: vec![], + type_parameters: self.extract_type_parameters(captures, source), + }, + KIND_LABEL_INTERFACE => JavaIndexMetadata::Interface { + modifiers: vec![], + type_parameters: self.extract_type_parameters(captures, source), + }, KIND_LABEL_ENUM => JavaIndexMetadata::Enum { modifiers: vec![], constants: vec![], @@ -201,4 +207,62 @@ impl JavaParser { _ => unreachable!(), } } + + fn extract_type_parameters<'a>( + &self, + captures: &[QueryCapture<'a>], + source: &'a str, + ) -> Vec { + let declaration_node = captures.iter().find_map(|c| { + if c.index == self.indices.class_def || c.index == self.indices.inter_def { + Some(c.node) + } else { + None + } + }); + + let Some(declaration_node) = declaration_node else { + return Vec::new(); + }; + + let type_params_node = declaration_node + .child_by_field_name("type_parameters") + .or_else(|| { + let mut cursor = declaration_node.walk(); + declaration_node + .children(&mut cursor) + .find(|n| n.kind() == "type_parameters") + }); + + let Some(type_params_node) = type_params_node else { + return Vec::new(); + }; + + let mut result = Vec::new(); + let mut cursor = type_params_node.walk(); + for child in type_params_node.children(&mut cursor) { + if child.kind() != "type_parameter" { + continue; + } + + if let Some(name_node) = child.child_by_field_name("name") { + if let Ok(name) = name_node.utf8_text(source.as_bytes()) { + result.push(name.to_string()); + } + continue; + } + + let mut type_param_cursor = child.walk(); + for gc in child.children(&mut type_param_cursor) { + if gc.kind() == "type_identifier" { + if let Ok(name) = gc.utf8_text(source.as_bytes()) { + result.push(name.to_string()); + } + break; + } + } + } + + result + } } diff --git a/crates/lang-java/src/parser/ast/metadata.rs b/crates/lang-java/src/parser/ast/metadata.rs index 6821cb1..1164ae7 100644 --- a/crates/lang-java/src/parser/ast/metadata.rs +++ b/crates/lang-java/src/parser/ast/metadata.rs @@ -96,7 +96,7 @@ impl JavaParser { } match element { - JavaIndexMetadata::Class { modifiers: _ } => { + JavaIndexMetadata::Class { .. } => { if let Some(s) = captures .iter() .find(|c| c.index == self.indices.class_super) @@ -143,7 +143,7 @@ impl JavaParser { }); } } - JavaIndexMetadata::Interface { modifiers: _ } => { + JavaIndexMetadata::Interface { .. } => { for cc in captures .iter() .filter(|c| c.index == self.indices.inter_ext) @@ -234,12 +234,12 @@ impl JavaParser { fn add_modifier(&self, element: &mut JavaIndexMetadata, m_str: String) { match element { - JavaIndexMetadata::Class { modifiers } => { + JavaIndexMetadata::Class { modifiers, .. } => { if !modifiers.contains(&m_str) { modifiers.push(m_str); } } - JavaIndexMetadata::Interface { modifiers } => { + JavaIndexMetadata::Interface { modifiers, .. } => { if !modifiers.contains(&m_str) { modifiers.push(m_str); } diff --git a/crates/lang-java/src/resolve/external/mod.rs b/crates/lang-java/src/resolve/external/mod.rs index 636015a..c7f6c64 100644 --- a/crates/lang-java/src/resolve/external/mod.rs +++ b/crates/lang-java/src/resolve/external/mod.rs @@ -233,7 +233,10 @@ impl JavaExternalResolver { }; let modifiers = JavaModifierConverter::parse_class(class.access_flags); - let metadata = crate::model::JavaIndexMetadata::Class { modifiers }; + let metadata = crate::model::JavaIndexMetadata::Class { + modifiers, + type_parameters: vec![], + }; return Ok(IndexNode { id: naviscope_api::models::symbol::NodeId::Flat(fqn.to_string()), diff --git a/crates/lang-java/tests/inference_mock_test.rs b/crates/lang-java/tests/inference_mock_test.rs index 2c5920c..b4da127 100644 --- a/crates/lang-java/tests/inference_mock_test.rs +++ b/crates/lang-java/tests/inference_mock_test.rs @@ -4,11 +4,15 @@ use naviscope_api::models::TypeRef; use std::collections::HashMap; +use naviscope_java::inference::{create_inference_context, infer_expression}; use naviscope_java::inference::JavaTypeSystem; use naviscope_java::inference::{InheritanceProvider, MemberProvider, TypeProvider}; use naviscope_java::inference::{ MemberInfo, MemberKind, TypeInfo, TypeKind, TypeResolutionContext, }; +use naviscope_java::inference::core::types::TypeParameter; +use naviscope_java::inference::scope::ScopeManager; +use naviscope_java::parser::JavaParser; /// A mock type system for testing. /// @@ -46,6 +50,37 @@ impl MockTypeSystem { self } + /// Add a class with generic type parameters. + pub fn add_class_with_type_params( + mut self, + fqn: &str, + super_class: Option<&str>, + type_parameters: Vec<&str>, + ) -> Self { + self.types.insert( + fqn.to_string(), + TypeInfo { + fqn: fqn.to_string(), + kind: TypeKind::Class, + modifiers: vec![], + type_parameters: type_parameters + .into_iter() + .map(|name| TypeParameter { + name: name.to_string(), + bounds: vec![], + }) + .collect(), + }, + ); + + self.inheritance.insert( + fqn.to_string(), + (super_class.map(|s| s.to_string()), vec![]), + ); + + self + } + /// Add an interface to the mock. pub fn add_interface(mut self, fqn: &str) -> Self { self.types.insert( @@ -63,6 +98,33 @@ impl MockTypeSystem { self } + /// Add an interface with generic type parameters. + pub fn add_interface_with_type_params( + mut self, + fqn: &str, + type_parameters: Vec<&str>, + ) -> Self { + self.types.insert( + fqn.to_string(), + TypeInfo { + fqn: fqn.to_string(), + kind: TypeKind::Interface, + modifiers: vec![], + type_parameters: type_parameters + .into_iter() + .map(|name| TypeParameter { + name: name.to_string(), + bounds: vec![], + }) + .collect(), + }, + ); + + self.inheritance.insert(fqn.to_string(), (None, vec![])); + + self + } + /// Add interface implementation to a class. pub fn implements(mut self, class_fqn: &str, interface_fqn: &str) -> Self { if let Some((_super_class, interfaces)) = self.inheritance.get_mut(class_fqn) { @@ -123,6 +185,19 @@ impl MockTypeSystem { } } +fn find_first_named<'a>(node: tree_sitter::Node<'a>, kind: &str) -> Option> { + if node.kind() == kind { + return Some(node); + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if let Some(found) = find_first_named(child, kind) { + return Some(found); + } + } + None +} + impl TypeProvider for MockTypeSystem { fn get_type_info(&self, fqn: &str) -> Option { self.types.get(fqn).cloned() @@ -567,3 +642,163 @@ fn test_generic_return_type() { // For now, returns the raw type parameter E assert_eq!(get_method[0].type_ref, TypeRef::Id("E".into())); } + +#[test] +fn test_infer_generic_method_call_with_substitution() { + let source = r#" +import java.util.List; +class Demo { + void run() { + List list = null; + list.get(0); + } +} +"#; + + let parser = JavaParser::new().expect("failed to create parser"); + let tree = parser.parse(source, None).expect("failed to parse source"); + let root = tree.root_node(); + let method_invocation = find_first_named(root, "method_invocation") + .expect("expected method_invocation in test snippet"); + + let ts = MockTypeSystem::new() + .add_class("java.lang.String", None) + .add_class("java.lang.Object", None) + .add_interface_with_type_params("java.util.List", vec!["E"]) + .add_method("java.util.List", "get", TypeRef::Id("E".into())); + + let mut scope_manager = ScopeManager::new(); + let ctx = create_inference_context( + &root, + source, + &ts, + &mut scope_manager, + None, + vec!["java.util.List".to_string()], + ); + + let inferred = infer_expression(&method_invocation, &ctx); + assert_eq!(inferred, Some(TypeRef::Id("java.lang.String".into()))); +} + +#[test] +fn test_infer_map_get_returns_integer() { + let source = r#" +import java.util.Map; +class Demo { + void run() { + Map map = null; + map.get("k"); + } +} +"#; + + let parser = JavaParser::new().expect("failed to create parser"); + let tree = parser.parse(source, None).expect("failed to parse source"); + let root = tree.root_node(); + let method_invocation = find_first_named(root, "method_invocation") + .expect("expected method_invocation in test snippet"); + + let ts = MockTypeSystem::new() + .add_class("java.lang.String", None) + .add_class("java.lang.Integer", None) + .add_interface_with_type_params("java.util.Map", vec!["K", "V"]) + .add_method("java.util.Map", "get", TypeRef::Id("V".into())); + + let mut scope_manager = ScopeManager::new(); + let ctx = create_inference_context( + &root, + source, + &ts, + &mut scope_manager, + None, + vec!["java.util.Map".to_string()], + ); + + let inferred = infer_expression(&method_invocation, &ctx); + assert_eq!(inferred, Some(TypeRef::Id("java.lang.Integer".into()))); +} + +#[test] +fn test_infer_optional_get_returns_user() { + let source = r#" +import java.util.Optional; +class User {} +class Demo { + void run() { + Optional user = null; + user.get(); + } +} +"#; + + let parser = JavaParser::new().expect("failed to create parser"); + let tree = parser.parse(source, None).expect("failed to parse source"); + let root = tree.root_node(); + let method_invocation = find_first_named(root, "method_invocation") + .expect("expected method_invocation in test snippet"); + + let ts = MockTypeSystem::new() + .add_class("User", None) + .add_interface_with_type_params("java.util.Optional", vec!["T"]) + .add_method("java.util.Optional", "get", TypeRef::Id("T".into())); + + let mut scope_manager = ScopeManager::new(); + let ctx = create_inference_context( + &root, + source, + &ts, + &mut scope_manager, + None, + vec!["java.util.Optional".to_string()], + ); + + let inferred = infer_expression(&method_invocation, &ctx); + assert_eq!(inferred, Some(TypeRef::Id("User".into()))); +} + +#[test] +fn test_infer_nested_generic_map_get_returns_list_of_integer() { + let source = r#" +import java.util.Map; +import java.util.List; +class Demo { + void run() { + Map> map = null; + map.get("k"); + } +} +"#; + + let parser = JavaParser::new().expect("failed to create parser"); + let tree = parser.parse(source, None).expect("failed to parse source"); + let root = tree.root_node(); + let method_invocation = find_first_named(root, "method_invocation") + .expect("expected method_invocation in test snippet"); + + let ts = MockTypeSystem::new() + .add_class("java.lang.String", None) + .add_class("java.lang.Integer", None) + .add_interface_with_type_params("java.util.List", vec!["E"]) + .add_interface_with_type_params("java.util.Map", vec!["K", "V"]) + .add_method("java.util.Map", "get", TypeRef::Id("V".into())); + + let mut scope_manager = ScopeManager::new(); + let ctx = create_inference_context( + &root, + source, + &ts, + &mut scope_manager, + None, + vec!["java.util.Map".to_string(), "java.util.List".to_string()], + ); + + let inferred = infer_expression(&method_invocation, &ctx); + assert_eq!( + inferred, + Some(TypeRef::Generic { + base: Box::new(TypeRef::Id("java.util.List".into())), + args: vec![TypeRef::Id("java.lang.Integer".into())], + }) + ); +} diff --git a/crates/lang-java/tests/repro_modifiers.rs b/crates/lang-java/tests/repro_modifiers.rs index 4483d3e..1894f1e 100644 --- a/crates/lang-java/tests/repro_modifiers.rs +++ b/crates/lang-java/tests/repro_modifiers.rs @@ -65,6 +65,7 @@ fn test_metadata_serialization_roundtrip() { ]; let metadata = JavaIndexMetadata::Class { modifiers: modifiers.clone(), + type_parameters: vec![], }; // Serialize JavaIndexMetadata (simulating cache storage) @@ -83,7 +84,7 @@ fn test_metadata_serialization_roundtrip() { .expect("Should be JavaIndexMetadata"); match back { - JavaIndexMetadata::Class { modifiers: m } => { + JavaIndexMetadata::Class { modifiers: m, .. } => { assert_eq!(m, &modifiers); } _ => panic!("Wrong variant"), @@ -96,6 +97,7 @@ fn test_node_metadata_interning() { let modifiers = vec!["public".to_string(), "static".to_string()]; let metadata = JavaIndexMetadata::Class { modifiers: modifiers.clone(), + type_parameters: vec![], }; let interned = metadata.intern(&mut ctx); @@ -107,7 +109,7 @@ fn test_node_metadata_interning() { .expect("Should be JavaNodeMetadata"); match node_meta { - JavaNodeMetadata::Class { modifiers_sids } => { + JavaNodeMetadata::Class { modifiers_sids, .. } => { assert_eq!(modifiers_sids.len(), 2); // Verify strings let s1 = ctx.resolve(modifiers_sids[0]).unwrap(); From b91e30c3110b75cb059c74033d0bc262d95ae5be Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Thu, 12 Feb 2026 15:54:01 +0800 Subject: [PATCH 25/49] semantic: add index-aware occurrence filtering for references --- crates/core/src/facade/semantic.rs | 3 +- crates/core/src/features/discovery.rs | 7 +- crates/core/tests/semantic_traits.rs | 1 + crates/core/tests/stub_cache.rs | 3 +- crates/lang-java/src/cap/runtime.rs | 4 +- crates/lang-java/src/lsp/mod.rs | 7 +- crates/lang-java/src/lsp/references.rs | 227 ++++++++++++++++++------- crates/plugin/src/cap/runtime.rs | 8 +- 8 files changed, 190 insertions(+), 70 deletions(-) diff --git a/crates/core/src/facade/semantic.rs b/crates/core/src/facade/semantic.rs index 0d1126b..6f453b4 100644 --- a/crates/core/src/facade/semantic.rs +++ b/crates/core/src/facade/semantic.rs @@ -91,7 +91,8 @@ impl SymbolNavigator for EngineHandle { None => return Ok(vec![]), }; - Ok(semantic.find_occurrences(&content, &tree, &res)) + let graph = self.graph().await; + Ok(semantic.find_occurrences(&content, &tree, &res, Some(&graph))) } async fn find_definitions(&self, query: &SymbolQuery) -> ApiResult> { diff --git a/crates/core/src/features/discovery.rs b/crates/core/src/features/discovery.rs index 12f7478..cb0b51e 100644 --- a/crates/core/src/features/discovery.rs +++ b/crates/core/src/features/discovery.rs @@ -155,7 +155,12 @@ impl<'a> DiscoveryEngine<'a> { ) -> Vec { if let Some(tree) = semantic.parse(source, None) { // 1. Syntactic Scan (Fast) - let candidates = semantic.find_occurrences(source, &tree, target_resolution); + let candidates = semantic.find_occurrences( + source, + &tree, + target_resolution, + Some(self.index.as_plugin_graph()), + ); // 2. Semantic Verification (Precise) let mut valid_locations = Vec::new(); diff --git a/crates/core/tests/semantic_traits.rs b/crates/core/tests/semantic_traits.rs index 32b14e1..a93d811 100644 --- a/crates/core/tests/semantic_traits.rs +++ b/crates/core/tests/semantic_traits.rs @@ -146,6 +146,7 @@ impl LspSyntaxService for MockCap { _source: &str, _tree: &Tree, _target: &SymbolResolution, + _index: Option<&dyn naviscope_plugin::CodeGraph>, ) -> Vec { vec![] } diff --git a/crates/core/tests/stub_cache.rs b/crates/core/tests/stub_cache.rs index 2d12f49..b663289 100644 --- a/crates/core/tests/stub_cache.rs +++ b/crates/core/tests/stub_cache.rs @@ -180,6 +180,7 @@ fn test_cache_with_java_metadata() { // 1. Create a stub with Java-specific metadata let java_meta = JavaIndexMetadata::Class { modifiers: vec!["public".to_string(), "final".to_string()], + type_parameters: vec![], }; let stub = IndexNode { @@ -213,7 +214,7 @@ fn test_cache_with_java_metadata() { .downcast_ref::() .expect("Metadata should be downcastable to JavaIndexMetadata"); - if let JavaIndexMetadata::Class { modifiers } = meta { + if let JavaIndexMetadata::Class { modifiers, .. } = meta { assert!(modifiers.contains(&"public".to_string())); assert!(modifiers.contains(&"final".to_string())); } else { diff --git a/crates/lang-java/src/cap/runtime.rs b/crates/lang-java/src/cap/runtime.rs index 0c5fe79..bd291f0 100644 --- a/crates/lang-java/src/cap/runtime.rs +++ b/crates/lang-java/src/cap/runtime.rs @@ -23,8 +23,10 @@ impl LspSyntaxService for JavaPlugin { source: &str, tree: &tree_sitter::Tree, target: &naviscope_api::models::SymbolResolution, + index: Option<&dyn naviscope_plugin::CodeGraph>, ) -> Vec { - crate::lsp::JavaLspService::new(self.parser.clone()).find_occurrences(source, tree, target) + crate::lsp::JavaLspService::new(self.parser.clone()) + .find_occurrences(source, tree, target, index) } } diff --git a/crates/lang-java/src/lsp/mod.rs b/crates/lang-java/src/lsp/mod.rs index afca47f..052a970 100644 --- a/crates/lang-java/src/lsp/mod.rs +++ b/crates/lang-java/src/lsp/mod.rs @@ -5,6 +5,7 @@ pub mod type_system; use crate::parser::JavaParser; use naviscope_api::models::SymbolResolution; use naviscope_api::models::graph::DisplayGraphNode; +use naviscope_plugin::CodeGraph; use naviscope_plugin::LspSyntaxService; use std::sync::Arc; use tree_sitter::Tree; @@ -33,10 +34,8 @@ impl LspSyntaxService for JavaLspService { source: &str, tree: &Tree, target: &SymbolResolution, + index: Option<&dyn CodeGraph>, ) -> Vec { - // In the fast syntactic path, we don't strictly need the full type system, - // but we could use it for early filtering if needed. - // For now, we perform the syntactic scan. - references::find_occurrences(&self.parser, source, tree, target) + references::find_occurrences(&self.parser, source, tree, target, index) } } diff --git a/crates/lang-java/src/lsp/references.rs b/crates/lang-java/src/lsp/references.rs index 63ac583..0346b73 100644 --- a/crates/lang-java/src/lsp/references.rs +++ b/crates/lang-java/src/lsp/references.rs @@ -1,9 +1,11 @@ -use crate::inference::adapters::NoOpTypeSystem; +use crate::inference::adapters::{CodeGraphTypeSystem, NoOpTypeSystem}; use crate::inference::create_inference_context; use crate::inference::scope::ScopeManager; +use crate::inference::InferContext; use crate::parser::JavaParser; -use naviscope_api::models::SymbolResolution; +use naviscope_api::models::{SymbolIntent, SymbolResolution, TypeRef}; use naviscope_api::models::symbol::Range; +use naviscope_plugin::CodeGraph; use naviscope_plugin::utils::{line_col_at_to_offset, range_from_ts}; use tree_sitter::{Node, Tree}; @@ -12,98 +14,118 @@ pub fn find_occurrences( source: &str, tree: &Tree, target: &SymbolResolution, + index: Option<&dyn CodeGraph>, ) -> Vec { let mut ranges = Vec::new(); - // 1. Setup Type System & Scope Manager - // We use NoOpTypeSystem because LspParser typically doesn't have access to the global index. - // This is sufficient for precise local variable resolution. - let ts = NoOpTypeSystem; - let mut scope_manager = ScopeManager::new(); - - // 2. Extract package and imports + // 1. Extract package and imports let (package, imports) = parser.extract_package_and_imports(tree, source); - // 3. Build Inference Context (populates ScopeManager with all local definitions) - let _ctx = create_inference_context( - &tree.root_node(), - source, - &ts, - &mut scope_manager, - package, - imports, - ); + // 2. Build inference context with available type system + if let Some(index) = index { + let ts = CodeGraphTypeSystem::new(index); + let mut scope_manager = ScopeManager::new(); + let ctx = create_inference_context( + &tree.root_node(), + source, + &ts, + &mut scope_manager, + package, + imports, + ); + collect_occurrences_with_ctx(tree, source, target, &ctx, &mut ranges); + } else { + let ts = NoOpTypeSystem; + let mut scope_manager = ScopeManager::new(); + let ctx = create_inference_context( + &tree.root_node(), + source, + &ts, + &mut scope_manager, + package, + imports, + ); + collect_occurrences_with_ctx(tree, source, target, &ctx, &mut ranges); + } + + ranges +} + +fn collect_occurrences_with_ctx( + tree: &Tree, + source: &str, + target: &SymbolResolution, + infer_ctx: &InferContext, + ranges: &mut Vec, +) { + let Some(scope_manager) = infer_ctx.scope_manager else { + return; + }; - // 4. Handle based on resolution type match target { SymbolResolution::Local(decl_range, _decl_name) => { - // Case A: Local Variable - // We want to find all usages that resolve to THIS specific declaration range. - - // Extract the variable name from the source if possible if let Some(name) = extract_name_from_range(source, decl_range) { - // Find all identifiers that match the name find_matching_identifiers( tree, source, &name, - |node, sm| { - // Start scope search from this node's scope - if let Some(scope_id) = find_start_scope_id(node, sm) { - if let Some(info) = sm.lookup_symbol(scope_id, &name) { - // MATCH condition: The resolved symbol must have the exact same declaration range - if info.range == *decl_range { - return true; - } + |node| { + if let Some(scope_id) = find_start_scope_id(node, scope_manager) { + if let Some(info) = scope_manager.lookup_symbol(scope_id, &name) { + return info.range == *decl_range; } } false }, - &scope_manager, - &mut ranges, + ranges, ); - - // Also add the declaration itself if not already covered (usually identifiers cover it) - // But let's ensure we don't duplicate. find_matching_identifiers scans all nodes. } } SymbolResolution::Precise(fqn, _) | SymbolResolution::Global(fqn) => { - // Case B: Global/Member Symbol - // We want to find usages that DO NOT resolve to a local variable. - // Since we don't have full type resolution, this is a "best effort" semantic search. - let name = fqn .split(|c| c == '.' || c == '#' || c == '$') .last() .unwrap_or(fqn); if name.is_empty() { - return ranges; + return; } + let member_target = fqn.contains('#'); + let type_target = matches!(target, SymbolResolution::Precise(_, SymbolIntent::Type)); + find_matching_identifiers( tree, source, name, - |node, sm| { - if let Some(scope_id) = find_start_scope_id(node, sm) { - // If it resolves to a LOCAL variable, it is SHADOWED -> Not a match - if sm.lookup_symbol(scope_id, name).is_some() { + |node| { + if let Some(scope_id) = find_start_scope_id(node, scope_manager) { + if scope_manager.lookup_symbol(scope_id, name).is_some() { return false; } } - // If not locally resolved, it refers to a field/method/class. - // Without global index, we assume it matches the target FQN if name matches. - // Improvement: Check imports? + + if member_target { + return resolve_member_reference_fqn(node, infer_ctx) + .map(|resolved| member_fqn_matches_target(&resolved, fqn, infer_ctx)) + .unwrap_or(false); + } + + if type_target { + if let Some(TypeRef::Id(resolved_type)) = + crate::inference::strategy::infer_expression(node, infer_ctx) + { + return resolved_type == *fqn; + } + } + + // Name-only fallback for non-member symbols. true }, - &scope_manager, - &mut ranges, + ranges, ); } } - - ranges } fn extract_name_from_range(source: &str, range: &Range) -> Option { @@ -121,18 +143,15 @@ fn find_matching_identifiers( source: &str, target_name: &str, predicate: F, - scope_manager: &ScopeManager, ranges: &mut Vec, ) where - F: Fn(&Node, &ScopeManager) -> bool, + F: Fn(&Node) -> bool, { - // A simple recursive walker is sufficient visit_tree_recursive( &tree.root_node(), source, target_name, &predicate, - scope_manager, ranges, ); } @@ -142,15 +161,14 @@ fn visit_tree_recursive( source: &str, target_name: &str, predicate: &F, - scope_manager: &ScopeManager, ranges: &mut Vec, ) where - F: Fn(&Node, &ScopeManager) -> bool, + F: Fn(&Node) -> bool, { if node.kind() == "identifier" || node.kind() == "type_identifier" { if let Ok(text) = node.utf8_text(source.as_bytes()) { if text == target_name { - if predicate(node, scope_manager) { + if predicate(node) { ranges.push(range_from_ts(node.range())); } } @@ -164,7 +182,6 @@ fn visit_tree_recursive( source, target_name, predicate, - scope_manager, ranges, ); } @@ -180,3 +197,91 @@ fn find_start_scope_id(node: &Node, sm: &ScopeManager) -> Option { } None } + +fn resolve_member_reference_fqn(node: &Node, infer_ctx: &InferContext) -> Option { + if node.kind() != "identifier" { + return None; + } + + if let Some(parent) = node.parent() { + if parent.kind() == "method_invocation" && parent.child_by_field_name("name") == Some(*node) + { + if let Some(resolved) = + crate::inference::strategy::MethodCallInfer.infer_member(&parent, infer_ctx) + { + return Some(resolved); + } + + // Fallback for implicit `this` calls when enclosing_class is not pre-filled in ctx. + if parent.child_by_field_name("object").is_none() { + let member_name = node.utf8_text(infer_ctx.source.as_bytes()).ok()?; + let class_fqn = find_enclosing_class_fqn(node, infer_ctx)?; + return Some(crate::naming::build_member_fqn(&class_fqn, member_name)); + } + + return None; + } + + if parent.kind() == "field_access" && parent.child_by_field_name("field") == Some(*node) { + return crate::inference::strategy::FieldAccessInfer.infer_member(&parent, infer_ctx); + } + } + + resolve_member_declaration_fqn(node, infer_ctx) +} + +fn resolve_member_declaration_fqn(node: &Node, infer_ctx: &InferContext) -> Option { + let parent = node.parent()?; + if parent.child_by_field_name("name") != Some(*node) { + return None; + } + + let member_name = node.utf8_text(infer_ctx.source.as_bytes()).ok()?; + let class_fqn = find_enclosing_class_fqn(node, infer_ctx)?; + + match parent.kind() { + "method_declaration" | "constructor_declaration" => { + Some(crate::naming::build_member_fqn(&class_fqn, member_name)) + } + "variable_declarator" => { + if parent.parent().map(|p| p.kind()) == Some("field_declaration") { + return Some(crate::naming::build_member_fqn(&class_fqn, member_name)); + } + None + } + _ => None, + } +} + +fn find_enclosing_class_fqn(node: &Node, infer_ctx: &InferContext) -> Option { + let sm = infer_ctx.scope_manager?; + let start_scope = find_start_scope_id(node, sm)?; + sm.find_enclosing_class(start_scope) +} + +fn member_fqn_matches_target(resolved: &str, target: &str, infer_ctx: &InferContext) -> bool { + if resolved == target { + return true; + } + + let (Some((resolved_owner, resolved_name)), Some((target_owner, target_name))) = + (split_member_fqn(resolved), split_member_fqn(target)) + else { + return false; + }; + + if resolved_name != target_name { + return false; + } + + let resolved_ty = TypeRef::Id(resolved_owner.to_string()); + let target_ty = TypeRef::Id(target_owner.to_string()); + + infer_ctx.ts.is_subtype(&resolved_ty, &target_ty) + || infer_ctx.ts.is_subtype(&target_ty, &resolved_ty) +} + +fn split_member_fqn(fqn: &str) -> Option<(&str, &str)> { + let (owner, member) = fqn.split_once('#')?; + Some((owner, member)) +} diff --git a/crates/plugin/src/cap/runtime.rs b/crates/plugin/src/cap/runtime.rs index 43abcd2..ccd862e 100644 --- a/crates/plugin/src/cap/runtime.rs +++ b/crates/plugin/src/cap/runtime.rs @@ -28,7 +28,13 @@ pub trait SymbolQueryService: Send + Sync { pub trait LspSyntaxService: Send + Sync { fn parse(&self, source: &str, old_tree: Option<&Tree>) -> Option; fn extract_symbols(&self, tree: &Tree, source: &str) -> Vec; - fn find_occurrences(&self, source: &str, tree: &Tree, target: &SymbolResolution) -> Vec; + fn find_occurrences( + &self, + source: &str, + tree: &Tree, + target: &SymbolResolution, + index: Option<&dyn CodeGraph>, + ) -> Vec; } pub trait ReferenceCheckService: Send + Sync { From 9e30ac5061128f790c19e1befd0751ffde4c5743 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Thu, 12 Feb 2026 16:22:57 +0800 Subject: [PATCH 26/49] inference: add autoboxing and varargs method matching --- .../lang-java/src/inference/core/subtyping.rs | 47 +++++++ .../src/inference/core/type_system.rs | 93 +++++++++++-- crates/lang-java/tests/inference_mock_test.rs | 122 ++++++++++++++++++ 3 files changed, 250 insertions(+), 12 deletions(-) diff --git a/crates/lang-java/src/inference/core/subtyping.rs b/crates/lang-java/src/inference/core/subtyping.rs index 76d5363..b887dd1 100644 --- a/crates/lang-java/src/inference/core/subtyping.rs +++ b/crates/lang-java/src/inference/core/subtyping.rs @@ -31,6 +31,25 @@ pub fn is_subtype(sub: &TypeRef, super_type: &TypeRe // Primitive widening (TypeRef::Raw(s1), TypeRef::Raw(s2)) => is_primitive_subtype(s1, s2), + // Primitive boxing + reference widening, e.g. int -> Integer / Number / Object + (TypeRef::Raw(prim), TypeRef::Id(super_id)) => { + if let Some(wrapper) = primitive_to_wrapper(prim) { + if wrapper == super_id { + return true; + } + return is_class_subtype(wrapper, super_id, ts); + } + false + } + + // Wrapper unboxing (+ primitive widening), e.g. Integer -> int / long + (TypeRef::Id(sub_id), TypeRef::Raw(super_prim)) => { + if let Some(unboxed) = wrapper_to_primitive(sub_id) { + return unboxed == super_prim || is_primitive_subtype(unboxed, super_prim); + } + false + } + // Class/Interface hierarchy (TypeRef::Id(sub_id), TypeRef::Id(super_id)) => is_class_subtype(sub_id, super_id, ts), @@ -45,6 +64,34 @@ pub fn is_subtype(sub: &TypeRef, super_type: &TypeRe } } +fn primitive_to_wrapper(primitive: &str) -> Option<&'static str> { + match primitive { + "byte" => Some("java.lang.Byte"), + "short" => Some("java.lang.Short"), + "char" => Some("java.lang.Character"), + "int" => Some("java.lang.Integer"), + "long" => Some("java.lang.Long"), + "float" => Some("java.lang.Float"), + "double" => Some("java.lang.Double"), + "boolean" => Some("java.lang.Boolean"), + _ => None, + } +} + +fn wrapper_to_primitive(wrapper: &str) -> Option<&'static str> { + match wrapper { + "java.lang.Byte" => Some("byte"), + "java.lang.Short" => Some("short"), + "java.lang.Character" => Some("char"), + "java.lang.Integer" => Some("int"), + "java.lang.Long" => Some("long"), + "java.lang.Float" => Some("float"), + "java.lang.Double" => Some("double"), + "java.lang.Boolean" => Some("boolean"), + _ => None, + } +} + fn is_primitive_subtype(sub: &str, sup: &str) -> bool { match sub { "byte" => matches!(sup, "short" | "int" | "long" | "float" | "double"), diff --git a/crates/lang-java/src/inference/core/type_system.rs b/crates/lang-java/src/inference/core/type_system.rs index 9cd498e..3b37929 100644 --- a/crates/lang-java/src/inference/core/type_system.rs +++ b/crates/lang-java/src/inference/core/type_system.rs @@ -3,7 +3,7 @@ //! These traits abstract away the data source, allowing the inference //! engine to work with CodeGraph, stubs, or mock implementations. -use super::types::{MemberInfo, TypeInfo, TypeResolutionContext}; +use super::types::{MemberInfo, ParameterInfo, TypeInfo, TypeResolutionContext}; /// Provides type information by FQN. /// @@ -122,17 +122,30 @@ pub trait JavaTypeSystem: TypeProvider + InheritanceProvider + MemberProvider { // 2. Subtype match (widening) for cand in candidates { if let Some(params) = &cand.parameters { - if params.len() == arg_types.len() { - let mut match_all = true; - for (p, a) in params.iter().zip(arg_types.iter()) { - if !self.is_subtype(a, &p.type_ref) { - match_all = false; - break; - } - } - if match_all { - return Some(cand.clone()); - } + if matches_fixed_arity(params, arg_types, |arg, expected| { + self.is_subtype(arg, expected) + }) { + return Some(cand.clone()); + } + } + } + + // 3. Varargs exact match (heuristic: last array parameter is treated as varargs) + for cand in candidates { + if let Some(params) = &cand.parameters { + if matches_varargs_arity(params, arg_types, |arg, expected| arg == expected) { + return Some(cand.clone()); + } + } + } + + // 4. Varargs subtype match + for cand in candidates { + if let Some(params) = &cand.parameters { + if matches_varargs_arity(params, arg_types, |arg, expected| { + self.is_subtype(arg, expected) + }) { + return Some(cand.clone()); } } } @@ -155,3 +168,59 @@ pub trait JavaTypeSystem: TypeProvider + InheritanceProvider + MemberProvider { // Blanket implementation: any type implementing all three traits gets JavaTypeSystem impl JavaTypeSystem for T {} + +fn matches_fixed_arity(params: &[ParameterInfo], arg_types: &[TypeRef], mut matches: F) -> bool +where + F: FnMut(&TypeRef, &TypeRef) -> bool, +{ + if params.len() != arg_types.len() { + return false; + } + + params + .iter() + .zip(arg_types.iter()) + .all(|(p, a)| matches(a, &p.type_ref)) +} + +fn matches_varargs_arity(params: &[ParameterInfo], arg_types: &[TypeRef], mut matches: F) -> bool +where + F: FnMut(&TypeRef, &TypeRef) -> bool, +{ + let Some(last_param) = params.last() else { + return false; + }; + + let TypeRef::Array { element, .. } = &last_param.type_ref else { + return false; + }; + + let fixed_count = params.len() - 1; + if arg_types.len() < fixed_count { + return false; + } + + // Prefix arguments (before varargs tail) + if !params[..fixed_count] + .iter() + .zip(arg_types[..fixed_count].iter()) + .all(|(p, a)| matches(a, &p.type_ref)) + { + return false; + } + + // No varargs arguments provided + if arg_types.len() == fixed_count { + return true; + } + + // Direct array pass-through: foo(String[]) called with one String[] argument. + if arg_types.len() == params.len() && matches(&arg_types[fixed_count], &last_param.type_ref) { + return true; + } + + // Expanded varargs: foo(String...) called with N String arguments. + arg_types[fixed_count..] + .iter() + .all(|a| matches(a, element.as_ref())) +} diff --git a/crates/lang-java/tests/inference_mock_test.rs b/crates/lang-java/tests/inference_mock_test.rs index b4da127..158caf2 100644 --- a/crates/lang-java/tests/inference_mock_test.rs +++ b/crates/lang-java/tests/inference_mock_test.rs @@ -802,3 +802,125 @@ class Demo { }) ); } + +#[test] +fn test_resolve_method_autoboxing_primitive_to_wrapper() { + let ts = MockTypeSystem::new().add_class("java.lang.Integer", Some("java.lang.Object")); + + let candidates = vec![MemberInfo { + name: "set".to_string(), + fqn: "Demo#set".to_string(), + kind: MemberKind::Method, + declaring_type: "Demo".to_string(), + type_ref: TypeRef::Raw("void".into()), + parameters: Some(vec![naviscope_java::inference::ParameterInfo { + name: "value".to_string(), + type_ref: TypeRef::Id("java.lang.Integer".into()), + }]), + modifiers: vec![], + generic_signature: None, + }]; + + let resolved = ts.resolve_method(&candidates, &[TypeRef::Raw("int".into())]); + assert!(resolved.is_some()); +} + +#[test] +fn test_resolve_method_unboxing_with_widening() { + let ts = MockTypeSystem::new().add_class("java.lang.Integer", Some("java.lang.Object")); + + let candidates = vec![MemberInfo { + name: "set".to_string(), + fqn: "Demo#set".to_string(), + kind: MemberKind::Method, + declaring_type: "Demo".to_string(), + type_ref: TypeRef::Raw("void".into()), + parameters: Some(vec![naviscope_java::inference::ParameterInfo { + name: "value".to_string(), + type_ref: TypeRef::Raw("long".into()), + }]), + modifiers: vec![], + generic_signature: None, + }]; + + let resolved = ts.resolve_method(&candidates, &[TypeRef::Id("java.lang.Integer".into())]); + assert!(resolved.is_some()); +} + +#[test] +fn test_resolve_method_varargs_expanded_arguments() { + let ts = MockTypeSystem::new().add_class("java.lang.String", Some("java.lang.Object")); + + let candidates = vec![MemberInfo { + name: "log".to_string(), + fqn: "Demo#log".to_string(), + kind: MemberKind::Method, + declaring_type: "Demo".to_string(), + type_ref: TypeRef::Raw("void".into()), + parameters: Some(vec![ + naviscope_java::inference::ParameterInfo { + name: "tag".to_string(), + type_ref: TypeRef::Id("java.lang.String".into()), + }, + naviscope_java::inference::ParameterInfo { + name: "args".to_string(), + type_ref: TypeRef::Array { + element: Box::new(TypeRef::Id("java.lang.String".into())), + dimensions: 1, + }, + }, + ]), + modifiers: vec![], + generic_signature: None, + }]; + + let resolved = ts.resolve_method( + &candidates, + &[ + TypeRef::Id("java.lang.String".into()), + TypeRef::Id("java.lang.String".into()), + TypeRef::Id("java.lang.String".into()), + ], + ); + assert!(resolved.is_some()); +} + +#[test] +fn test_resolve_method_varargs_array_passthrough() { + let ts = MockTypeSystem::new().add_class("java.lang.String", Some("java.lang.Object")); + + let candidates = vec![MemberInfo { + name: "log".to_string(), + fqn: "Demo#log".to_string(), + kind: MemberKind::Method, + declaring_type: "Demo".to_string(), + type_ref: TypeRef::Raw("void".into()), + parameters: Some(vec![ + naviscope_java::inference::ParameterInfo { + name: "tag".to_string(), + type_ref: TypeRef::Id("java.lang.String".into()), + }, + naviscope_java::inference::ParameterInfo { + name: "args".to_string(), + type_ref: TypeRef::Array { + element: Box::new(TypeRef::Id("java.lang.String".into())), + dimensions: 1, + }, + }, + ]), + modifiers: vec![], + generic_signature: None, + }]; + + let resolved = ts.resolve_method( + &candidates, + &[ + TypeRef::Id("java.lang.String".into()), + TypeRef::Array { + element: Box::new(TypeRef::Id("java.lang.String".into())), + dimensions: 1, + }, + ], + ); + assert!(resolved.is_some()); +} From 6240e88906d527c2a3b58a5852c2ec752fee3ca8 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Thu, 12 Feb 2026 17:42:18 +0800 Subject: [PATCH 27/49] inference: make varargs explicit across parser and type system --- crates/lang-java/src/cap/presentation.rs | 24 ++++++- .../lang-java/src/inference/adapters/graph.rs | 2 + .../src/inference/core/type_system.rs | 8 +++ crates/lang-java/src/inference/core/types.rs | 2 + crates/lang-java/src/model.rs | 5 ++ crates/lang-java/src/parser/ast/metadata.rs | 8 ++- .../src/queries/java_definitions.scm | 5 ++ .../src/resolve/external/converter.rs | 2 + crates/lang-java/src/resolve/external/mod.rs | 8 ++- crates/lang-java/tests/inference_mock_test.rs | 70 +++++++++++++++++++ 10 files changed, 128 insertions(+), 6 deletions(-) diff --git a/crates/lang-java/src/cap/presentation.rs b/crates/lang-java/src/cap/presentation.rs index 427fab2..8241a9a 100644 --- a/crates/lang-java/src/cap/presentation.rs +++ b/crates/lang-java/src/cap/presentation.rs @@ -72,12 +72,22 @@ impl NodePresenter for JavaPlugin { let params_str = parameters .iter() .map(|p| { + let param_type = if p.is_varargs { + match &p.type_ref { + naviscope_api::models::TypeRef::Array { element, .. } => { + format!("{}...", crate::model::fmt_type(element)) + } + _ => format!("{}...", crate::model::fmt_type(&p.type_ref)), + } + } else { + crate::model::fmt_type(&p.type_ref) + }; format!( "{}: {}", fqns.resolve_atom(Symbol( lasso::Spur::try_from_usize(p.name_sid as usize).unwrap(), )), - crate::model::fmt_type(&p.type_ref) + param_type ) }) .collect::>() @@ -141,10 +151,20 @@ impl NodePresenter for JavaPlugin { let params_str = parameters .iter() .map(|p| { + let param_type = if p.is_varargs { + match &p.type_ref { + naviscope_api::models::TypeRef::Array { element, .. } => { + format!("{}...", crate::model::fmt_type_uninterned(element)) + } + _ => format!("{}...", crate::model::fmt_type_uninterned(&p.type_ref)), + } + } else { + crate::model::fmt_type_uninterned(&p.type_ref) + }; format!( "{}: {}", p.name, - crate::model::fmt_type_uninterned(&p.type_ref) + param_type ) }) .collect::>() diff --git a/crates/lang-java/src/inference/adapters/graph.rs b/crates/lang-java/src/inference/adapters/graph.rs index 3313d35..b1c3f05 100644 --- a/crates/lang-java/src/inference/adapters/graph.rs +++ b/crates/lang-java/src/inference/adapters/graph.rs @@ -147,6 +147,7 @@ impl<'a> CodeGraphTypeSystem<'a> { .map(|(i, p)| ParameterInfo { name: format!("arg{}", i), // Cannot resolve SID without interner type_ref: p.type_ref.clone(), + is_varargs: p.is_varargs, }) .collect(), ); @@ -164,6 +165,7 @@ impl<'a> CodeGraphTypeSystem<'a> { .map(|p| ParameterInfo { name: p.name.clone(), type_ref: p.type_ref.clone(), + is_varargs: p.is_varargs, }) .collect(), ); diff --git a/crates/lang-java/src/inference/core/type_system.rs b/crates/lang-java/src/inference/core/type_system.rs index 3b37929..6df9382 100644 --- a/crates/lang-java/src/inference/core/type_system.rs +++ b/crates/lang-java/src/inference/core/type_system.rs @@ -191,6 +191,10 @@ where return false; }; + if !is_varargs_parameter(last_param) { + return false; + } + let TypeRef::Array { element, .. } = &last_param.type_ref else { return false; }; @@ -224,3 +228,7 @@ where .iter() .all(|a| matches(a, element.as_ref())) } + +fn is_varargs_parameter(param: &ParameterInfo) -> bool { + param.is_varargs +} diff --git a/crates/lang-java/src/inference/core/types.rs b/crates/lang-java/src/inference/core/types.rs index 0981af5..447ceb2 100644 --- a/crates/lang-java/src/inference/core/types.rs +++ b/crates/lang-java/src/inference/core/types.rs @@ -87,6 +87,8 @@ pub struct ParameterInfo { pub name: String, /// Parameter type pub type_ref: TypeRef, + /// True when this parameter is declared with `...` varargs syntax. + pub is_varargs: bool, } /// Context for type name resolution diff --git a/crates/lang-java/src/model.rs b/crates/lang-java/src/model.rs index a6108f0..7c2184e 100644 --- a/crates/lang-java/src/model.rs +++ b/crates/lang-java/src/model.rs @@ -142,6 +142,7 @@ impl JavaIndexMetadata { .map(|p| JavaParameterStorage { name_sid: ctx.intern_str(&p.name), type_ref: p.type_ref.clone(), + is_varargs: p.is_varargs, }) .collect(), is_constructor: *is_constructor, @@ -168,12 +169,16 @@ impl NodeMetadata for JavaNodeMetadata { pub struct JavaParameter { pub name: String, pub type_ref: TypeRef, + #[serde(default)] + pub is_varargs: bool, } #[derive(Debug, Serialize, Deserialize, Clone)] pub struct JavaParameterStorage { pub name_sid: u32, pub type_ref: TypeRef, + #[serde(default)] + pub is_varargs: bool, } pub fn fmt_type(t: &TypeRef) -> String { diff --git a/crates/lang-java/src/parser/ast/metadata.rs b/crates/lang-java/src/parser/ast/metadata.rs index 1164ae7..6294a86 100644 --- a/crates/lang-java/src/parser/ast/metadata.rs +++ b/crates/lang-java/src/parser/ast/metadata.rs @@ -207,13 +207,19 @@ impl JavaParser { .utf8_text(source.as_bytes()) .unwrap_or_default() .to_string(); + let is_varargs = captures + .iter() + .find(|c| c.index == self.indices.param_match) + .map(|c| c.node.kind() == "spread_parameter") + .unwrap_or(false); if !parameters .iter() - .any(|p| p.name == n && p.type_ref == t_ref) + .any(|p| p.name == n && p.type_ref == t_ref && p.is_varargs == is_varargs) { parameters.push(JavaParameter { type_ref: t_ref, name: n, + is_varargs, }); } self.generate_typed_as_edges(t_node, source, &fqn_id, relations); diff --git a/crates/lang-java/src/queries/java_definitions.scm b/crates/lang-java/src/queries/java_definitions.scm index 57f7633..9f91428 100644 --- a/crates/lang-java/src/queries/java_definitions.scm +++ b/crates/lang-java/src/queries/java_definitions.scm @@ -65,6 +65,11 @@ type: (_) @param_type name: (identifier) @param_name) @param_match +(spread_parameter + (_unannotated_type) @param_type + (variable_declarator + name: (identifier) @param_name)) @param_match + ;; Call and Instantiation (method_invocation name: (identifier) @call_name) @method_call diff --git a/crates/lang-java/src/resolve/external/converter.rs b/crates/lang-java/src/resolve/external/converter.rs index 2f98e21..2bfe681 100644 --- a/crates/lang-java/src/resolve/external/converter.rs +++ b/crates/lang-java/src/resolve/external/converter.rs @@ -10,6 +10,7 @@ impl JavaTypeConverter { pub fn convert_method( descriptor: &str, + is_varargs: bool, ) -> Result<(TypeRef, Vec), ristretto_classfile::Error> { let (params, ret) = FieldType::parse_method_descriptor(descriptor)?; let return_type = match ret { @@ -23,6 +24,7 @@ impl JavaTypeConverter { .map(|(i, field_type)| crate::model::JavaParameter { name: format!("arg{}", i), type_ref: Self::convert_field(field_type), + is_varargs: is_varargs && i == params.len().saturating_sub(1), }) .collect(); diff --git a/crates/lang-java/src/resolve/external/mod.rs b/crates/lang-java/src/resolve/external/mod.rs index c7f6c64..4599960 100644 --- a/crates/lang-java/src/resolve/external/mod.rs +++ b/crates/lang-java/src/resolve/external/mod.rs @@ -2,7 +2,7 @@ use naviscope_plugin::{ AssetEntry, AssetIndexer, AssetSource, AssetSourceLocator, GlobalParseResult, IndexNode, StubGenerator, }; -use ristretto_classfile::{ClassAccessFlags, ClassFile}; +use ristretto_classfile::{ClassAccessFlags, ClassFile, MethodAccessFlags}; use ristretto_jimage::Image; use std::collections::HashSet; use std::fs::File; @@ -290,8 +290,10 @@ impl JavaExternalResolver { .constant_pool .try_get_utf8(method.descriptor_index) .map_err(|e| format!("Failed to parse method descriptor: {e:?}"))?; - let (return_type, parameters) = JavaTypeConverter::convert_method(method_descriptor) - .map_err(|e| format!("Failed to parse method signature: {e:?}"))?; + let is_varargs = method.access_flags.contains(MethodAccessFlags::VARARGS); + let (return_type, parameters) = + JavaTypeConverter::convert_method(method_descriptor, is_varargs) + .map_err(|e| format!("Failed to parse method signature: {e:?}"))?; let modifiers = JavaModifierConverter::parse_method(method.access_flags); let metadata = crate::model::JavaIndexMetadata::Method { modifiers, diff --git a/crates/lang-java/tests/inference_mock_test.rs b/crates/lang-java/tests/inference_mock_test.rs index 158caf2..2340aa2 100644 --- a/crates/lang-java/tests/inference_mock_test.rs +++ b/crates/lang-java/tests/inference_mock_test.rs @@ -816,6 +816,7 @@ fn test_resolve_method_autoboxing_primitive_to_wrapper() { parameters: Some(vec![naviscope_java::inference::ParameterInfo { name: "value".to_string(), type_ref: TypeRef::Id("java.lang.Integer".into()), + is_varargs: false, }]), modifiers: vec![], generic_signature: None, @@ -838,6 +839,7 @@ fn test_resolve_method_unboxing_with_widening() { parameters: Some(vec![naviscope_java::inference::ParameterInfo { name: "value".to_string(), type_ref: TypeRef::Raw("long".into()), + is_varargs: false, }]), modifiers: vec![], generic_signature: None, @@ -861,6 +863,7 @@ fn test_resolve_method_varargs_expanded_arguments() { naviscope_java::inference::ParameterInfo { name: "tag".to_string(), type_ref: TypeRef::Id("java.lang.String".into()), + is_varargs: false, }, naviscope_java::inference::ParameterInfo { name: "args".to_string(), @@ -868,6 +871,7 @@ fn test_resolve_method_varargs_expanded_arguments() { element: Box::new(TypeRef::Id("java.lang.String".into())), dimensions: 1, }, + is_varargs: true, }, ]), modifiers: vec![], @@ -899,6 +903,7 @@ fn test_resolve_method_varargs_array_passthrough() { naviscope_java::inference::ParameterInfo { name: "tag".to_string(), type_ref: TypeRef::Id("java.lang.String".into()), + is_varargs: false, }, naviscope_java::inference::ParameterInfo { name: "args".to_string(), @@ -906,6 +911,7 @@ fn test_resolve_method_varargs_array_passthrough() { element: Box::new(TypeRef::Id("java.lang.String".into())), dimensions: 1, }, + is_varargs: true, }, ]), modifiers: vec![], @@ -924,3 +930,67 @@ fn test_resolve_method_varargs_array_passthrough() { ); assert!(resolved.is_some()); } + +#[test] +fn test_resolve_method_varargs_uses_explicit_marker() { + let ts = MockTypeSystem::new().add_class("java.lang.String", Some("java.lang.Object")); + + let non_varargs = MemberInfo { + name: "log".to_string(), + fqn: "Demo#logArray".to_string(), + kind: MemberKind::Method, + declaring_type: "Demo".to_string(), + type_ref: TypeRef::Raw("void".into()), + parameters: Some(vec![ + naviscope_java::inference::ParameterInfo { + name: "tag".to_string(), + type_ref: TypeRef::Id("java.lang.String".into()), + is_varargs: false, + }, + naviscope_java::inference::ParameterInfo { + name: "args".to_string(), + type_ref: TypeRef::Array { + element: Box::new(TypeRef::Id("java.lang.String".into())), + dimensions: 1, + }, + is_varargs: false, + }, + ]), + modifiers: vec![], + generic_signature: None, + }; + + let varargs = MemberInfo { + fqn: "Demo#logVarargs".to_string(), + ..non_varargs.clone() + }; + let varargs = MemberInfo { + parameters: Some(vec![ + naviscope_java::inference::ParameterInfo { + name: "tag".to_string(), + type_ref: TypeRef::Id("java.lang.String".into()), + is_varargs: false, + }, + naviscope_java::inference::ParameterInfo { + name: "args".to_string(), + type_ref: TypeRef::Array { + element: Box::new(TypeRef::Id("java.lang.String".into())), + dimensions: 1, + }, + is_varargs: true, + }, + ]), + ..varargs + }; + + let resolved = ts.resolve_method( + &[non_varargs, varargs.clone()], + &[ + TypeRef::Id("java.lang.String".into()), + TypeRef::Id("java.lang.String".into()), + TypeRef::Id("java.lang.String".into()), + ], + ); + + assert_eq!(resolved.map(|m| m.fqn), Some("Demo#logVarargs".to_string())); +} From dac5121db9cad673c9039e0169468e317459794a Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Thu, 12 Feb 2026 17:45:37 +0800 Subject: [PATCH 28/49] lsp: enrich hover for locals and member owner context --- crates/lang-java/src/resolve/mod.rs | 9 ++------- crates/lsp/src/hover.rs | 14 +++++++++++++- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/crates/lang-java/src/resolve/mod.rs b/crates/lang-java/src/resolve/mod.rs index 9d80bea..e89f5f7 100644 --- a/crates/lang-java/src/resolve/mod.rs +++ b/crates/lang-java/src/resolve/mod.rs @@ -149,13 +149,8 @@ impl JavaPlugin { if decl_line < usage_point.row || (decl_line == usage_point.row && decl_col < usage_point.column) { - // Extract type name from reference if possible, or stringify the type - // The old find_local_declaration returned Option for type name. - // We have info.type_ref. - let type_name = match &info.type_ref { - TypeRef::Id(id) | TypeRef::Raw(id) => Some(id.clone()), - _ => None, // Complex types might need rendering - }; + // Render the full local type for hover/highlight consumers. + let type_name = Some(crate::model::fmt_type(&info.type_ref)); return Some(SymbolResolution::Local(info.range.clone(), type_name)); } } diff --git a/crates/lsp/src/hover.rs b/crates/lsp/src/hover.rs index 6082593..4d1ffda 100644 --- a/crates/lsp/src/hover.rs +++ b/crates/lsp/src/hover.rs @@ -36,11 +36,19 @@ pub async fn hover(server: &LspServer, params: HoverParams) -> Result { + naviscope_api::models::SymbolResolution::Local(range, type_name) => { hover_text.push_str("**Local variable**"); if let Some(t) = type_name { hover_text.push_str(&format!(": `{}`", t)); } + hover_text.push_str("\n\n"); + hover_text.push_str(&format!( + "Declared at `{}:{}`", + range.start_line + 1, + range.start_col + 1 + )); + hover_text.push_str("\n\n"); + hover_text.push_str("*Scope: local*"); } naviscope_api::models::SymbolResolution::Precise(fqn, _) | naviscope_api::models::SymbolResolution::Global(fqn) => { @@ -57,6 +65,10 @@ pub async fn hover(server: &LspServer, params: HoverParams) -> Result Date: Thu, 12 Feb 2026 19:59:15 +0800 Subject: [PATCH 29/49] lang-java: harden inference adapters and enable lambda heuristic test --- crates/lang-java/src/cap/presentation.rs | 172 +++++++-- .../lang-java/src/inference/adapters/graph.rs | 69 +++- .../src/inference/core/unification.rs | 7 + .../lang-java/src/inference/strategy/local.rs | 244 ++++++++++++- .../tests/core_inference_regression.rs | 201 ++++++++++ .../tests/graph_adapter_regression.rs | 342 ++++++++++++++++++ crates/lang-java/tests/java_integration.rs | 1 - 7 files changed, 994 insertions(+), 42 deletions(-) create mode 100644 crates/lang-java/tests/core_inference_regression.rs create mode 100644 crates/lang-java/tests/graph_adapter_regression.rs diff --git a/crates/lang-java/src/cap/presentation.rs b/crates/lang-java/src/cap/presentation.rs index 8241a9a..de9c4f0 100644 --- a/crates/lang-java/src/cap/presentation.rs +++ b/crates/lang-java/src/cap/presentation.rs @@ -8,6 +8,11 @@ use std::sync::Arc; impl NodePresenter for JavaPlugin { fn render_display_node(&self, node: &GraphNode, fqns: &dyn FqnReader) -> DisplayGraphNode { + let resolve_sid = |sid: u32| { + lasso::Spur::try_from_usize(sid as usize) + .map(|spur| fqns.resolve_atom(Symbol(spur)).to_string()) + }; + let mut display = DisplayGraphNode { id: crate::naming::JavaNamingConvention::default().render_fqn(node.id, fqns), name: fqns.resolve_atom(node.name).to_string(), @@ -40,12 +45,7 @@ impl NodePresenter for JavaPlugin { | crate::model::JavaNodeMetadata::Annotation { modifiers_sids } => { display.modifiers = modifiers_sids .iter() - .map(|&s| { - fqns.resolve_atom(Symbol( - lasso::Spur::try_from_usize(s as usize).unwrap(), - )) - .to_string() - }) + .filter_map(|&s| resolve_sid(s)) .collect(); let prefix = match node.kind { NodeKind::Interface => "interface", @@ -62,16 +62,12 @@ impl NodePresenter for JavaPlugin { } => { display.modifiers = modifiers_sids .iter() - .map(|&s| { - fqns.resolve_atom(Symbol( - lasso::Spur::try_from_usize(s as usize).unwrap(), - )) - .to_string() - }) + .filter_map(|&s| resolve_sid(s)) .collect(); let params_str = parameters .iter() - .map(|p| { + .enumerate() + .map(|(idx, p)| { let param_type = if p.is_varargs { match &p.type_ref { naviscope_api::models::TypeRef::Array { element, .. } => { @@ -84,9 +80,8 @@ impl NodePresenter for JavaPlugin { }; format!( "{}: {}", - fqns.resolve_atom(Symbol( - lasso::Spur::try_from_usize(p.name_sid as usize).unwrap(), - )), + resolve_sid(p.name_sid) + .unwrap_or_else(|| format!("arg{}", idx)), param_type ) }) @@ -109,12 +104,7 @@ impl NodePresenter for JavaPlugin { } => { display.modifiers = modifiers_sids .iter() - .map(|&s| { - fqns.resolve_atom(Symbol( - lasso::Spur::try_from_usize(s as usize).unwrap(), - )) - .to_string() - }) + .filter_map(|&s| resolve_sid(s)) .collect(); display.signature = Some(format!( "{}: {}", @@ -227,3 +217,141 @@ impl PresentationCap for JavaPlugin { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{JavaNodeMetadata, JavaParameterStorage}; + use lasso::Key; + use naviscope_api::models::fqn::FqnNode; + use naviscope_api::models::graph::{GraphNode, NodeKind}; + use naviscope_api::models::symbol::{FqnId, Symbol}; + use naviscope_api::models::TypeRef; + use std::collections::HashMap; + use std::sync::Arc; + + struct FakeFqnReader { + nodes: HashMap, + atoms: HashMap, + } + + impl FqnReader for FakeFqnReader { + fn resolve_node(&self, id: FqnId) -> Option { + self.nodes.get(&id).cloned() + } + + fn resolve_atom(&self, atom: Symbol) -> &str { + self.atoms + .get(&(atom.0.into_usize() as u32)) + .map(String::as_str) + .unwrap_or("") + } + } + + fn spur(id: usize) -> lasso::Spur { + lasso::Spur::try_from_usize(id).expect("valid spur") + } + + fn sym(id: usize) -> Symbol { + Symbol(spur(id)) + } + + fn fake_fqns() -> FakeFqnReader { + let mut nodes = HashMap::new(); + nodes.insert( + FqnId(1), + FqnNode { + parent: None, + name: sym(1), + kind: NodeKind::Package, + }, + ); + nodes.insert( + FqnId(2), + FqnNode { + parent: Some(FqnId(1)), + name: sym(2), + kind: NodeKind::Class, + }, + ); + nodes.insert( + FqnId(3), + FqnNode { + parent: Some(FqnId(2)), + name: sym(3), + kind: NodeKind::Method, + }, + ); + + let mut atoms = HashMap::new(); + atoms.insert(1, "com".to_string()); + atoms.insert(2, "User".to_string()); + atoms.insert(3, "setNames".to_string()); + atoms.insert(4, "java".to_string()); + atoms.insert(5, "public".to_string()); + atoms.insert(6, "names".to_string()); + + FakeFqnReader { nodes, atoms } + } + + #[test] + fn render_display_node_formats_varargs_signature() { + let plugin = JavaPlugin::new().expect("plugin"); + let fqns = fake_fqns(); + let metadata = JavaNodeMetadata::Method { + modifiers_sids: vec![5], + return_type: TypeRef::Raw("void".to_string()), + parameters: vec![JavaParameterStorage { + name_sid: 6, + type_ref: TypeRef::Array { + element: Box::new(TypeRef::Id("java.lang.String".to_string())), + dimensions: 1, + }, + is_varargs: true, + }], + is_constructor: false, + }; + + let node = GraphNode { + id: FqnId(3), + name: sym(3), + kind: NodeKind::Method, + lang: sym(4), + metadata: Arc::new(metadata), + ..GraphNode::default() + }; + + let display = plugin.render_display_node(&node, &fqns); + assert_eq!(display.signature.as_deref(), Some("setNames(names: String...) -> void")); + assert_eq!(display.modifiers, vec!["public".to_string()]); + } + + #[test] + fn render_display_node_invalid_sid_falls_back_without_panic() { + let plugin = JavaPlugin::new().expect("plugin"); + let fqns = fake_fqns(); + let metadata = JavaNodeMetadata::Method { + modifiers_sids: vec![u32::MAX], + return_type: TypeRef::Raw("void".to_string()), + parameters: vec![JavaParameterStorage { + name_sid: u32::MAX, + type_ref: TypeRef::Id("java.lang.String".to_string()), + is_varargs: false, + }], + is_constructor: false, + }; + + let node = GraphNode { + id: FqnId(3), + name: sym(3), + kind: NodeKind::Method, + lang: sym(4), + metadata: Arc::new(metadata), + ..GraphNode::default() + }; + + let display = plugin.render_display_node(&node, &fqns); + assert_eq!(display.signature.as_deref(), Some("setNames(arg0: String) -> void")); + assert!(display.modifiers.is_empty()); + } +} diff --git a/crates/lang-java/src/inference/adapters/graph.rs b/crates/lang-java/src/inference/adapters/graph.rs index b1c3f05..037dd4d 100644 --- a/crates/lang-java/src/inference/adapters/graph.rs +++ b/crates/lang-java/src/inference/adapters/graph.rs @@ -7,6 +7,7 @@ use naviscope_api::models::graph::{EdgeType, NodeKind, NodeMetadata}; use naviscope_api::models::symbol::{FqnId, Symbol}; use naviscope_plugin::{CodeGraph, Direction}; use lasso::Key; +use std::collections::HashSet; use std::sync::Arc; use crate::inference::{InheritanceProvider, MemberProvider, TypeProvider}; @@ -38,8 +39,28 @@ impl<'a> CodeGraphTypeSystem<'a> { } } + fn resolve_sid(&self, sid: u32) -> Option { + lasso::Spur::try_from_usize(sid as usize) + .map(|spur| self.graph.fqns().resolve_atom(Symbol(spur)).to_string()) + } + /// Extract modifiers from node metadata. fn extract_modifiers(&self, metadata: &Arc) -> Vec { + if let Some(java_meta) = metadata.as_any().downcast_ref::() { + return match java_meta { + JavaNodeMetadata::Class { modifiers_sids, .. } + | JavaNodeMetadata::Interface { modifiers_sids, .. } + | JavaNodeMetadata::Enum { modifiers_sids, .. } + | JavaNodeMetadata::Annotation { modifiers_sids } + | JavaNodeMetadata::Method { modifiers_sids, .. } + | JavaNodeMetadata::Field { modifiers_sids, .. } => modifiers_sids + .iter() + .filter_map(|sid| self.resolve_sid(*sid)) + .collect(), + _ => vec![], + }; + } + if let Some(java_meta) = metadata.as_any().downcast_ref::() { return match java_meta { JavaIndexMetadata::Class { modifiers, .. } => modifiers.clone(), @@ -65,8 +86,7 @@ impl<'a> CodeGraphTypeSystem<'a> { .. } => type_parameters_sids .iter() - .filter_map(|sid| lasso::Spur::try_from_usize(*sid as usize)) - .map(|spur| self.graph.fqns().resolve_atom(Symbol(spur)).to_string()) + .filter_map(|sid| self.resolve_sid(*sid)) .collect(), _ => vec![], }; @@ -145,7 +165,9 @@ impl<'a> CodeGraphTypeSystem<'a> { .iter() .enumerate() .map(|(i, p)| ParameterInfo { - name: format!("arg{}", i), // Cannot resolve SID without interner + name: self + .resolve_sid(p.name_sid) + .unwrap_or_else(|| format!("arg{}", i)), type_ref: p.type_ref.clone(), is_varargs: p.is_varargs, }) @@ -199,7 +221,15 @@ impl<'a> TypeProvider for CodeGraphTypeSystem<'a> { } } - // 2. Check wildcard imports + // 2. Check same package + if let Some(pkg) = &ctx.package { + let candidate = format!("{}.{}", pkg, simple_name); + if !self.graph.resolve_fqn(&candidate).is_empty() { + return Some(candidate); + } + } + + // 3. Check wildcard imports for imp in &ctx.imports { if imp.ends_with(".*") { let prefix = &imp[..imp.len() - 2]; @@ -210,14 +240,6 @@ impl<'a> TypeProvider for CodeGraphTypeSystem<'a> { } } - // 3. Check same package - if let Some(pkg) = &ctx.package { - let candidate = format!("{}.{}", pkg, simple_name); - if !self.graph.resolve_fqn(&candidate).is_empty() { - return Some(candidate); - } - } - // 4. Check java.lang let java_lang = format!("java.lang.{}", simple_name); if !self.graph.resolve_fqn(&java_lang).is_empty() { @@ -255,6 +277,7 @@ impl<'a> InheritanceProvider for CodeGraphTypeSystem<'a> { fn get_interfaces(&self, fqn: &str) -> Vec { let node_ids = self.graph.resolve_fqn(fqn); + let mut seen = HashSet::new(); let mut result = vec![]; for node_id in node_ids { @@ -263,7 +286,10 @@ impl<'a> InheritanceProvider for CodeGraphTypeSystem<'a> { .get_neighbors(node_id, Direction::Outgoing, Some(EdgeType::Implements)); for iface_id in neighbors { - result.push(self.render_fqn_id(iface_id)); + let iface_fqn = self.render_fqn_id(iface_id); + if seen.insert(iface_fqn.clone()) { + result.push(iface_fqn); + } } } @@ -276,6 +302,7 @@ impl<'a> InheritanceProvider for CodeGraphTypeSystem<'a> { fn get_direct_subtypes(&self, fqn: &str) -> Vec { let node_ids = self.graph.resolve_fqn(fqn); + let mut seen = HashSet::new(); let mut result = vec![]; for node_id in node_ids { @@ -286,14 +313,20 @@ impl<'a> InheritanceProvider for CodeGraphTypeSystem<'a> { Some(EdgeType::InheritsFrom), ); for sub_id in subs { - result.push(self.render_fqn_id(sub_id)); + let sub_fqn = self.render_fqn_id(sub_id); + if seen.insert(sub_fqn.clone()) { + result.push(sub_fqn); + } } let impls = self.graph .get_neighbors(node_id, Direction::Incoming, Some(EdgeType::Implements)); for sub_id in impls { - result.push(self.render_fqn_id(sub_id)); + let sub_fqn = self.render_fqn_id(sub_id); + if seen.insert(sub_fqn.clone()) { + result.push(sub_fqn); + } } } @@ -318,7 +351,7 @@ impl<'a> MemberProvider for CodeGraphTypeSystem<'a> { NodeKind::Method => MemberKind::Method, NodeKind::Field => MemberKind::Field, NodeKind::Constructor => MemberKind::Constructor, - _ => MemberKind::Method, + _ => continue, }; let type_ref = self.extract_type_from_metadata(&node.metadata); @@ -330,7 +363,7 @@ impl<'a> MemberProvider for CodeGraphTypeSystem<'a> { declaring_type: type_fqn.to_string(), type_ref, parameters: self.extract_parameters(&node.metadata), - modifiers: vec![], + modifiers: self.extract_modifiers(&node.metadata), generic_signature: None, }); } @@ -373,7 +406,7 @@ impl<'a> MemberProvider for CodeGraphTypeSystem<'a> { declaring_type: type_fqn.to_string(), type_ref, parameters: self.extract_parameters(&node.metadata), - modifiers: vec![], + modifiers: self.extract_modifiers(&node.metadata), generic_signature: None, }); } diff --git a/crates/lang-java/src/inference/core/unification.rs b/crates/lang-java/src/inference/core/unification.rs index 72fd34a..5b2a90f 100644 --- a/crates/lang-java/src/inference/core/unification.rs +++ b/crates/lang-java/src/inference/core/unification.rs @@ -43,6 +43,13 @@ impl Substitution { base: Box::new(self.apply(base)), args: args.iter().map(|arg| self.apply(arg)).collect(), }, + TypeRef::Wildcard { + bound, + is_upper_bound, + } => TypeRef::Wildcard { + bound: bound.as_ref().map(|b| Box::new(self.apply(b))), + is_upper_bound: *is_upper_bound, + }, // Primitives and others remain unchanged _ => ty.clone(), } diff --git a/crates/lang-java/src/inference/strategy/local.rs b/crates/lang-java/src/inference/strategy/local.rs index 26536d0..abefca2 100644 --- a/crates/lang-java/src/inference/strategy/local.rs +++ b/crates/lang-java/src/inference/strategy/local.rs @@ -1,7 +1,9 @@ //! Local variable type inference. use super::InferStrategy; +use crate::inference::core::unification::Substitution; use crate::inference::InferContext; +use crate::inference::TypeRefExt; use naviscope_api::models::TypeRef; use tree_sitter::Node; @@ -31,7 +33,12 @@ impl InferStrategy for LocalVarInfer { if sm.get_scope_id(parent.id()).is_some() { // Delegate to ScopeManager to lookup variable starting from this scope // The lookup method will automatically traverse up the scope chain - return sm.lookup(parent.id(), name); + if let Some(ty) = sm.lookup(parent.id(), name) { + if ty != TypeRef::Unknown { + return Some(ty); + } + } + return self.infer_lambda_parameter_type(node, name, ctx); } current = parent; } @@ -42,6 +49,241 @@ impl InferStrategy for LocalVarInfer { } } +impl LocalVarInfer { + fn infer_lambda_parameter_type( + &self, + node: &Node, + name: &str, + ctx: &InferContext, + ) -> Option { + let (lambda, lambda_param_index) = self.find_enclosing_lambda_param(node, name, ctx)?; + let (call_node, arg_index) = self.find_lambda_call_site(&lambda)?; + let expected_arg_type = + self.resolve_expected_argument_type(&call_node, &lambda, arg_index, ctx)?; + self.extract_lambda_parameter_type(&expected_arg_type, lambda_param_index, ctx) + } + + fn find_enclosing_lambda_param<'a>( + &self, + node: &Node<'a>, + name: &str, + ctx: &InferContext, + ) -> Option<(Node<'a>, usize)> { + let mut current = *node; + while let Some(parent) = current.parent() { + if parent.kind() == "lambda_expression" { + let params = parent.child_by_field_name("parameters")?; + let names = collect_lambda_parameter_names(¶ms, ctx.source); + if let Some(index) = names.iter().position(|n| n == name) { + return Some((parent, index)); + } + } + current = parent; + } + None + } + + fn find_lambda_call_site<'a>(&self, lambda: &Node<'a>) -> Option<(Node<'a>, usize)> { + let args_node = lambda.parent()?; + if args_node.kind() != "argument_list" { + return None; + } + let call_node = args_node.parent()?; + if call_node.kind() != "method_invocation" { + return None; + } + + let mut arg_index = None; + let mut idx = 0usize; + let mut cursor = args_node.walk(); + for child in args_node.children(&mut cursor) { + if !child.is_named() { + continue; + } + if child == *lambda { + arg_index = Some(idx); + break; + } + idx += 1; + } + + Some((call_node, arg_index?)) + } + + fn resolve_expected_argument_type( + &self, + call_node: &Node<'_>, + lambda_node: &Node<'_>, + arg_index: usize, + ctx: &InferContext, + ) -> Option { + let method_name_node = call_node.child_by_field_name("name")?; + let method_name = method_name_node.utf8_text(ctx.source.as_bytes()).ok()?; + + let receiver_type = if let Some(receiver) = call_node.child_by_field_name("object") { + super::infer_expression(&receiver, ctx)? + } else { + TypeRef::Id(ctx.enclosing_class.clone()?) + }; + let receiver_fqn = receiver_type.as_fqn()?; + + let candidates = ctx.ts.find_member_in_hierarchy(&receiver_fqn, method_name); + if candidates.is_empty() { + // Heuristic fallback when external SDK methods are not indexed. + // Example: List
.forEach(it -> it.hello()) => infer lambda param as A. + if method_name == "forEach" { + if let TypeRef::Generic { args, .. } = &receiver_type { + if let Some(first_arg) = args.first() { + return Some(TypeRef::Generic { + base: Box::new(TypeRef::Id( + "java.util.function.Consumer".to_string(), + )), + args: vec![first_arg.clone()], + }); + } + } + } + return None; + } + let candidates = self.apply_receiver_substitution(candidates, &receiver_type, ctx); + + let args_node = call_node.child_by_field_name("arguments")?; + let mut arg_types = Vec::new(); + let mut cursor = args_node.walk(); + for child in args_node.children(&mut cursor) { + if !child.is_named() { + continue; + } + if child == *lambda_node { + arg_types.push(TypeRef::Unknown); + } else if let Some(t) = super::infer_expression(&child, ctx) { + arg_types.push(t); + } else { + arg_types.push(TypeRef::Unknown); + } + } + + let resolved = ctx.ts.resolve_method(&candidates, &arg_types)?; + let params = resolved.parameters?; + params.get(arg_index).map(|p| p.type_ref.clone()) + } + + fn apply_receiver_substitution( + &self, + candidates: Vec, + receiver_type: &TypeRef, + ctx: &InferContext, + ) -> Vec { + let (base_fqn, receiver_args) = match receiver_type { + TypeRef::Generic { base, args } => { + let Some(base_fqn) = base.as_fqn() else { + return candidates; + }; + (base_fqn, args) + } + _ => return candidates, + }; + + let Some(type_info) = ctx.ts.get_type_info(&base_fqn) else { + return candidates; + }; + if type_info.type_parameters.len() != receiver_args.len() { + return candidates; + } + + let mut subst = Substitution::new(); + for (param, arg) in type_info.type_parameters.iter().zip(receiver_args.iter()) { + subst.insert(param.name.clone(), arg.clone()); + } + + candidates + .into_iter() + .map(|mut member| { + member.type_ref = subst.apply(&member.type_ref); + if let Some(params) = &mut member.parameters { + for p in params { + p.type_ref = subst.apply(&p.type_ref); + } + } + member + }) + .collect() + } + + fn extract_lambda_parameter_type( + &self, + expected_arg_type: &TypeRef, + lambda_param_index: usize, + ctx: &InferContext, + ) -> Option { + if let TypeRef::Generic { base, args } = expected_arg_type { + if lambda_param_index < args.len() { + return Some(unwrap_wildcard(args[lambda_param_index].clone())); + } + + if let Some(base_fqn) = base.as_fqn() { + for method_name in ["accept", "apply", "test", "run", "call", "get"] { + let members = ctx.ts.find_member_in_hierarchy(&base_fqn, method_name); + for member in members { + if let Some(params) = member.parameters { + if let Some(param) = params.get(lambda_param_index) { + return Some(unwrap_wildcard(param.type_ref.clone())); + } + } + } + } + } + } + + if let TypeRef::Wildcard { bound: Some(b), .. } = expected_arg_type { + return Some((**b).clone()); + } + + None + } +} + +fn unwrap_wildcard(ty: TypeRef) -> TypeRef { + match ty { + TypeRef::Wildcard { bound: Some(b), .. } => *b, + other => other, + } +} + +fn collect_lambda_parameter_names(params_node: &Node, source: &str) -> Vec { + if params_node.kind() == "identifier" { + return params_node + .utf8_text(source.as_bytes()) + .ok() + .map(|s| vec![s.to_string()]) + .unwrap_or_default(); + } + + let mut names = Vec::new(); + let mut cursor = params_node.walk(); + for child in params_node.children(&mut cursor) { + match child.kind() { + "formal_parameter" | "spread_parameter" => { + if let Some(name_node) = child.child_by_field_name("name") { + if let Ok(name) = name_node.utf8_text(source.as_bytes()) { + names.push(name.to_string()); + } + } + } + "inferred_parameters" => { + names.extend(collect_lambda_parameter_names(&child, source)); + } + "identifier" => { + if let Ok(name) = child.utf8_text(source.as_bytes()) { + names.push(name.to_string()); + } + } + _ => {} + } + } + names +} + /// Parse a type node into TypeRef. pub fn parse_type_node(node: &Node, ctx: &InferContext) -> Option { let kind = node.kind(); diff --git a/crates/lang-java/tests/core_inference_regression.rs b/crates/lang-java/tests/core_inference_regression.rs new file mode 100644 index 0000000..bb783d9 --- /dev/null +++ b/crates/lang-java/tests/core_inference_regression.rs @@ -0,0 +1,201 @@ +use naviscope_api::models::TypeRef; +use naviscope_java::inference::core::subtyping::is_subtype; +use naviscope_java::inference::core::unification::{Substitution, unify}; +use naviscope_java::inference::{ + InheritanceProvider, JavaTypeSystem, MemberInfo, MemberKind, MemberProvider, ParameterInfo, + TypeInfo, TypeProvider, TypeResolutionContext, +}; +use std::collections::{HashMap, HashSet, VecDeque}; + +#[derive(Default)] +struct MiniTypeSystem { + direct_parents: HashMap>, +} + +impl MiniTypeSystem { + fn with_parent(mut self, ty: &str, parent: &str) -> Self { + self.direct_parents + .entry(ty.to_string()) + .or_default() + .push(parent.to_string()); + self + } +} + +impl TypeProvider for MiniTypeSystem { + fn get_type_info(&self, _fqn: &str) -> Option { + None + } + + fn resolve_type_name( + &self, + _simple_name: &str, + _context: &TypeResolutionContext, + ) -> Option { + None + } +} + +impl InheritanceProvider for MiniTypeSystem { + fn get_superclass(&self, fqn: &str) -> Option { + self.direct_parents + .get(fqn) + .and_then(|p| p.first()) + .cloned() + } + + fn get_interfaces(&self, fqn: &str) -> Vec { + self.direct_parents + .get(fqn) + .map(|p| p.iter().skip(1).cloned().collect()) + .unwrap_or_default() + } + + fn walk_ancestors(&self, fqn: &str) -> Box + '_> { + let mut queue: VecDeque = VecDeque::new(); + let mut visited: HashSet = HashSet::new(); + let mut out = Vec::new(); + + if let Some(parents) = self.direct_parents.get(fqn) { + for p in parents { + queue.push_back(p.clone()); + } + } + + while let Some(curr) = queue.pop_front() { + if !visited.insert(curr.clone()) { + continue; + } + out.push(curr.clone()); + if let Some(parents) = self.direct_parents.get(&curr) { + for p in parents { + queue.push_back(p.clone()); + } + } + } + + Box::new(out.into_iter()) + } + + fn get_direct_subtypes(&self, fqn: &str) -> Vec { + self.direct_parents + .iter() + .filter(|(_, parents)| parents.iter().any(|p| p == fqn)) + .map(|(ty, _)| ty.clone()) + .collect() + } + + fn walk_descendants(&self, fqn: &str) -> Box + '_> { + Box::new(self.get_direct_subtypes(fqn).into_iter()) + } +} + +impl MemberProvider for MiniTypeSystem { + fn get_members(&self, _type_fqn: &str, _member_name: &str) -> Vec { + vec![] + } + + fn get_all_members(&self, _type_fqn: &str) -> Vec { + vec![] + } +} + +fn method(fqn: &str, param: TypeRef) -> MemberInfo { + MemberInfo { + name: "set".to_string(), + fqn: fqn.to_string(), + kind: MemberKind::Method, + declaring_type: "Demo".to_string(), + type_ref: TypeRef::Raw("void".to_string()), + parameters: Some(vec![ParameterInfo { + name: "value".to_string(), + type_ref: param, + is_varargs: false, + }]), + modifiers: vec![], + generic_signature: None, + } +} + +#[test] +fn resolve_method_prefers_exact_match_over_wider_match() { + let ts = MiniTypeSystem::default() + .with_parent("java.lang.Integer", "java.lang.Number") + .with_parent("java.lang.Number", "java.lang.Object"); + + let candidates = vec![ + method("Demo#setNumber", TypeRef::Id("java.lang.Number".to_string())), + method("Demo#setInteger", TypeRef::Id("java.lang.Integer".to_string())), + ]; + + let resolved = ts + .resolve_method(&candidates, &[TypeRef::Id("java.lang.Integer".to_string())]) + .expect("resolved"); + assert_eq!(resolved.fqn, "Demo#setInteger"); +} + +#[test] +fn subtyping_supports_boxing_and_reference_widening() { + let ts = MiniTypeSystem::default() + .with_parent("java.lang.Integer", "java.lang.Number") + .with_parent("java.lang.Number", "java.lang.Object"); + + assert!(is_subtype( + &TypeRef::Raw("int".to_string()), + &TypeRef::Id("java.lang.Number".to_string()), + &ts + )); +} + +#[test] +fn unify_binds_generic_parameter_in_nested_type() { + let lhs = TypeRef::Generic { + base: Box::new(TypeRef::Id("java.util.List".to_string())), + args: vec![TypeRef::Id("T".to_string())], + }; + let rhs = TypeRef::Generic { + base: Box::new(TypeRef::Id("java.util.List".to_string())), + args: vec![TypeRef::Id("java.lang.String".to_string())], + }; + + let subst = unify(&lhs, &rhs).expect("unify"); + let applied = subst.apply(&TypeRef::Id("T".to_string())); + assert_eq!(applied, TypeRef::Id("java.lang.String".to_string())); +} + +#[test] +fn unify_rejects_conflicting_type_variable_bindings() { + let lhs = TypeRef::Generic { + base: Box::new(TypeRef::Id("Pair".to_string())), + args: vec![TypeRef::Id("T".to_string()), TypeRef::Id("T".to_string())], + }; + let rhs = TypeRef::Generic { + base: Box::new(TypeRef::Id("Pair".to_string())), + args: vec![ + TypeRef::Id("java.lang.String".to_string()), + TypeRef::Id("java.lang.Integer".to_string()), + ], + }; + + assert!(unify(&lhs, &rhs).is_none()); +} + +#[test] +fn substitution_applies_inside_wildcard_bounds() { + let mut subst = Substitution::new(); + subst.insert("E".to_string(), TypeRef::Id("com.A".to_string())); + + let ty = TypeRef::Wildcard { + bound: Some(Box::new(TypeRef::Id("E".to_string()))), + is_upper_bound: false, + }; + + let applied = subst.apply(&ty); + assert_eq!( + applied, + TypeRef::Wildcard { + bound: Some(Box::new(TypeRef::Id("com.A".to_string()))), + is_upper_bound: false, + } + ); +} diff --git a/crates/lang-java/tests/graph_adapter_regression.rs b/crates/lang-java/tests/graph_adapter_regression.rs new file mode 100644 index 0000000..bfc71dd --- /dev/null +++ b/crates/lang-java/tests/graph_adapter_regression.rs @@ -0,0 +1,342 @@ +mod common; + +use common::setup_java_test_graph; +use lasso::Key; +use naviscope_api::models::fqn::FqnNode; +use naviscope_api::models::graph::{EdgeType, GraphNode, NodeKind}; +use naviscope_api::models::symbol::{FqnId, FqnReader, Symbol}; +use naviscope_java::inference::adapters::CodeGraphTypeSystem; +use naviscope_java::inference::{ + InheritanceProvider, MemberProvider, TypeProvider, TypeResolutionContext, +}; +use naviscope_plugin::{CodeGraph, Direction}; +use std::collections::HashMap; +use std::path::Path; + +#[test] +fn resolve_type_name_prefers_explicit_import() { + let files = vec![ + ("src/a/Foo.java", "package a; public class Foo {}"), + ("src/b/Foo.java", "package b; public class Foo {}"), + ( + "src/t/Use.java", + "package t; import a.Foo; import b.*; class Use { Foo foo; }", + ), + ]; + let (graph, _) = setup_java_test_graph(files); + let ts = CodeGraphTypeSystem::new(&graph); + let ctx = TypeResolutionContext { + package: Some("t".to_string()), + imports: vec!["a.Foo".to_string(), "b.*".to_string()], + ..TypeResolutionContext::default() + }; + + assert_eq!(ts.resolve_type_name("Foo", &ctx), Some("a.Foo".to_string())); +} + +#[test] +fn resolve_type_name_prefers_same_package_before_wildcard_import() { + let files = vec![ + ("src/t/Foo.java", "package t; public class Foo {}"), + ("src/b/Foo.java", "package b; public class Foo {}"), + ( + "src/t/Use.java", + "package t; import b.*; class Use { Foo foo; }", + ), + ]; + let (graph, _) = setup_java_test_graph(files); + let ts = CodeGraphTypeSystem::new(&graph); + let ctx = TypeResolutionContext { + package: Some("t".to_string()), + imports: vec!["b.*".to_string()], + ..TypeResolutionContext::default() + }; + + assert_eq!(ts.resolve_type_name("Foo", &ctx), Some("t.Foo".to_string())); +} + +#[test] +fn walk_ancestors_and_descendants_respect_max_depth() { + let files = vec![( + "src/p/Chain.java", + r#" +package p; +class C0 {} +class C1 extends C0 {} +class C2 extends C1 {} +class C3 extends C2 {} +class C4 extends C3 {} +class C5 extends C4 {} +class C6 extends C5 {} +class C7 extends C6 {} +class C8 extends C7 {} +class C9 extends C8 {} +class C10 extends C9 {} +class C11 extends C10 {} +"#, + )]; + + let (graph, _) = setup_java_test_graph(files); + let ts = CodeGraphTypeSystem::new(&graph); + + let ancestors: Vec<_> = ts.walk_ancestors("p.C11").collect(); + assert_eq!(ancestors.len(), 10); + assert!(ancestors.contains(&"p.C1".to_string())); + assert!(!ancestors.contains(&"p.C0".to_string())); + + let descendants: Vec<_> = ts.walk_descendants("p.C0").collect(); + assert_eq!(descendants.len(), 10); + assert!(descendants.contains(&"p.C10".to_string())); + assert!(!descendants.contains(&"p.C11".to_string())); +} + +#[test] +fn type_info_and_members_preserve_modifiers() { + let files = vec![( + "src/p/User.java", + "package p; public class User { public static final String NAME = \"x\"; public void ping() {} }", + )]; + + let (graph, _) = setup_java_test_graph(files); + let ts = CodeGraphTypeSystem::new(&graph); + + let info = ts.get_type_info("p.User").expect("type info"); + assert!(info.modifiers.iter().any(|m| m == "public")); + + let methods = ts.get_members("p.User", "ping"); + assert!(!methods.is_empty()); + assert!( + methods + .iter() + .any(|m| m.modifiers.iter().any(|modi| modi == "public")) + ); +} + +#[test] +fn method_parameters_keep_original_names_from_graph_metadata() { + let files = vec![( + "src/p/User.java", + "package p; public class User { public void greet(String name, int age) {} }", + )]; + + let (graph, _) = setup_java_test_graph(files); + let ts = CodeGraphTypeSystem::new(&graph); + + let method = ts + .get_members("p.User", "greet") + .into_iter() + .next() + .expect("greet member"); + let params = method.parameters.expect("method params"); + assert_eq!(params.len(), 2); + assert_eq!(params[0].name, "name"); + assert_eq!(params[1].name, "age"); +} + +struct FakeGraph { + fqns: HashMap>, + nodes: HashMap, + neighbors: HashMap<(FqnId, u8, Option), Vec>, + atoms: HashMap, +} + +impl FakeGraph { + fn new() -> Self { + Self { + fqns: HashMap::new(), + nodes: HashMap::new(), + neighbors: HashMap::new(), + atoms: HashMap::new(), + } + } +} + +impl FqnReader for FakeGraph { + fn resolve_node(&self, id: FqnId) -> Option { + self.nodes.get(&id).cloned() + } + + fn resolve_atom(&self, atom: Symbol) -> &str { + self.atoms + .get(&(atom.0.into_usize() as u32)) + .map(String::as_str) + .unwrap_or("") + } +} + +impl CodeGraph for FakeGraph { + fn resolve_fqn(&self, fqn: &str) -> Vec { + self.fqns.get(fqn).cloned().unwrap_or_default() + } + + fn get_node_at(&self, _path: &Path, _line: usize, _col: usize) -> Option { + None + } + + fn resolve_atom(&self, atom: Symbol) -> &str { + ::resolve_atom(self, atom) + } + + fn fqns(&self) -> &dyn FqnReader { + self + } + + fn get_node(&self, _id: FqnId) -> Option { + None + } + + fn get_neighbors( + &self, + id: FqnId, + direction: Direction, + edge_type: Option, + ) -> Vec { + self.neighbors + .get(&(id, dir_tag(direction), edge_type)) + .cloned() + .unwrap_or_default() + } +} + +fn atom(id: u32) -> Symbol { + Symbol(lasso::Spur::try_from_usize(id as usize).expect("spur")) +} + +fn dir_tag(direction: Direction) -> u8 { + match direction { + Direction::Incoming => 0, + Direction::Outgoing => 1, + } +} + +fn fake_graph_for_dedup() -> FakeGraph { + let mut g = FakeGraph::new(); + + // atoms + g.atoms.insert(1, "p".to_string()); + g.atoms.insert(2, "A".to_string()); + g.atoms.insert(3, "B".to_string()); + g.atoms.insert(4, "I".to_string()); + + // FQN tree: + // p (100) + // ├─ A (101) and duplicate A (102) + // ├─ B (103) + // └─ I (104) and duplicate I (105) + g.nodes.insert( + FqnId(100), + FqnNode { + parent: None, + name: atom(1), + kind: NodeKind::Package, + }, + ); + g.nodes.insert( + FqnId(101), + FqnNode { + parent: Some(FqnId(100)), + name: atom(2), + kind: NodeKind::Class, + }, + ); + g.nodes.insert( + FqnId(102), + FqnNode { + parent: Some(FqnId(100)), + name: atom(2), + kind: NodeKind::Class, + }, + ); + g.nodes.insert( + FqnId(103), + FqnNode { + parent: Some(FqnId(100)), + name: atom(3), + kind: NodeKind::Class, + }, + ); + g.nodes.insert( + FqnId(104), + FqnNode { + parent: Some(FqnId(100)), + name: atom(4), + kind: NodeKind::Interface, + }, + ); + g.nodes.insert( + FqnId(105), + FqnNode { + parent: Some(FqnId(100)), + name: atom(4), + kind: NodeKind::Interface, + }, + ); + + g.fqns.insert("p.A".to_string(), vec![FqnId(101), FqnId(102)]); + g.fqns.insert("p.I".to_string(), vec![FqnId(104), FqnId(105)]); + + // get_interfaces("p.A"): duplicates from duplicate node ids and repeated neighbors + g.neighbors.insert( + (FqnId(101), dir_tag(Direction::Outgoing), Some(EdgeType::Implements)), + vec![FqnId(104), FqnId(104)], + ); + g.neighbors.insert( + (FqnId(102), dir_tag(Direction::Outgoing), Some(EdgeType::Implements)), + vec![FqnId(104), FqnId(105)], + ); + + // get_direct_subtypes("p.I"): duplicates across InheritsFrom/Implements and duplicate I ids + g.neighbors.insert( + ( + FqnId(104), + dir_tag(Direction::Incoming), + Some(EdgeType::InheritsFrom), + ), + vec![FqnId(101)], + ); + g.neighbors.insert( + ( + FqnId(104), + dir_tag(Direction::Incoming), + Some(EdgeType::Implements), + ), + vec![FqnId(101), FqnId(103), FqnId(101)], + ); + g.neighbors.insert( + ( + FqnId(105), + dir_tag(Direction::Incoming), + Some(EdgeType::InheritsFrom), + ), + vec![FqnId(103)], + ); + g.neighbors.insert( + ( + FqnId(105), + dir_tag(Direction::Incoming), + Some(EdgeType::Implements), + ), + vec![FqnId(103)], + ); + + g +} + +#[test] +fn get_interfaces_returns_unique_fqns() { + let graph = fake_graph_for_dedup(); + let ts = CodeGraphTypeSystem::new(&graph); + + assert_eq!(ts.get_interfaces("p.A"), vec!["p.I".to_string()]); +} + +#[test] +fn get_direct_subtypes_returns_unique_fqns() { + let graph = fake_graph_for_dedup(); + let ts = CodeGraphTypeSystem::new(&graph); + + assert_eq!( + ts.get_direct_subtypes("p.I"), + vec!["p.A".to_string(), "p.B".to_string()] + ); +} diff --git a/crates/lang-java/tests/java_integration.rs b/crates/lang-java/tests/java_integration.rs index 50ef612..df9ee0c 100644 --- a/crates/lang-java/tests/java_integration.rs +++ b/crates/lang-java/tests/java_integration.rs @@ -266,7 +266,6 @@ fn test_lambda_explicit_type_resolution() { } #[test] -#[ignore = "Requires generics inference to propagate type parameters from List to lambda"] fn test_lambda_heuristic_type_inference() { let files = vec![ ( From eea10b2165a8755d5d2f3bbd9e76b260e0600143 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Thu, 12 Feb 2026 20:08:29 +0800 Subject: [PATCH 30/49] chore: upgrade deps and migrate to lsp-types Uri --- Cargo.lock | 201 ++++++++-------------- Cargo.toml | 10 +- crates/core/src/facade/semantic.rs | 19 +- crates/core/src/features/discovery.rs | 4 +- crates/lang-java/tests/logic_hierarchy.rs | 4 +- 5 files changed, 91 insertions(+), 147 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c21274b..544bc52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -148,14 +148,14 @@ checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "axum" -version = "0.7.9" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ - "async-trait", "axum-core", "base64", "bytes", + "form_urlencoded", "futures-util", "http", "http-body", @@ -168,15 +168,14 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rustversion", - "serde", + "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", "sha1", "sync_wrapper", "tokio", - "tokio-tungstenite 0.24.0", + "tokio-tungstenite", "tower 0.5.3", "tower-layer", "tower-service", @@ -185,19 +184,17 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.4.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ - "async-trait", "bytes", - "futures-util", + "futures-core", "http", "http-body", "http-body-util", "mime", "pin-project-lite", - "rustversion", "sync_wrapper", "tower-layer", "tower-service", @@ -397,9 +394,9 @@ dependencies = [ [[package]] name = "crc" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" dependencies = [ "crc-catalog", ] @@ -714,6 +711,15 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "fluent-uri" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1348,11 +1354,24 @@ dependencies = [ "url", ] +[[package]] +name = "lsp-types" +version = "0.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" +dependencies = [ + "bitflags 1.3.2", + "fluent-uri", + "serde", + "serde_json", + "serde_repr", +] + [[package]] name = "lzma-rust2" -version = "0.15.7" +version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1670343e58806300d87950e3401e820b519b9384281bbabfb15e3636689ffd69" +checksum = "96d43a6fec3e2f1176fd435ff6f0e337dab57361918f0f51bbc75995151e2ca0" dependencies = [ "crc", "sha2", @@ -1369,15 +1388,15 @@ dependencies = [ [[package]] name = "matchit" -version = "0.7.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memmap2" @@ -1434,7 +1453,7 @@ dependencies = [ "schemars", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror", ] [[package]] @@ -1471,7 +1490,7 @@ dependencies = [ "ignore", "lasso", "log", - "lsp-types", + "lsp-types 0.97.0", "naviscope-api", "naviscope-java", "naviscope-plugin", @@ -1486,7 +1505,7 @@ dependencies = [ "serde_bytes", "serde_json", "tempfile", - "thiserror 2.0.18", + "thiserror", "tokio", "tokio-util", "tracing", @@ -1506,7 +1525,7 @@ version = "0.5.5" dependencies = [ "dirs", "lasso", - "lsp-types", + "lsp-types 0.97.0", "naviscope-api", "naviscope-core", "naviscope-plugin", @@ -1516,7 +1535,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror 2.0.18", + "thiserror", "tree-sitter", "tree-sitter-groovy", "walkdir", @@ -1528,7 +1547,7 @@ version = "0.5.5" dependencies = [ "dirs", "lasso", - "lsp-types", + "lsp-types 0.97.0", "naviscope-api", "naviscope-core", "naviscope-plugin", @@ -1540,7 +1559,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror 2.0.18", + "thiserror", "tokio", "tree-sitter", "tree-sitter-java", @@ -1577,7 +1596,7 @@ dependencies = [ "serde", "serde_json", "tokio", - "tokio-tungstenite 0.28.0", + "tokio-tungstenite", "tokio-util", "tower-lsp", "tracing", @@ -1589,12 +1608,12 @@ name = "naviscope-plugin" version = "0.5.5" dependencies = [ "async-trait", - "lsp-types", + "lsp-types 0.97.0", "naviscope-api", "serde", "serde_bytes", "serde_json", - "thiserror 2.0.18", + "thiserror", "tree-sitter", ] @@ -1861,35 +1880,14 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", + "rand_chacha", + "rand_core", ] [[package]] @@ -1899,16 +1897,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", + "rand_core", ] [[package]] @@ -1957,7 +1946,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror", ] [[package]] @@ -1975,7 +1964,7 @@ dependencies = [ "strip-ansi-escapes", "strum", "strum_macros", - "thiserror 2.0.18", + "thiserror", "unicase", "unicode-segmentation", "unicode-width", @@ -2032,34 +2021,34 @@ checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" [[package]] name = "ristretto_classfile" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eda62b37a0ea624b0217f5dc6cd8abcf2a580dda5834b74b47aec276f91559ba" +checksum = "ce028b4c8fd5d7d8363ad78154324eb31576ac464a2f8c77910066573510c7c3" dependencies = [ "ahash", "bitflags 2.10.0", "byteorder", "indexmap", - "thiserror 2.0.18", + "thiserror", ] [[package]] name = "ristretto_jimage" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5635cd85cac414cf04d9f66b7138203aef545e06c84b9a5848e962de9f70787f" +checksum = "e049dd1c0f2ffafb0ac0a7f82558d59fb0690ac388c3f834558ed0fd416e59cd" dependencies = [ "byteorder", "memchr", "memmap2", - "thiserror 2.0.18", + "thiserror", ] [[package]] name = "rmcp" -version = "0.13.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1815dbc06c414d720f8bc1951eccd66bc99efc6376331f1e7093a119b3eb508" +checksum = "1bef41ebc9ebed2c1b1d90203e9d1756091e8a00bbc3107676151f39868ca0ee" dependencies = [ "async-trait", "base64", @@ -2071,7 +2060,7 @@ dependencies = [ "schemars", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror", "tokio", "tokio-util", "tracing", @@ -2079,9 +2068,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "0.13.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f0bc7008fa102e771a76c6d2c9b253be3f2baa5964e060464d038ae1cbc573" +checksum = "0e88ad84b8b6237a934534a62b379a5be6388915663c0cc598ceb9b3292bbbfe" dependencies = [ "darling", "proc-macro2", @@ -2511,33 +2500,13 @@ dependencies = [ "unicode-width", ] -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "thiserror-impl", ] [[package]] @@ -2629,18 +2598,6 @@ dependencies = [ "syn", ] -[[package]] -name = "tokio-tungstenite" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" -dependencies = [ - "futures-util", - "log", - "tokio", - "tungstenite 0.24.0", -] - [[package]] name = "tokio-tungstenite" version = "0.28.0" @@ -2650,7 +2607,7 @@ dependencies = [ "futures-util", "log", "tokio", - "tungstenite 0.28.0", + "tungstenite", ] [[package]] @@ -2714,7 +2671,7 @@ dependencies = [ "dashmap 5.5.3", "futures", "httparse", - "lsp-types", + "lsp-types 0.94.1", "memchr", "serde", "serde_json", @@ -2761,7 +2718,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" dependencies = [ "crossbeam-channel", - "thiserror 2.0.18", + "thiserror", "time", "tracing-subscriber", ] @@ -2856,24 +2813,6 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" -[[package]] -name = "tungstenite" -version = "0.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" -dependencies = [ - "byteorder", - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.8.5", - "sha1", - "thiserror 1.0.69", - "utf-8", -] - [[package]] name = "tungstenite" version = "0.28.0" @@ -2885,9 +2824,9 @@ dependencies = [ "http", "httparse", "log", - "rand 0.9.2", + "rand", "sha1", - "thiserror 2.0.18", + "thiserror", "utf-8", ] diff --git a/Cargo.toml b/Cargo.toml index 59e28bd..920c111 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,7 +42,7 @@ tracing-appender = "0.2.4" rmp-serde = "1.3.1" once_cell = "1.20" tower-lsp = "0.20" -lsp-types = "0.94" +lsp-types = "0.97" cc = "1.2" dashmap = "6.1.0" tokio-util = "0.7.18" @@ -54,10 +54,10 @@ nu-ansi-term = "0.50.3" shlex = "1.3.0" tabled = "0.20.0" indexmap = { version = "2.1.0", features = ["serde"] } -axum = { version = "0.7.5", features = ["ws"] } +axum = { version = "0.8.8", features = ["ws"] } futures = "0.3.31" tokio-tungstenite = "0.28.0" -rmcp = { version = "0.13.0", features = ["macros", "server", "transport-io"] } +rmcp = { version = "0.15.0", features = ["macros", "server", "transport-io"] } lasso = { version = "0.7", features = ["serialize", "multi-threaded"] } zstd = "0.13" async-trait = "0.1" @@ -67,5 +67,5 @@ tree-sitter-groovy = "0.1.2" mimalloc = "0.1" tempfile = "3.10" zip = "7.3.0" -ristretto_jimage = "0.28.0" -ristretto_classfile = "0.28.0" +ristretto_jimage = "0.29.0" +ristretto_classfile = "0.29.0" diff --git a/crates/core/src/facade/semantic.rs b/crates/core/src/facade/semantic.rs index 6f453b4..f082d2e 100644 --- a/crates/core/src/facade/semantic.rs +++ b/crates/core/src/facade/semantic.rs @@ -248,7 +248,7 @@ impl ReferenceAnalyzer for EngineHandle { let discovery = DiscoveryEngine::new(graph_snap.as_ref(), conventions_clone); let uri_str = format!("file://{}", path.display()); - let uri = match url::Url::parse(&uri_str) { + let uri: lsp_types::Uri = match uri_str.parse() { Ok(u) => u, Err(_) => return Vec::new(), }; @@ -257,9 +257,11 @@ impl ReferenceAnalyzer for EngineHandle { locations .into_iter() - .map(|loc| { - let path_buf = loc.uri.to_file_path().unwrap(); - SymbolLocation { + .filter_map(|loc| { + let path_buf = url::Url::parse(&loc.uri.to_string()) + .ok() + .and_then(|u| u.to_file_path().ok())?; + Some(SymbolLocation { path: Arc::from(path_buf), range: Range { start_line: loc.range.start.line as usize, @@ -268,7 +270,7 @@ impl ReferenceAnalyzer for EngineHandle { end_col: loc.range.end.character as usize, }, selection_range: None, - } + }) }) .collect::>() }); @@ -379,7 +381,7 @@ impl CallHierarchyAnalyzer for EngineHandle { let discovery = DiscoveryEngine::new(graph_snap.as_ref(), conventions_clone); let uri_str = format!("file://{}", path.display()); - let uri = match url::Url::parse(&uri_str) { + let uri: lsp_types::Uri = match uri_str.parse() { Ok(u) => u, Err(_) => return vec![], }; @@ -400,7 +402,10 @@ impl CallHierarchyAnalyzer for EngineHandle { let mut caller_map: HashMap> = HashMap::new(); for loc in all_call_sites { - if let Ok(path) = loc.uri.to_file_path() { + if let Some(path) = url::Url::parse(&loc.uri.to_string()) + .ok() + .and_then(|u| u.to_file_path().ok()) + { if let Some(caller_idx) = graph.find_container_node_at( &path, loc.range.start.line as usize, diff --git a/crates/core/src/features/discovery.rs b/crates/core/src/features/discovery.rs index cb0b51e..28e5cff 100644 --- a/crates/core/src/features/discovery.rs +++ b/crates/core/src/features/discovery.rs @@ -1,5 +1,5 @@ use super::CodeGraphLike; -use lsp_types::{Location, Url}; +use lsp_types::{Location, Uri}; pub use naviscope_api::models::SymbolResolution; use naviscope_plugin::SemanticCap; use std::collections::HashSet; @@ -151,7 +151,7 @@ impl<'a> DiscoveryEngine<'a> { semantic: &dyn SemanticCap, source: &str, target_resolution: &SymbolResolution, - uri: &Url, + uri: &Uri, ) -> Vec { if let Some(tree) = semantic.parse(source, None) { // 1. Syntactic Scan (Fast) diff --git a/crates/lang-java/tests/logic_hierarchy.rs b/crates/lang-java/tests/logic_hierarchy.rs index 1bdb415..4598026 100644 --- a/crates/lang-java/tests/logic_hierarchy.rs +++ b/crates/lang-java/tests/logic_hierarchy.rs @@ -39,7 +39,7 @@ fn test_call_hierarchy_incoming() { let mut callers = Vec::new(); let abs_path = std::env::current_dir().unwrap().join("Test.java"); - let uri = lsp_types::Url::from_file_path(&abs_path).unwrap(); + let uri: lsp_types::Uri = format!("file://{}", abs_path.display()).parse().unwrap(); let semantic = JavaPlugin::new().expect("failed to create java plugin"); for path in candidate_files { @@ -171,7 +171,7 @@ fn test_call_hierarchy_recursion() { let discovery = DiscoveryEngine::new(&index, std::collections::HashMap::new()); let mut callers = Vec::new(); let abs_path = std::env::current_dir().unwrap().join("Test.java"); - let uri = lsp_types::Url::from_file_path(&abs_path).unwrap(); + let uri: lsp_types::Uri = format!("file://{}", abs_path.display()).parse().unwrap(); let semantic = JavaPlugin::new().expect("failed to create java plugin"); let locations = discovery.scan_file(&semantic, content, &res, &uri); From 6790824bdf1b07a75eafaa1218399dcc59d56a4c Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Thu, 12 Feb 2026 22:56:34 +0800 Subject: [PATCH 31/49] Improve external symbol hover and query-time stub hydration --- crates/core/src/facade/semantic.rs | 53 ++++++++++++++ crates/core/src/runtime/orchestrator.rs | 20 ++++++ crates/lang-java/src/cap/presentation.rs | 34 ++++++++- crates/lsp/src/hover.rs | 90 +++++++++++++++++++++--- 4 files changed, 186 insertions(+), 11 deletions(-) diff --git a/crates/core/src/facade/semantic.rs b/crates/core/src/facade/semantic.rs index f082d2e..d25d912 100644 --- a/crates/core/src/facade/semantic.rs +++ b/crates/core/src/facade/semantic.rs @@ -17,6 +17,46 @@ use std::collections::{HashMap, HashSet}; use std::fs; use std::path::PathBuf; use std::sync::Arc; +use tokio::time::{Duration, sleep}; + +impl EngineHandle { + async fn hydrate_symbol_if_missing(&self, fqn: &str) -> ApiResult<()> { + if self + .get_node_display(fqn) + .await + .map_err(|e| ApiError::Internal(e.to_string()))? + .is_some() + { + return Ok(()); + } + + if !self.engine.request_stub_for_fqn(fqn) { + let _ = self.engine.scan_global_assets().await; + let _ = self.engine.request_stub_for_fqn(fqn); + } + + for _ in 0..3 { + sleep(Duration::from_millis(25)).await; + if self + .get_node_display(fqn) + .await + .map_err(|e| ApiError::Internal(e.to_string()))? + .is_some() + { + break; + } + } + + Ok(()) + } + + async fn hydrate_resolution_if_needed(&self, resolution: &SymbolResolution) -> ApiResult<()> { + if let Some(fqn) = resolution.fqn() { + self.hydrate_symbol_if_missing(fqn).await?; + } + Ok(()) + } +} #[async_trait] impl SymbolNavigator for EngineHandle { @@ -96,6 +136,8 @@ impl SymbolNavigator for EngineHandle { } async fn find_definitions(&self, query: &SymbolQuery) -> ApiResult> { + self.hydrate_resolution_if_needed(&query.resolution).await?; + let resolver = match self.get_semantic_resolver(query.language.clone()) { Some(r) => r, None => { @@ -129,6 +171,8 @@ impl SymbolNavigator for EngineHandle { } async fn find_type_definitions(&self, query: &SymbolQuery) -> ApiResult> { + self.hydrate_resolution_if_needed(&query.resolution).await?; + let resolver = match self.get_semantic_resolver(query.language.clone()) { Some(r) => r, None => { @@ -164,6 +208,8 @@ impl SymbolNavigator for EngineHandle { } async fn find_implementations(&self, query: &SymbolQuery) -> ApiResult> { + self.hydrate_resolution_if_needed(&query.resolution).await?; + let resolver = match self.get_semantic_resolver(query.language.clone()) { Some(r) => r, None => { @@ -199,6 +245,8 @@ impl SymbolNavigator for EngineHandle { #[async_trait] impl ReferenceAnalyzer for EngineHandle { async fn find_references(&self, query: &ReferenceQuery) -> ApiResult> { + self.hydrate_resolution_if_needed(&query.resolution).await?; + let resolver = match self.get_semantic_resolver(query.language.clone()) { Some(r) => r, None => { @@ -321,6 +369,8 @@ impl ReferenceAnalyzer for EngineHandle { #[async_trait] impl CallHierarchyAnalyzer for EngineHandle { async fn find_incoming_calls(&self, fqn: &str) -> ApiResult> { + self.hydrate_symbol_if_missing(fqn).await?; + let graph = self.graph().await; let mut target_indices = graph.find_matches_by_fqn(fqn); @@ -454,6 +504,8 @@ impl CallHierarchyAnalyzer for EngineHandle { } async fn find_outgoing_calls(&self, fqn: &str) -> ApiResult> { + self.hydrate_symbol_if_missing(fqn).await?; + let graph = self.graph().await; let conventions = (*self.naming_conventions()).clone(); let node_idx = match graph.find_node(fqn) { @@ -565,6 +617,7 @@ impl CallHierarchyAnalyzer for EngineHandle { #[async_trait] impl SymbolInfoProvider for EngineHandle { async fn get_symbol_info(&self, fqn: &str) -> ApiResult> { + self.hydrate_symbol_if_missing(fqn).await?; self.get_node_display(fqn) .await .map_err(|e| ApiError::Internal(e.to_string())) diff --git a/crates/core/src/runtime/orchestrator.rs b/crates/core/src/runtime/orchestrator.rs index 94b5994..756b857 100644 --- a/crates/core/src/runtime/orchestrator.rs +++ b/crates/core/src/runtime/orchestrator.rs @@ -216,6 +216,26 @@ impl NaviscopeEngine { self.asset_service.as_ref() } + /// Request on-demand stub generation for a single FQN. + /// Returns true if a request was successfully enqueued. + pub fn request_stub_for_fqn(&self, fqn: &str) -> bool { + let Some(service) = &self.asset_service else { + return false; + }; + let Some(candidate_paths) = service.lookup_paths(fqn) else { + return false; + }; + if candidate_paths.is_empty() { + return false; + } + self.stub_tx + .send(StubRequest { + fqn: fqn.to_string(), + candidate_paths, + }) + .is_ok() + } + /// Run the global asset scan and populate routes /// Returns the scan result with statistics pub async fn scan_global_assets(&self) -> Option { diff --git a/crates/lang-java/src/cap/presentation.rs b/crates/lang-java/src/cap/presentation.rs index de9c4f0..58c48b8 100644 --- a/crates/lang-java/src/cap/presentation.rs +++ b/crates/lang-java/src/cap/presentation.rs @@ -28,9 +28,12 @@ impl NodePresenter for JavaPlugin { }; let fqn = display.id.as_str(); - let parts: Vec<&str> = fqn.split('.').collect(); - if parts.len() > 1 { - let container = parts[..parts.len() - 1].join("."); + let container = if let Some((owner, _member)) = fqn.split_once('#') { + Some(owner.to_string()) + } else { + fqn.rsplit_once('.').map(|(owner, _)| owner.to_string()) + }; + if let Some(container) = container { display.detail = Some(format!("*Defined in `{}`*", container)); } @@ -354,4 +357,29 @@ mod tests { assert_eq!(display.signature.as_deref(), Some("setNames(arg0: String) -> void")); assert!(display.modifiers.is_empty()); } + + #[test] + fn render_display_node_member_detail_uses_member_owner() { + let plugin = JavaPlugin::new().expect("plugin"); + let fqns = fake_fqns(); + let metadata = JavaNodeMetadata::Method { + modifiers_sids: vec![], + return_type: TypeRef::Raw("void".to_string()), + parameters: vec![], + is_constructor: false, + }; + + let node = GraphNode { + id: FqnId(3), + name: sym(3), + kind: NodeKind::Method, + lang: sym(4), + metadata: Arc::new(metadata), + ..GraphNode::default() + }; + + let display = plugin.render_display_node(&node, &fqns); + assert_eq!(display.id, "com.User#setNames"); + assert_eq!(display.detail.as_deref(), Some("*Defined in `com.User`*")); + } } diff --git a/crates/lsp/src/hover.rs b/crates/lsp/src/hover.rs index 4d1ffda..7086a69 100644 --- a/crates/lsp/src/hover.rs +++ b/crates/lsp/src/hover.rs @@ -3,6 +3,31 @@ use naviscope_api::models::PositionContext; use tower_lsp::jsonrpc::Result; use tower_lsp::lsp_types::*; +fn format_fallback_hover( + fqn: &str, + intent: Option, +) -> String { + let mut text = String::new(); + let label = match intent { + Some(naviscope_api::models::SymbolIntent::Type) => "Type", + Some(naviscope_api::models::SymbolIntent::Method) => "Method", + Some(naviscope_api::models::SymbolIntent::Field) => "Field", + Some(naviscope_api::models::SymbolIntent::Variable) => "Variable", + _ => "Symbol", + }; + text.push_str(&format!("**{}**\n\n", label)); + + if let Some((owner, _member)) = fqn.split_once('#') { + text.push_str(&format!("Declared in `{}`\n\n", owner)); + } else if let Some((owner, _name)) = fqn.rsplit_once('.') { + text.push_str(&format!("Defined in `{}`\n\n", owner)); + } + + text.push_str("*Metadata unavailable (symbol may not be indexed yet)*\n\n"); + text.push_str(&format!("*`{}`*", fqn)); + text +} + 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; @@ -50,10 +75,15 @@ pub async fn hover(server: &LspServer, params: HoverParams) -> Result { + naviscope_api::models::SymbolResolution::Precise(fqn, intent) => { // Fetch detailed info for FQN if let Ok(Some(info)) = engine.get_symbol_info(&fqn).await { + let detail = info.detail; + let container_line = detail.or_else(|| { + fqn.split_once('#') + .map(|(owner, _member)| format!("Declared in `{}`", owner)) + }); + if let Some(sig) = info.signature { let lang_tag = info.lang; hover_text.push_str(&format!("```{}\n{}\n```\n", lang_tag, sig)); @@ -65,19 +95,63 @@ pub async fn hover(server: &LspServer, params: HoverParams) -> Result { + hover_text.push_str("*Source: external*\n\n"); + } + naviscope_api::models::NodeSource::Builtin => { + hover_text.push_str("*Source: builtin*\n\n"); + } + naviscope_api::models::NodeSource::Project => {} + } + + hover_text.push_str(&format!("*`{}`*", fqn)); + } else { + hover_text.push_str(&format_fallback_hover(&fqn, Some(intent))); + } + } + naviscope_api::models::SymbolResolution::Global(fqn) => { + if let Ok(Some(info)) = engine.get_symbol_info(&fqn).await { + let detail = info.detail; + let container_line = detail.or_else(|| { + fqn.split_once('#') + .map(|(owner, _member)| format!("Declared in `{}`", owner)) + }); + + if let Some(sig) = info.signature { + let lang_tag = info.lang; + hover_text.push_str(&format!("```{}\n{}\n```\n", lang_tag, sig)); + } else { + hover_text.push_str(&format!( + "**{}** *{}*\n\n", + info.name, + info.kind.to_string() + )); + } + + if let Some(container_line) = container_line { + hover_text.push_str(&container_line); hover_text.push_str("\n\n"); } + match info.source { + naviscope_api::models::NodeSource::External => { + hover_text.push_str("*Source: external*\n\n"); + } + naviscope_api::models::NodeSource::Builtin => { + hover_text.push_str("*Source: builtin*\n\n"); + } + naviscope_api::models::NodeSource::Project => {} + } + hover_text.push_str(&format!("*`{}`*", fqn)); } else { - // Fallback to FQN only - hover_text.push_str(&format!("**Symbol**\n\n*`{}`*", fqn)); + hover_text.push_str(&format_fallback_hover(&fqn, None)); } } } From 40283bd97ecc6838c2833692c7453d39c828d9c8 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Thu, 12 Feb 2026 23:23:02 +0800 Subject: [PATCH 32/49] references: handle static hiding and add precision regressions --- crates/lang-java/src/lsp/references.rs | 58 ++++- crates/lang-java/tests/test_engine_facade.rs | 232 +++++++++++++++++-- 2 files changed, 266 insertions(+), 24 deletions(-) diff --git a/crates/lang-java/src/lsp/references.rs b/crates/lang-java/src/lsp/references.rs index 0346b73..cf8b5ea 100644 --- a/crates/lang-java/src/lsp/references.rs +++ b/crates/lang-java/src/lsp/references.rs @@ -106,8 +106,10 @@ fn collect_occurrences_with_ctx( } if member_target { - return resolve_member_reference_fqn(node, infer_ctx) - .map(|resolved| member_fqn_matches_target(&resolved, fqn, infer_ctx)) + return resolve_member_reference(node, infer_ctx) + .map(|(resolved, static_site)| { + member_fqn_matches_target(&resolved, fqn, static_site, infer_ctx) + }) .unwrap_or(false); } @@ -198,7 +200,7 @@ fn find_start_scope_id(node: &Node, sm: &ScopeManager) -> Option { None } -fn resolve_member_reference_fqn(node: &Node, infer_ctx: &InferContext) -> Option { +fn resolve_member_reference(node: &Node, infer_ctx: &InferContext) -> Option<(String, bool)> { if node.kind() != "identifier" { return None; } @@ -209,25 +211,28 @@ fn resolve_member_reference_fqn(node: &Node, infer_ctx: &InferContext) -> Option if let Some(resolved) = crate::inference::strategy::MethodCallInfer.infer_member(&parent, infer_ctx) { - return Some(resolved); + let static_site = is_static_member_access_site(&parent, infer_ctx); + return Some((resolved, static_site)); } // Fallback for implicit `this` calls when enclosing_class is not pre-filled in ctx. if parent.child_by_field_name("object").is_none() { let member_name = node.utf8_text(infer_ctx.source.as_bytes()).ok()?; let class_fqn = find_enclosing_class_fqn(node, infer_ctx)?; - return Some(crate::naming::build_member_fqn(&class_fqn, member_name)); + return Some((crate::naming::build_member_fqn(&class_fqn, member_name), false)); } return None; } if parent.kind() == "field_access" && parent.child_by_field_name("field") == Some(*node) { - return crate::inference::strategy::FieldAccessInfer.infer_member(&parent, infer_ctx); + return crate::inference::strategy::FieldAccessInfer + .infer_member(&parent, infer_ctx) + .map(|fqn| (fqn, is_static_member_access_site(&parent, infer_ctx))); } } - resolve_member_declaration_fqn(node, infer_ctx) + resolve_member_declaration_fqn(node, infer_ctx).map(|fqn| (fqn, false)) } fn resolve_member_declaration_fqn(node: &Node, infer_ctx: &InferContext) -> Option { @@ -259,7 +264,12 @@ fn find_enclosing_class_fqn(node: &Node, infer_ctx: &InferContext) -> Option bool { +fn member_fqn_matches_target( + resolved: &str, + target: &str, + static_site: bool, + infer_ctx: &InferContext, +) -> bool { if resolved == target { return true; } @@ -274,6 +284,11 @@ fn member_fqn_matches_target(resolved: &str, target: &str, infer_ctx: &InferCont return false; } + // Static member hiding is name-based and not polymorphic: require exact owner match. + if static_site { + return resolved_owner == target_owner; + } + let resolved_ty = TypeRef::Id(resolved_owner.to_string()); let target_ty = TypeRef::Id(target_owner.to_string()); @@ -285,3 +300,30 @@ fn split_member_fqn(fqn: &str) -> Option<(&str, &str)> { let (owner, member) = fqn.split_once('#')?; Some((owner, member)) } + +fn is_static_member_access_site(access_parent: &Node, infer_ctx: &InferContext) -> bool { + let Some(object) = access_parent.child_by_field_name("object") else { + return false; + }; + + match object.kind() { + "type_identifier" | "scoped_type_identifier" | "generic_type" => true, + "this" | "super" => false, + "identifier" => { + let Ok(name) = object.utf8_text(infer_ctx.source.as_bytes()) else { + return false; + }; + + let Some(sm) = infer_ctx.scope_manager else { + return false; + }; + let Some(scope_id) = find_start_scope_id(&object, sm) else { + return true; + }; + + // If identifier is a local variable, this is instance access; otherwise treat as type. + sm.lookup_symbol(scope_id, name).is_none() + } + _ => false, + } +} diff --git a/crates/lang-java/tests/test_engine_facade.rs b/crates/lang-java/tests/test_engine_facade.rs index a20eb09..5283609 100644 --- a/crates/lang-java/tests/test_engine_facade.rs +++ b/crates/lang-java/tests/test_engine_facade.rs @@ -37,19 +37,14 @@ public class App { let handle = setup_java_engine(&temp_dir, files).await; let graph = handle.graph().await; - - // Demonstrate registering a convention (even if default is already Standard) - // This verifies the API is accessible and working. graph.register_naming_convention(Box::new( naviscope_plugin::StandardNamingConvention::default(), )); + graph.topology(); - graph.topology(); // Ensure graph is usable - - // 1. Resolve 'run' call in App.java (line 6 roughly) let app_path = temp_dir.join("com/example/App.java"); let app_content = std::fs::read_to_string(&app_path).unwrap(); - let run_pos = app_content.find("b.run()").unwrap() + 2; // Point to 'run' + let run_pos = app_content.find("b.run()").unwrap() + 2; let (line, col) = offset_to_point(&app_content, run_pos); let ctx = PositionContext { @@ -65,7 +60,6 @@ public class App { .unwrap() .expect("Should resolve b.run()"); - // Should resolve to com.example.Base#run match &resolution { SymbolResolution::Precise(fqn, _) => assert_eq!(fqn, "com.example.Base#run"), SymbolResolution::Global(fqn) => assert_eq!(fqn, "com.example.Base#run"), @@ -75,7 +69,6 @@ public class App { ), } - // 2. Find implementations of 'run' let query = SymbolQuery { language: naviscope_api::models::Language::JAVA, resolution: resolution.clone(), @@ -84,9 +77,6 @@ public class App { assert_eq!(impls.len(), 1); assert!(impls[0].path.to_string_lossy().contains("Impl.java")); - // 3. Find incoming calls to 'Impl#run' - // With semantic reference checks, searching for an implementation should find - // calls to the base method as well. let calls = handle .find_incoming_calls("com.example.Impl#run") .await @@ -97,11 +87,8 @@ public class App { "Lookup of Impl#run should find the call via Base type" ); - // 4. Find incoming calls to 'Base#run' - let target_fqn = "com.example.Base#run"; - let incoming_base = handle.find_incoming_calls(target_fqn).await.unwrap(); + let incoming_base = handle.find_incoming_calls("com.example.Base#run").await.unwrap(); - // Test find_references as a comparison let query_refs = ReferenceQuery { language: naviscope_api::models::Language::JAVA, resolution: resolution.clone(), @@ -121,3 +108,216 @@ public class App { ); assert_eq!(incoming_base[0].from.id, "com.example.App#start"); } + +#[tokio::test] +async fn test_find_references_filters_same_name_across_types() { + let temp_dir = std::env::temp_dir().join("naviscope_java_ref_same_name_test"); + if temp_dir.exists() { + let _ = std::fs::remove_dir_all(&temp_dir); + } + std::fs::create_dir_all(&temp_dir).unwrap(); + + let files = vec![ + ( + "com/example/A.java", + "package com.example; public class A { public void target() {} }", + ), + ( + "com/example/X.java", + "package com.example; public class X { public void target() {} }", + ), + ( + "com/example/Use.java", + r#" +package com.example; +public class Use { + void useA(A a) { a.target(); } + void useX(X x) { x.target(); } +} +"#, + ), + ]; + + let handle = setup_java_engine(&temp_dir, files).await; + + let a_path = temp_dir.join("com/example/A.java"); + let a_content = std::fs::read_to_string(&a_path).unwrap(); + let pos = a_content.find("target()").unwrap(); + let (line, col) = offset_to_point(&a_content, pos); + + let ctx = PositionContext { + uri: format!("file://{}", a_path.display()), + line: line as u32, + char: col as u32, + content: Some(a_content.clone()), + }; + + let resolution = handle + .resolve_symbol_at(&ctx) + .await + .unwrap() + .expect("Should resolve A#target"); + + let refs = handle + .find_references(&ReferenceQuery { + language: naviscope_api::models::Language::JAVA, + resolution, + include_declaration: false, + }) + .await + .unwrap(); + + assert_eq!(refs.len(), 1, "A#target should only match a.target() usage"); + assert!(refs[0].path.to_string_lossy().contains("Use.java")); + + let use_content = std::fs::read_to_string(temp_dir.join("com/example/Use.java")).unwrap(); + let prefix = use_content + .lines() + .take(refs[0].range.start_line + 1) + .collect::>() + .join("\n"); + assert!( + prefix.contains("useA"), + "Reference should belong to useA(A), not useX(X)" + ); +} + +#[tokio::test] +async fn test_find_references_include_declaration_switch() { + let temp_dir = std::env::temp_dir().join("naviscope_java_ref_include_decl_test"); + if temp_dir.exists() { + let _ = std::fs::remove_dir_all(&temp_dir); + } + std::fs::create_dir_all(&temp_dir).unwrap(); + + let files = vec![ + ( + "com/example/A.java", + "package com.example; public class A { public void target() {} }", + ), + ( + "com/example/Use.java", + r#" +package com.example; +public class Use { + void useA(A a) { a.target(); } +} +"#, + ), + ]; + + let handle = setup_java_engine(&temp_dir, files).await; + + let a_path = temp_dir.join("com/example/A.java"); + let a_content = std::fs::read_to_string(&a_path).unwrap(); + let pos = a_content.find("target()").unwrap(); + let (line, col) = offset_to_point(&a_content, pos); + + let ctx = PositionContext { + uri: format!("file://{}", a_path.display()), + line: line as u32, + char: col as u32, + content: Some(a_content.clone()), + }; + + let resolution = handle + .resolve_symbol_at(&ctx) + .await + .unwrap() + .expect("Should resolve A#target"); + + let refs_without_decl = handle + .find_references(&ReferenceQuery { + language: naviscope_api::models::Language::JAVA, + resolution: resolution.clone(), + include_declaration: false, + }) + .await + .unwrap(); + assert_eq!(refs_without_decl.len(), 1); + + let refs_with_decl = handle + .find_references(&ReferenceQuery { + language: naviscope_api::models::Language::JAVA, + resolution, + include_declaration: true, + }) + .await + .unwrap(); + assert_eq!(refs_with_decl.len(), 2); +} + +#[tokio::test] +async fn test_find_references_static_member_hiding() { + let temp_dir = std::env::temp_dir().join("naviscope_java_ref_static_hiding_test"); + if temp_dir.exists() { + let _ = std::fs::remove_dir_all(&temp_dir); + } + std::fs::create_dir_all(&temp_dir).unwrap(); + + let files = vec![ + ( + "com/example/Base.java", + "package com.example; public class Base { public static void ping() {} }", + ), + ( + "com/example/Child.java", + "package com.example; public class Child extends Base { public static void ping() {} }", + ), + ( + "com/example/Use.java", + r#" +package com.example; +public class Use { + void use() { + Base.ping(); + Child.ping(); + } +} +"#, + ), + ]; + + let handle = setup_java_engine(&temp_dir, files).await; + + let base_path = temp_dir.join("com/example/Base.java"); + let base_content = std::fs::read_to_string(&base_path).unwrap(); + let pos = base_content.find("ping()").unwrap(); + let (line, col) = offset_to_point(&base_content, pos); + + let ctx = PositionContext { + uri: format!("file://{}", base_path.display()), + line: line as u32, + char: col as u32, + content: Some(base_content.clone()), + }; + + let resolution = handle + .resolve_symbol_at(&ctx) + .await + .unwrap() + .expect("Should resolve Base#ping"); + + let refs = handle + .find_references(&ReferenceQuery { + language: naviscope_api::models::Language::JAVA, + resolution, + include_declaration: false, + }) + .await + .unwrap(); + + assert_eq!(refs.len(), 1, "Base#ping should only match Base.ping() usage"); + assert!(refs[0].path.to_string_lossy().contains("Use.java")); + + let use_content = std::fs::read_to_string(temp_dir.join("com/example/Use.java")).unwrap(); + let prefix = use_content + .lines() + .take(refs[0].range.start_line + 1) + .collect::>() + .join("\n"); + assert!( + prefix.contains("Base.ping"), + "Reference should belong to Base.ping(), not Child.ping()" + ); +} From d6e609d24e1ef881b101969dd86077ff23f67c56 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Thu, 12 Feb 2026 23:33:32 +0800 Subject: [PATCH 33/49] hover: add chain-middle integration coverage and unit tests --- crates/lang-java/tests/java_integration.rs | 50 +++++ crates/lsp/src/hover.rs | 233 +++++++++++++-------- 2 files changed, 196 insertions(+), 87 deletions(-) diff --git a/crates/lang-java/tests/java_integration.rs b/crates/lang-java/tests/java_integration.rs index df9ee0c..d77afd7 100644 --- a/crates/lang-java/tests/java_integration.rs +++ b/crates/lang-java/tests/java_integration.rs @@ -196,6 +196,56 @@ fn test_chained_calls_resolution() { } } +#[test] +fn test_hover_chain_middle_node_resolution() { + let files = vec![ + ( + "src/web/HttpResponse.java", + "package com.web; public class HttpResponse { public SessionContext getContext() { return new SessionContext(); } }", + ), + ( + "src/web/SessionContext.java", + "package com.web; public class SessionContext { public Object get(String key) { return null; } }", + ), + ( + "src/web/Main.java", + r#"package com.web; +public class Main { + void run() { + HttpResponse response = new HttpResponse(); + response.getContext().get("key"); + } +}"#, + ), + ]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let main_content = &trees[2].1; + let main_tree = &trees[2].2; + + let get_context_pos = main_content + .find("getContext()") + .expect("Could not find 'getContext()'"); + let (line, col) = offset_to_point(main_content, get_context_pos); + + let res = resolver.resolve_at(main_tree, main_content, line, col, &index); + assert!( + res.is_some(), + "Failed to resolve chain middle node 'getContext'" + ); + + if let Some(naviscope_core::ingest::parser::SymbolResolution::Precise(fqn, _)) = res { + assert_eq!(fqn, "com.web.HttpResponse#getContext"); + } else { + panic!( + "Expected precise resolution to com.web.HttpResponse#getContext, got {:?}", + res + ); + } +} + #[test] fn test_lambda_parameter_resolution() { let files = vec![( diff --git a/crates/lsp/src/hover.rs b/crates/lsp/src/hover.rs index 7086a69..c4f9732 100644 --- a/crates/lsp/src/hover.rs +++ b/crates/lsp/src/hover.rs @@ -1,5 +1,5 @@ use crate::LspServer; -use naviscope_api::models::PositionContext; +use naviscope_api::models::{DisplayGraphNode, PositionContext, SymbolResolution}; use tower_lsp::jsonrpc::Result; use tower_lsp::lsp_types::*; @@ -57,11 +57,28 @@ pub async fn hover(server: &LspServer, params: HoverParams) -> Result { + engine.get_symbol_info(fqn).await.ok().flatten() + } + SymbolResolution::Local(_, _) => None, + }; + let hover_text = build_hover_text(&resolution, info.as_ref()); + if !hover_text.is_empty() { + return Ok(Some(Hover { + contents: HoverContents::Scalar(MarkedString::String(hover_text)), + range: None, + })); + } + + Ok(None) +} + +fn build_hover_text(resolution: &SymbolResolution, info: Option<&DisplayGraphNode>) -> String { match resolution { - naviscope_api::models::SymbolResolution::Local(range, type_name) => { + SymbolResolution::Local(range, type_name) => { + let mut hover_text = String::new(); hover_text.push_str("**Local variable**"); if let Some(t) = type_name { hover_text.push_str(&format!(": `{}`", t)); @@ -74,94 +91,136 @@ pub async fn hover(server: &LspServer, params: HoverParams) -> Result { - // Fetch detailed info for FQN - if let Ok(Some(info)) = engine.get_symbol_info(&fqn).await { - let detail = info.detail; - let container_line = detail.or_else(|| { - fqn.split_once('#') - .map(|(owner, _member)| format!("Declared in `{}`", owner)) - }); - - if let Some(sig) = info.signature { - let lang_tag = info.lang; - hover_text.push_str(&format!("```{}\n{}\n```\n", lang_tag, sig)); - } else { - hover_text.push_str(&format!( - "**{}** *{}*\n\n", - info.name, - info.kind.to_string() - )); - } - - if let Some(container_line) = container_line { - hover_text.push_str(&container_line); - hover_text.push_str("\n\n"); - } - - match info.source { - naviscope_api::models::NodeSource::External => { - hover_text.push_str("*Source: external*\n\n"); - } - naviscope_api::models::NodeSource::Builtin => { - hover_text.push_str("*Source: builtin*\n\n"); - } - naviscope_api::models::NodeSource::Project => {} - } - - hover_text.push_str(&format!("*`{}`*", fqn)); - } else { - hover_text.push_str(&format_fallback_hover(&fqn, Some(intent))); - } + SymbolResolution::Precise(fqn, intent) => build_symbol_hover(fqn, Some(*intent), info), + SymbolResolution::Global(fqn) => build_symbol_hover(fqn, None, info), + } +} + +fn build_symbol_hover( + fqn: &str, + intent: Option, + info: Option<&DisplayGraphNode>, +) -> String { + let Some(info) = info else { + return format_fallback_hover(fqn, intent); + }; + + let mut hover_text = String::new(); + let container_line = info.detail.clone().or_else(|| { + fqn.split_once('#') + .map(|(owner, _member)| format!("Declared in `{}`", owner)) + }); + + if let Some(sig) = &info.signature { + let lang_tag = &info.lang; + hover_text.push_str(&format!("```{}\n{}\n```\n", lang_tag, sig)); + } else { + hover_text.push_str(&format!("**{}** *{}*\n\n", info.name, info.kind.to_string())); + } + + if let Some(container_line) = container_line { + hover_text.push_str(&container_line); + hover_text.push_str("\n\n"); + } + + match info.source { + naviscope_api::models::NodeSource::External => { + hover_text.push_str("*Source: external*\n\n"); } - naviscope_api::models::SymbolResolution::Global(fqn) => { - if let Ok(Some(info)) = engine.get_symbol_info(&fqn).await { - let detail = info.detail; - let container_line = detail.or_else(|| { - fqn.split_once('#') - .map(|(owner, _member)| format!("Declared in `{}`", owner)) - }); - - if let Some(sig) = info.signature { - let lang_tag = info.lang; - hover_text.push_str(&format!("```{}\n{}\n```\n", lang_tag, sig)); - } else { - hover_text.push_str(&format!( - "**{}** *{}*\n\n", - info.name, - info.kind.to_string() - )); - } - - if let Some(container_line) = container_line { - hover_text.push_str(&container_line); - hover_text.push_str("\n\n"); - } - - match info.source { - naviscope_api::models::NodeSource::External => { - hover_text.push_str("*Source: external*\n\n"); - } - naviscope_api::models::NodeSource::Builtin => { - hover_text.push_str("*Source: builtin*\n\n"); - } - naviscope_api::models::NodeSource::Project => {} - } - - hover_text.push_str(&format!("*`{}`*", fqn)); - } else { - hover_text.push_str(&format_fallback_hover(&fqn, None)); - } + naviscope_api::models::NodeSource::Builtin => { + hover_text.push_str("*Source: builtin*\n\n"); } + naviscope_api::models::NodeSource::Project => {} } - if !hover_text.is_empty() { - return Ok(Some(Hover { - contents: HoverContents::Scalar(MarkedString::String(hover_text)), - range: None, - })); + hover_text.push_str(&format!("*`{}`*", fqn)); + hover_text +} + +#[cfg(test)] +mod tests { + use super::*; + use naviscope_api::models::graph::{NodeKind, NodeSource, ResolutionStatus}; + use naviscope_api::models::symbol::Range; + + #[test] + fn hover_local_contains_type_and_decl() { + let text = build_hover_text( + &SymbolResolution::Local( + Range { + start_line: 3, + start_col: 8, + end_line: 3, + end_col: 12, + }, + Some("List".into()), + ), + None, + ); + assert!(text.contains("Local variable")); + assert!(text.contains("List")); + assert!(text.contains("Declared at `4:9`")); } - Ok(None) + #[test] + fn hover_member_uses_signature_and_owner() { + let info = DisplayGraphNode { + id: "com.example.Service#getContext".into(), + name: "getContext".into(), + kind: NodeKind::Method, + lang: "java".into(), + source: NodeSource::Project, + status: ResolutionStatus::Resolved, + location: None, + detail: None, + signature: Some("SessionContext getContext()".into()), + modifiers: vec![], + children: None, + }; + + let text = build_hover_text( + &SymbolResolution::Precise( + "com.example.Service#getContext".into(), + naviscope_api::models::SymbolIntent::Method, + ), + Some(&info), + ); + assert!(text.contains("SessionContext getContext()")); + assert!(text.contains("Declared in `com.example.Service`")); + } + + #[test] + fn hover_fallback_mentions_metadata_unavailable() { + let text = build_hover_text( + &SymbolResolution::Global("com.example.Missing#call".into()), + None, + ); + assert!(text.contains("Metadata unavailable")); + assert!(text.contains("com.example.Missing")); + } + + #[test] + fn hover_external_marks_source() { + let info = DisplayGraphNode { + id: "java.util.List#size".into(), + name: "size".into(), + kind: NodeKind::Method, + lang: "java".into(), + source: NodeSource::External, + status: ResolutionStatus::Resolved, + location: None, + detail: Some("Declared in `java.util.List`".into()), + signature: Some("int size()".into()), + modifiers: vec![], + children: None, + }; + + let text = build_hover_text( + &SymbolResolution::Global("java.util.List#size".into()), + Some(&info), + ); + assert!(text.contains("Source: external")); + } } From 0eaf04e0b2832133378df8c0d17266e0d7595061 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Thu, 12 Feb 2026 23:40:09 +0800 Subject: [PATCH 34/49] feat(java-inference): add most-specific overload selection --- .../src/inference/core/type_system.rs | 178 ++++++++++++++---- crates/lang-java/tests/inference_mock_test.rs | 91 +++++++++ 2 files changed, 231 insertions(+), 38 deletions(-) diff --git a/crates/lang-java/src/inference/core/type_system.rs b/crates/lang-java/src/inference/core/type_system.rs index 6df9382..79d20c3 100644 --- a/crates/lang-java/src/inference/core/type_system.rs +++ b/crates/lang-java/src/inference/core/type_system.rs @@ -102,52 +102,37 @@ pub trait JavaTypeSystem: TypeProvider + InheritanceProvider + MemberProvider { } // 1. Precise match (exact signatures) - for cand in candidates { - if let Some(params) = &cand.parameters { - if params.len() == arg_types.len() { - let mut match_all = true; - for (p, a) in params.iter().zip(arg_types.iter()) { - if p.type_ref != *a { - match_all = false; - break; - } - } - if match_all { - return Some(cand.clone()); - } - } - } + let exact_fixed = collect_matching_candidates(candidates, |params| { + matches_fixed_arity(params, arg_types, |arg, expected| arg == expected) + }); + if !exact_fixed.is_empty() { + return select_most_specific(self, exact_fixed, arg_types); } // 2. Subtype match (widening) - for cand in candidates { - if let Some(params) = &cand.parameters { - if matches_fixed_arity(params, arg_types, |arg, expected| { - self.is_subtype(arg, expected) - }) { - return Some(cand.clone()); - } - } + let subtype_fixed = collect_matching_candidates(candidates, |params| { + matches_fixed_arity(params, arg_types, |arg, expected| self.is_subtype(arg, expected)) + }); + if !subtype_fixed.is_empty() { + return select_most_specific(self, subtype_fixed, arg_types); } - // 3. Varargs exact match (heuristic: last array parameter is treated as varargs) - for cand in candidates { - if let Some(params) = &cand.parameters { - if matches_varargs_arity(params, arg_types, |arg, expected| arg == expected) { - return Some(cand.clone()); - } - } + // 3. Varargs exact match + let exact_varargs = collect_matching_candidates(candidates, |params| { + matches_varargs_arity(params, arg_types, |arg, expected| arg == expected) + }); + if !exact_varargs.is_empty() { + return select_most_specific(self, exact_varargs, arg_types); } // 4. Varargs subtype match - for cand in candidates { - if let Some(params) = &cand.parameters { - if matches_varargs_arity(params, arg_types, |arg, expected| { - self.is_subtype(arg, expected) - }) { - return Some(cand.clone()); - } - } + let subtype_varargs = collect_matching_candidates(candidates, |params| { + matches_varargs_arity(params, arg_types, |arg, expected| { + self.is_subtype(arg, expected) + }) + }); + if !subtype_varargs.is_empty() { + return select_most_specific(self, subtype_varargs, arg_types); } // Fallback to first if no parameters expected or provided @@ -232,3 +217,120 @@ where fn is_varargs_parameter(param: &ParameterInfo) -> bool { param.is_varargs } + +fn collect_matching_candidates(candidates: &[MemberInfo], mut matches: F) -> Vec +where + F: FnMut(&[ParameterInfo]) -> bool, +{ + candidates + .iter() + .filter_map(|cand| { + let params = cand.parameters.as_ref()?; + if matches(params) { + Some(cand.clone()) + } else { + None + } + }) + .collect() +} + +fn select_most_specific( + ts: &T, + candidates: Vec, + arg_types: &[TypeRef], +) -> Option { + if candidates.is_empty() { + return None; + } + if candidates.len() == 1 { + return candidates.into_iter().next(); + } + + let mut best_idx = 0usize; + let mut best_score = i32::MIN; + + for (i, cand) in candidates.iter().enumerate() { + let mut score = 0i32; + for (j, other) in candidates.iter().enumerate() { + if i == j { + continue; + } + let cand_more_specific = is_more_specific_than(ts, cand, other, arg_types); + let other_more_specific = is_more_specific_than(ts, other, cand, arg_types); + if cand_more_specific && !other_more_specific { + score += 1; + } else if other_more_specific && !cand_more_specific { + score -= 1; + } + } + + if score > best_score { + best_score = score; + best_idx = i; + } + } + + candidates.get(best_idx).cloned() +} + +fn is_more_specific_than( + ts: &T, + left: &MemberInfo, + right: &MemberInfo, + arg_types: &[TypeRef], +) -> bool { + let Some(left_types) = effective_param_types(left, arg_types.len()) else { + return false; + }; + let Some(right_types) = effective_param_types(right, arg_types.len()) else { + return false; + }; + if left_types.len() != right_types.len() { + return false; + } + + let mut strict = false; + for (l, r) in left_types.iter().zip(right_types.iter()) { + if l == r { + continue; + } + if ts.is_subtype(l, r) { + strict = true; + } else { + return false; + } + } + strict +} + +fn effective_param_types(member: &MemberInfo, arg_count: usize) -> Option> { + let params = member.parameters.as_ref()?; + let Some(last) = params.last() else { + return Some(vec![]); + }; + + if !is_varargs_parameter(last) { + if params.len() == arg_count { + return Some(params.iter().map(|p| p.type_ref.clone()).collect()); + } + return None; + } + + let TypeRef::Array { element, .. } = &last.type_ref else { + return None; + }; + let fixed_count = params.len() - 1; + if arg_count < fixed_count { + return None; + } + + let mut types = Vec::with_capacity(arg_count); + for p in ¶ms[..fixed_count] { + types.push(p.type_ref.clone()); + } + for _ in fixed_count..arg_count { + types.push(element.as_ref().clone()); + } + Some(types) +} diff --git a/crates/lang-java/tests/inference_mock_test.rs b/crates/lang-java/tests/inference_mock_test.rs index 2340aa2..f20cf41 100644 --- a/crates/lang-java/tests/inference_mock_test.rs +++ b/crates/lang-java/tests/inference_mock_test.rs @@ -994,3 +994,94 @@ fn test_resolve_method_varargs_uses_explicit_marker() { assert_eq!(resolved.map(|m| m.fqn), Some("Demo#logVarargs".to_string())); } + +#[test] +fn test_resolve_method_prefers_most_specific_fixed_overload() { + let ts = MockTypeSystem::new() + .add_class("java.lang.Object", None) + .add_class("java.lang.Number", Some("java.lang.Object")) + .add_class("java.lang.Integer", Some("java.lang.Number")); + + let object_overload = MemberInfo { + name: "set".to_string(), + fqn: "Demo#setObject".to_string(), + kind: MemberKind::Method, + declaring_type: "Demo".to_string(), + type_ref: TypeRef::Raw("void".into()), + parameters: Some(vec![naviscope_java::inference::ParameterInfo { + name: "value".to_string(), + type_ref: TypeRef::Id("java.lang.Object".into()), + is_varargs: false, + }]), + modifiers: vec![], + generic_signature: None, + }; + + let number_overload = MemberInfo { + fqn: "Demo#setNumber".to_string(), + parameters: Some(vec![naviscope_java::inference::ParameterInfo { + name: "value".to_string(), + type_ref: TypeRef::Id("java.lang.Number".into()), + is_varargs: false, + }]), + ..object_overload.clone() + }; + + let resolved = ts.resolve_method( + &[object_overload, number_overload.clone()], + &[TypeRef::Id("java.lang.Integer".into())], + ); + + assert_eq!(resolved.map(|m| m.fqn), Some("Demo#setNumber".to_string())); +} + +#[test] +fn test_resolve_method_prefers_most_specific_varargs_overload() { + let ts = MockTypeSystem::new() + .add_class("java.lang.Object", None) + .add_class("java.lang.String", Some("java.lang.Object")); + + let object_varargs = MemberInfo { + name: "log".to_string(), + fqn: "Demo#logObjectVarargs".to_string(), + kind: MemberKind::Method, + declaring_type: "Demo".to_string(), + type_ref: TypeRef::Raw("void".into()), + parameters: Some(vec![naviscope_java::inference::ParameterInfo { + name: "args".to_string(), + type_ref: TypeRef::Array { + element: Box::new(TypeRef::Id("java.lang.Object".into())), + dimensions: 1, + }, + is_varargs: true, + }]), + modifiers: vec![], + generic_signature: None, + }; + + let string_varargs = MemberInfo { + fqn: "Demo#logStringVarargs".to_string(), + parameters: Some(vec![naviscope_java::inference::ParameterInfo { + name: "args".to_string(), + type_ref: TypeRef::Array { + element: Box::new(TypeRef::Id("java.lang.String".into())), + dimensions: 1, + }, + is_varargs: true, + }]), + ..object_varargs.clone() + }; + + let resolved = ts.resolve_method( + &[object_varargs, string_varargs.clone()], + &[ + TypeRef::Id("java.lang.String".into()), + TypeRef::Id("java.lang.String".into()), + ], + ); + + assert_eq!( + resolved.map(|m| m.fqn), + Some("Demo#logStringVarargs".to_string()) + ); +} From 7ad23c34ca55f91244d0928fc94becd26801dd28 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Fri, 13 Feb 2026 01:46:03 +0800 Subject: [PATCH 35/49] Add implementation and reference tests for Java plugin - Introduced new tests in `implementation_behavior.rs` to verify that the Java plugin correctly identifies all implementors of an interface and method overrides. - Removed outdated tests from `logic_goto_def.rs`, `logic_goto_impl.rs`, `logic_goto_ref.rs`, `logic_goto_type.rs`, and `logic_hierarchy.rs` to streamline the test suite. - Added comprehensive reference tests in `references_behavior.rs` to ensure correct handling of method references, local shadowing, and overloaded methods. - Implemented type definition tests in `type_definition_behavior.rs` to validate that the plugin resolves variable and method return types accurately. - Enhanced the engine facade tests in `test_engine_facade.rs` to cover overload scenarios and ensure proper resolution of method declarations. --- crates/lang-java/tests/behavior_common.rs | 21 ++ .../tests/call_hierarchy_behavior.rs | 192 ++++++++++++ .../tests/goto_definition_behavior.rs | 280 ++++++++++++++++++ .../tests/implementation_behavior.rs | 85 ++++++ crates/lang-java/tests/logic_goto_def.rs | 181 ----------- crates/lang-java/tests/logic_goto_impl.rs | 85 ------ crates/lang-java/tests/logic_goto_ref.rs | 44 --- crates/lang-java/tests/logic_goto_type.rs | 80 ----- crates/lang-java/tests/logic_hierarchy.rs | 206 ------------- crates/lang-java/tests/references_behavior.rs | 230 ++++++++++++++ crates/lang-java/tests/test_engine_facade.rs | 122 +++++++- .../tests/type_definition_behavior.rs | 80 +++++ 12 files changed, 1009 insertions(+), 597 deletions(-) create mode 100644 crates/lang-java/tests/behavior_common.rs create mode 100644 crates/lang-java/tests/call_hierarchy_behavior.rs create mode 100644 crates/lang-java/tests/goto_definition_behavior.rs create mode 100644 crates/lang-java/tests/implementation_behavior.rs delete mode 100644 crates/lang-java/tests/logic_goto_def.rs delete mode 100644 crates/lang-java/tests/logic_goto_impl.rs delete mode 100644 crates/lang-java/tests/logic_goto_ref.rs delete mode 100644 crates/lang-java/tests/logic_goto_type.rs delete mode 100644 crates/lang-java/tests/logic_hierarchy.rs create mode 100644 crates/lang-java/tests/references_behavior.rs create mode 100644 crates/lang-java/tests/type_definition_behavior.rs diff --git a/crates/lang-java/tests/behavior_common.rs b/crates/lang-java/tests/behavior_common.rs new file mode 100644 index 0000000..7f63f3f --- /dev/null +++ b/crates/lang-java/tests/behavior_common.rs @@ -0,0 +1,21 @@ +use naviscope_api::models::SymbolResolution; +use naviscope_core::model::CodeGraph; +use naviscope_java::JavaPlugin; +use naviscope_plugin::LspSyntaxService; +use std::path::PathBuf; +use tree_sitter::Tree; + +pub fn collect_occurrence_counts_by_file( + resolver: &JavaPlugin, + index: &CodeGraph, + trees: &[(PathBuf, String, Tree)], + target: &SymbolResolution, +) -> Vec<(String, usize)> { + trees + .iter() + .map(|(path, source, tree)| { + let ranges = resolver.find_occurrences(source, tree, target, Some(index)); + (path.to_string_lossy().to_string(), ranges.len()) + }) + .collect() +} diff --git a/crates/lang-java/tests/call_hierarchy_behavior.rs b/crates/lang-java/tests/call_hierarchy_behavior.rs new file mode 100644 index 0000000..5fc1fbc --- /dev/null +++ b/crates/lang-java/tests/call_hierarchy_behavior.rs @@ -0,0 +1,192 @@ +mod common; + +use common::{offset_to_point, setup_java_test_graph}; +use naviscope_core::features::CodeGraphLike; +use naviscope_core::features::discovery::DiscoveryEngine; +use naviscope_core::ingest::parser::SymbolResolution; +use naviscope_java::JavaPlugin; +use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; + +#[test] +fn given_leaf_method_when_find_incoming_callers_then_returns_direct_callers_only() { + let files = vec![( + "Test.java", + "public class Test { void leaf() {} void c1() { leaf(); } void c2() { leaf(); } }", + )]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let content = &trees[0].1; + let tree = &trees[0].2; + let leaf_pos = content.find("void leaf").expect("find leaf") + 5; + let (line, col) = offset_to_point(content, leaf_pos); + let resolution = resolver + .resolve_at(tree, content, line, col, &index) + .expect("resolve leaf symbol"); + + let target = resolver.find_matches(&index, &resolution); + let target_idx = *index.fqn_map().get(&target[0]).expect("target node exists"); + + let discovery = DiscoveryEngine::new(&index, std::collections::HashMap::new()); + let abs_path = std::env::current_dir().expect("cwd").join("Test.java"); + let uri: lsp_types::Uri = format!("file://{}", abs_path.display()) + .parse() + .expect("valid uri"); + + let semantic = JavaPlugin::new().expect("failed to create java plugin"); + let locations = discovery.scan_file(&semantic, content, &resolution, &uri); + + let mut callers = Vec::new(); + for loc in locations { + if let Some(name_range) = index.topology()[target_idx].name_range() + && name_range.start_line == loc.range.start.line as usize + && name_range.start_col == loc.range.start.character as usize + { + continue; + } + + if let Some(container_idx) = index.find_container_node_at( + &std::path::PathBuf::from("Test.java"), + loc.range.start.line as usize, + loc.range.start.character as usize, + ) { + let node = &index.topology()[container_idx]; + let fqn = index + .render_fqn( + node, + Some(&naviscope_java::naming::JavaNamingConvention::default()), + ) + .to_string(); + if !callers.contains(&fqn) { + callers.push(fqn); + } + } + } + + callers.sort(); + assert_eq!(callers, vec!["Test#c1", "Test#c2"]); +} + +#[test] +fn given_root_method_when_find_outgoing_calls_then_returns_direct_callees() { + let files = vec![( + "Test.java", + "public class Test { void root() { step1(); step2(); } void step1() {} void step2() {} }", + )]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let content = &trees[0].1; + let tree = &trees[0].2; + let root_pos = content.find("void root").expect("find root") + 5; + let (line, col) = offset_to_point(content, root_pos); + let resolution = resolver + .resolve_at(tree, content, line, col, &index) + .expect("resolve root"); + + let target_fqn = resolver.find_matches(&index, &resolution)[0]; + let target_idx = *index.fqn_map().get(&target_fqn).expect("target node exists"); + let container_range = index.topology()[target_idx].range().expect("container range"); + + let mut callees = Vec::new(); + let mut stack = vec![tree.root_node()]; + while let Some(node) = stack.pop() { + let range = node.range(); + if range.start_point.row > container_range.end_line + || range.end_point.row < container_range.start_line + { + continue; + } + + if node.kind() == "identifier" + && let Some(out_res) = resolver.resolve_at( + tree, + content, + range.start_point.row, + range.start_point.column, + &index, + ) + { + let maybe_fqn = match out_res { + SymbolResolution::Global(fqn) => Some(fqn), + SymbolResolution::Precise(fqn, _) => Some(fqn), + _ => None, + }; + + if let Some(fqn) = maybe_fqn + && fqn.contains('#') + && fqn != "Test#root" + && !callees.contains(&fqn) + { + callees.push(fqn); + } + } + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + stack.push(child); + } + } + + callees.sort(); + assert_eq!(callees, vec!["Test#step1", "Test#step2"]); +} + +#[test] +fn given_recursive_method_when_find_incoming_callers_then_includes_self_call() { + let files = vec![("Test.java", "public class Test { void rec() { rec(); } }")]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let content = &trees[0].1; + let tree = &trees[0].2; + let rec_pos = content.find("void rec").expect("find rec") + 5; + let (line, col) = offset_to_point(content, rec_pos); + let resolution = resolver + .resolve_at(tree, content, line, col, &index) + .expect("resolve rec"); + + let target_fqn = resolver.find_matches(&index, &resolution)[0]; + let target_idx = *index.fqn_map().get(&target_fqn).expect("target node exists"); + + let discovery = DiscoveryEngine::new(&index, std::collections::HashMap::new()); + let abs_path = std::env::current_dir().expect("cwd").join("Test.java"); + let uri: lsp_types::Uri = format!("file://{}", abs_path.display()) + .parse() + .expect("valid uri"); + + let semantic = JavaPlugin::new().expect("failed to create java plugin"); + let locations = discovery.scan_file(&semantic, content, &resolution, &uri); + + let mut callers = Vec::new(); + for loc in locations { + if let Some(name_range) = index.topology()[target_idx].name_range() + && name_range.start_line == loc.range.start.line as usize + && name_range.start_col == loc.range.start.character as usize + { + continue; + } + + if let Some(container_idx) = index.find_container_node_at( + &std::path::PathBuf::from("Test.java"), + loc.range.start.line as usize, + loc.range.start.character as usize, + ) { + let node = &index.topology()[container_idx]; + let fqn = index + .render_fqn( + node, + Some(&naviscope_java::naming::JavaNamingConvention::default()), + ) + .to_string(); + if !callers.contains(&fqn) { + callers.push(fqn); + } + } + } + + assert!(callers.contains(&"Test#rec".to_string())); +} diff --git a/crates/lang-java/tests/goto_definition_behavior.rs b/crates/lang-java/tests/goto_definition_behavior.rs new file mode 100644 index 0000000..0baea92 --- /dev/null +++ b/crates/lang-java/tests/goto_definition_behavior.rs @@ -0,0 +1,280 @@ +mod common; + +use common::{offset_to_point, setup_java_test_graph}; +use naviscope_core::features::CodeGraphLike; +use naviscope_core::ingest::parser::SymbolResolution; +use naviscope_java::JavaPlugin; +use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; + +#[test] +fn given_local_usage_when_goto_definition_then_returns_local_binding() { + let files = vec![( + "Test.java", + "public class Test { void main() { int x = 1; int y = x + 1; } }", + )]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let content = &trees[0].1; + let tree = &trees[0].2; + let usage_pos = content.rfind("x + 1").expect("find usage"); + let (line, col) = offset_to_point(content, usage_pos); + + let resolution = resolver + .resolve_at(tree, content, line, col, &index) + .expect("resolve local symbol"); + + if let SymbolResolution::Local(range, _) = resolution { + let def_pos = content.find("int x").expect("find declaration") + 4; + assert_eq!(range.start_col, def_pos); + } else { + panic!("expected local resolution"); + } +} + +#[test] +fn given_cross_file_call_when_goto_definition_then_resolves_precise_method_owner() { + let files = vec![ + ( + "A.java", + "package com; public class A { public void hello() {} }", + ), + ( + "B.java", + "package com; public class B { void run(A a) { a.hello(); } }", + ), + ]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let content = &trees[1].1; + let tree = &trees[1].2; + let pos = content.find("hello()").expect("find method call"); + let (line, col) = offset_to_point(content, pos); + + let resolution = resolver + .resolve_at(tree, content, line, col, &index) + .expect("resolve hello call"); + let matches = resolver.find_matches(&index, &resolution); + + assert_eq!(matches.len(), 1, "goto definition should be unique"); + let idx = *index.fqn_map().get(&matches[0]).expect("node exists"); + assert_eq!( + index.render_fqn( + &index.topology()[idx], + Some(&naviscope_java::naming::JavaNamingConvention::default()) + ), + "com.A#hello" + ); +} + +#[test] +fn given_shadowed_local_when_goto_definition_then_binds_to_local_declaration() { + let files = vec![( + "Test.java", + "public class Test { int x = 0; void run() { int x = 1; x = 2; } }", + )]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let content = &trees[0].1; + let tree = &trees[0].2; + let usage_pos = content.find("x = 2").expect("find local assignment"); + let (line, col) = offset_to_point(content, usage_pos); + + let resolution = resolver + .resolve_at(tree, content, line, col, &index) + .expect("resolve local usage"); + + if let SymbolResolution::Local(range, _) = resolution { + let def_pos = content.find("int x = 1").expect("find local declaration") + 4; + assert_eq!(range.start_col, def_pos); + } else { + panic!("expected local resolution for shadowed variable"); + } +} + +#[test] +fn given_constructor_call_when_goto_definition_then_resolves_class_or_ctor_symbol() { + let files = vec![ + ("A.java", "public class A { public A() {} }"), + ("B.java", "public class B { A a = new A(); }"), + ]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let b_content = &trees[1].1; + let b_tree = &trees[1].2; + let usage_pos = b_content.find("new A()").expect("find constructor call") + 4; + let (line, col) = offset_to_point(b_content, usage_pos); + + let resolution = resolver + .resolve_at(b_tree, b_content, line, col, &index) + .expect("resolve constructor symbol"); + let matches = resolver.find_matches(&index, &resolution); + + assert!(!matches.is_empty()); + let idx = *index.fqn_map().get(&matches[0]).expect("node exists"); + assert!( + index + .render_fqn( + &index.topology()[idx], + Some(&naviscope_java::naming::JavaNamingConvention::default()) + ) + .contains('A') + ); +} + +#[test] +fn given_static_field_access_when_goto_definition_then_resolves_declaring_field() { + let files = vec![ + ("A.java", "public class A { public static int VAL = 1; }"), + ("B.java", "public class B { int x = A.VAL; }"), + ]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let b_content = &trees[1].1; + let b_tree = &trees[1].2; + let usage_pos = b_content.find("VAL").expect("find static field usage"); + let (line, col) = offset_to_point(b_content, usage_pos); + + let resolution = resolver + .resolve_at(b_tree, b_content, line, col, &index) + .expect("resolve static field"); + let matches = resolver.find_matches(&index, &resolution); + + assert!(!matches.is_empty()); + let idx = *index.fqn_map().get(&matches[0]).expect("node exists"); + assert_eq!( + index.render_fqn( + &index.topology()[idx], + Some(&naviscope_java::naming::JavaNamingConvention::default()) + ), + "A#VAL" + ); +} + +#[test] +fn given_overloaded_method_call_chain_when_goto_definition_then_uses_most_specific_overload() { + let files = vec![ + ("BaseResult.java", "public class BaseResult { void base() {} }"), + ( + "SpecialResult.java", + "public class SpecialResult extends BaseResult { void special() {} }", + ), + ("SpecialArg.java", "public class SpecialArg {}"), + ( + "A.java", + "public class A { BaseResult pick(Object o) { return new BaseResult(); } SpecialResult pick(SpecialArg a) { return new SpecialResult(); } }", + ), + ( + "Use.java", + "public class Use { void run(A a) { a.pick(new SpecialArg()).special(); } }", + ), + ]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let content = &trees[4].1; + let tree = &trees[4].2; + let pos = content + .find("special()") + .expect("find chained method invocation"); + let (line, col) = offset_to_point(content, pos); + + let resolution = resolver + .resolve_at(tree, content, line, col, &index) + .expect("resolve chained method symbol"); + let matches = resolver.find_matches(&index, &resolution); + + assert_eq!(matches.len(), 1); + let idx = *index.fqn_map().get(&matches[0]).expect("node exists"); + assert_eq!( + index.render_fqn( + &index.topology()[idx], + Some(&naviscope_java::naming::JavaNamingConvention::default()) + ), + "SpecialResult#special" + ); +} + +#[test] +fn given_overloaded_constructor_call_when_goto_definition_then_resolves_constructor_type_symbol() { + let files = vec![ + ("SpecialArg.java", "public class SpecialArg {}"), + ( + "A.java", + "public class A { A() {} A(SpecialArg arg) {} }", + ), + ( + "Use.java", + "public class Use { A build() { return new A(new SpecialArg()); } }", + ), + ]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let content = &trees[2].1; + let tree = &trees[2].2; + let pos = content + .find("new A(new SpecialArg())") + .expect("find overloaded constructor call") + + 4; + let (line, col) = offset_to_point(content, pos); + + let resolution = resolver + .resolve_at(tree, content, line, col, &index) + .expect("resolve constructor symbol"); + let matches = resolver.find_matches(&index, &resolution); + + assert!(!matches.is_empty()); + let idx = *index.fqn_map().get(&matches[0]).expect("node exists"); + assert_eq!( + index.render_fqn( + &index.topology()[idx], + Some(&naviscope_java::naming::JavaNamingConvention::default()) + ), + "A" + ); +} + +#[test] +fn given_same_class_different_arity_overloads_when_goto_definition_then_resolves_member_symbol() { + let files = vec![( + "A.java", + "public class A { void target() { target(1); target(1, 2); } void target(int a) {} void target(int a, int b) {} }", + )]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let content = &trees[0].1; + let tree = &trees[0].2; + let pos = content + .find("target(1, 2)") + .expect("find same-class overload call"); + let (line, col) = offset_to_point(content, pos); + + let resolution = resolver + .resolve_at(tree, content, line, col, &index) + .expect("resolve overloaded call"); + let matches = resolver.find_matches(&index, &resolution); + + assert!(!matches.is_empty()); + let idx = *index.fqn_map().get(&matches[0]).expect("node exists"); + assert_eq!( + index.render_fqn( + &index.topology()[idx], + Some(&naviscope_java::naming::JavaNamingConvention::default()) + ), + "A#target" + ); +} diff --git a/crates/lang-java/tests/implementation_behavior.rs b/crates/lang-java/tests/implementation_behavior.rs new file mode 100644 index 0000000..9fd54b3 --- /dev/null +++ b/crates/lang-java/tests/implementation_behavior.rs @@ -0,0 +1,85 @@ +mod common; + +use common::{offset_to_point, setup_java_test_graph}; +use naviscope_core::features::CodeGraphLike; +use naviscope_java::JavaPlugin; +use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; + +#[test] +fn given_interface_when_goto_implementation_then_returns_all_implementors() { + let files = vec![ + ("IService.java", "public interface IService { void run(); }"), + ( + "AService.java", + "public class AService implements IService { public void run() {} }", + ), + ( + "BService.java", + "public class BService implements IService { public void run() {} }", + ), + ]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let content = &trees[0].1; + let tree = &trees[0].2; + let pos = content.find("IService").expect("find interface"); + let (line, col) = offset_to_point(content, pos); + + let resolution = resolver + .resolve_at(tree, content, line, col, &index) + .expect("resolve interface"); + + let impls = resolver.find_implementations(&index, &resolution); + let mut fqns: Vec = impls + .iter() + .map(|id| { + let idx = *index.fqn_map().get(id).expect("node exists"); + index + .render_fqn( + &index.topology()[idx], + Some(&naviscope_java::naming::JavaNamingConvention), + ) + .to_string() + }) + .collect(); + fqns.sort(); + + assert_eq!(fqns, vec!["AService", "BService"]); +} + +#[test] +fn given_interface_method_when_goto_implementation_then_returns_method_override() { + let files = vec![ + ("IBase.java", "public interface IBase { void act(); }"), + ( + "Impl.java", + "public class Impl implements IBase { public void act() {} }", + ), + ]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let content = &trees[0].1; + let tree = &trees[0].2; + let pos = content.find("act()").expect("find interface method"); + let (line, col) = offset_to_point(content, pos); + + let resolution = resolver + .resolve_at(tree, content, line, col, &index) + .expect("resolve interface method"); + + let impls = resolver.find_implementations(&index, &resolution); + assert_eq!(impls.len(), 1); + + let idx = *index.fqn_map().get(&impls[0]).expect("node exists"); + assert_eq!( + index.render_fqn( + &index.topology()[idx], + Some(&naviscope_java::naming::JavaNamingConvention) + ), + "Impl#act" + ); +} diff --git a/crates/lang-java/tests/logic_goto_def.rs b/crates/lang-java/tests/logic_goto_def.rs deleted file mode 100644 index 1df078d..0000000 --- a/crates/lang-java/tests/logic_goto_def.rs +++ /dev/null @@ -1,181 +0,0 @@ -mod common; - -use common::{offset_to_point, setup_java_test_graph}; -use naviscope_core::features::CodeGraphLike; -use naviscope_core::ingest::parser::SymbolResolution; -use naviscope_java::JavaPlugin; -use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; - -#[test] -fn test_goto_definition_local() { - let files = vec![( - "Test.java", - "public class Test { void main() { int x = 1; int y = x + 1; } }", - )]; - let (index, trees) = setup_java_test_graph(files); - let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); - - let content = &trees[0].1; - let tree = &trees[0].2; - - // Position of 'x' in 'x + 1' - let usage_pos = content.rfind("x + 1").unwrap(); - let (line, col) = offset_to_point(content, usage_pos); - - let res = resolver - .resolve_at(tree, content, line, col, &index) - .expect("Should resolve"); - - if let SymbolResolution::Local(range, _) = res { - // 'int x = 1' starts at index 35 - let def_pos = content.find("int x").unwrap() + 4; - assert_eq!(range.start_col, def_pos); - } else { - panic!("Expected local resolution, got {:?}", res); - } -} - -#[test] -fn test_goto_definition_cross_file() { - let files = vec![ - ( - "A.java", - "package com; public class A { public void hello() {} }", - ), - ( - "B.java", - "package com; public class B { void test() { A a = new A(); a.hello(); } }", - ), - ]; - let (index, trees) = setup_java_test_graph(files); - let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); - - let b_content = &trees[1].1; - let b_tree = &trees[1].2; - - // 1. Resolve Class A - let a_usage = b_content.find("A a").unwrap(); - let (line, col) = offset_to_point(b_content, a_usage); - let res = resolver - .resolve_at(b_tree, b_content, line, col, &index) - .expect("Should resolve A"); - let matches = resolver.find_matches(&index, &res); - assert!(!matches.is_empty()); - let idx = *index.fqn_map().get(&matches[0]).expect("Node not found"); - assert_eq!( - index.render_fqn( - &index.topology()[idx], - Some(&naviscope_java::naming::JavaNamingConvention::default()) - ), - "com.A" - ); - - // 2. Resolve Method hello - let hello_usage = b_content.find("hello()").unwrap(); - let (line, col) = offset_to_point(b_content, hello_usage); - let res = resolver - .resolve_at(b_tree, b_content, line, col, &index) - .expect("Should resolve hello"); - let matches = resolver.find_matches(&index, &res); - assert!(!matches.is_empty()); - let idx = *index.fqn_map().get(&matches[0]).expect("Node not found"); - assert_eq!( - index.render_fqn( - &index.topology()[idx], - Some(&naviscope_java::naming::JavaNamingConvention::default()) - ), - "com.A#hello" - ); -} - -#[test] -fn test_goto_definition_shadowing() { - let files = vec![( - "Test.java", - "public class Test { int x = 0; void m() { int x = 1; x = 2; } }", - )]; - let (index, trees) = setup_java_test_graph(files); - let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); - - let content = &trees[0].1; - let tree = &trees[0].2; - - // Position of 'x' in 'x = 2' (should be local x) - let usage_pos = content.find("x = 2").unwrap(); - let (line, col) = offset_to_point(content, usage_pos); - - let res = resolver - .resolve_at(tree, content, line, col, &index) - .expect("Should resolve"); - - if let SymbolResolution::Local(range, _) = res { - let local_def = content.find("int x = 1").unwrap() + 4; - assert_eq!(range.start_col, local_def); - } else { - panic!("Expected local resolution for shadowed x, got {:?}", res); - } -} - -#[test] -fn test_goto_definition_constructor() { - let files = vec![ - ("A.java", "public class A { public A() {} }"), - ("B.java", "public class B { A a = new A(); }"), - ]; - let (index, trees) = setup_java_test_graph(files); - let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); - - let b_content = &trees[1].1; - let b_tree = &trees[1].2; - - // Resolve 'A' in 'new A()' - let usage_pos = b_content.find("new A()").unwrap() + 4; - let (line, col) = offset_to_point(b_content, usage_pos); - - let res = resolver - .resolve_at(b_tree, b_content, line, col, &index) - .expect("Should resolve constructor"); - let matches = resolver.find_matches(&index, &res); - assert!(!matches.is_empty()); - // In our model, constructor might be the class or the method depending on implementation - let idx = *index.fqn_map().get(&matches[0]).expect("Node not found"); - assert!( - index - .render_fqn( - &index.topology()[idx], - Some(&naviscope_java::naming::JavaNamingConvention::default()) - ) - .contains("A") - ); -} - -#[test] -fn test_goto_definition_static() { - let files = vec![ - ("A.java", "public class A { public static int VAL = 1; }"), - ("B.java", "public class B { int x = A.VAL; }"), - ]; - let (index, trees) = setup_java_test_graph(files); - let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); - - let b_content = &trees[1].1; - let b_tree = &trees[1].2; - - // Resolve 'VAL' in 'A.VAL' - let usage_pos = b_content.find("VAL").unwrap(); - let (line, col) = offset_to_point(b_content, usage_pos); - - let res = resolver - .resolve_at(b_tree, b_content, line, col, &index) - .expect("Should resolve static field"); - let matches = resolver.find_matches(&index, &res); - assert!(!matches.is_empty()); - let idx = *index.fqn_map().get(&matches[0]).expect("Node not found"); - assert_eq!( - index.render_fqn( - &index.topology()[idx], - Some(&naviscope_java::naming::JavaNamingConvention::default()) - ), - "A#VAL" - ); -} diff --git a/crates/lang-java/tests/logic_goto_impl.rs b/crates/lang-java/tests/logic_goto_impl.rs deleted file mode 100644 index 1760cf2..0000000 --- a/crates/lang-java/tests/logic_goto_impl.rs +++ /dev/null @@ -1,85 +0,0 @@ -mod common; - -use common::{offset_to_point, setup_java_test_graph}; -use naviscope_core::features::CodeGraphLike; -use naviscope_java::JavaPlugin; -use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; - -#[test] -fn test_goto_implementation_interface() { - let files = vec![ - ("IBase.java", "public interface IBase { void act(); }"), - ( - "ImplA.java", - "public class ImplA implements IBase { public void act() {} }", - ), - ( - "ImplB.java", - "public class ImplB implements IBase { public void act() {} }", - ), - ]; - let (index, trees) = setup_java_test_graph(files); - let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); - - let base_content = &trees[0].1; - let base_tree = &trees[0].2; - - // Resolve 'IBase' - let usage_pos = base_content.find("IBase").unwrap(); - let (line, col) = offset_to_point(base_content, usage_pos); - let res = resolver - .resolve_at(base_tree, base_content, line, col, &index) - .expect("Should resolve IBase"); - - let impls = resolver.find_implementations(&index, &res); - assert_eq!(impls.len(), 2); - - let fqns: Vec<_> = impls - .iter() - .map(|&id| { - let idx = *index.fqn_map().get(&id).expect("Node not found"); - index - .render_fqn( - &index.topology()[idx], - Some(&naviscope_java::naming::JavaNamingConvention), - ) - .to_string() - }) - .collect(); - assert!(fqns.contains(&"ImplA".to_string())); - assert!(fqns.contains(&"ImplB".to_string())); -} - -#[test] -fn test_goto_implementation_method() { - let files = vec![ - ("IBase.java", "public interface IBase { void act(); }"), - ( - "Impl.java", - "public class Impl implements IBase { public void act() {} }", - ), - ]; - let (index, trees) = setup_java_test_graph(files); - let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); - - let base_content = &trees[0].1; - let base_tree = &trees[0].2; - - // Resolve 'act' in IBase - let usage_pos = base_content.find("act()").unwrap(); - let (line, col) = offset_to_point(base_content, usage_pos); - let res = resolver - .resolve_at(base_tree, base_content, line, col, &index) - .expect("Should resolve act"); - - let impls = resolver.find_implementations(&index, &res); - assert_eq!(impls.len(), 1); - let idx = *index.fqn_map().get(&impls[0]).expect("Node not found"); - assert_eq!( - index.render_fqn( - &index.topology()[idx], - Some(&naviscope_java::naming::JavaNamingConvention) - ), - "Impl#act" - ); -} diff --git a/crates/lang-java/tests/logic_goto_ref.rs b/crates/lang-java/tests/logic_goto_ref.rs deleted file mode 100644 index d699846..0000000 --- a/crates/lang-java/tests/logic_goto_ref.rs +++ /dev/null @@ -1,44 +0,0 @@ -mod common; - -use common::{offset_to_point, setup_java_test_graph}; -use naviscope_java::JavaPlugin; -use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; - -#[test] -fn test_goto_references_method() { - let files = vec![ - ("A.java", "public class A { public void target() {} }"), - ("B.java", "public class B { void m1(A a) { a.target(); } }"), - ("C.java", "public class C { void m2(A a) { a.target(); } }"), - ]; - let (index, trees) = setup_java_test_graph(files); - let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); - - let a_content = &trees[0].1; - let a_tree = &trees[0].2; - - // Resolve 'target' in A - let usage_pos = a_content.find("target()").unwrap(); - let (line, col) = offset_to_point(a_content, usage_pos); - let res = resolver - .resolve_at(a_tree, a_content, line, col, &index) - .expect("Should resolve target"); - let matches = resolver.find_matches(&index, &res); - let target_fqn = matches[0]; - let target_idx = *index.fqn_map().get(&target_fqn).expect("Node not found"); - - // Check for candidate files via DiscoveryEngine (Meso-scouting) - let discovery = naviscope_core::features::discovery::DiscoveryEngine::new( - &index, - std::collections::HashMap::new(), - ); - let candidate_files = discovery.scout_references(&[target_idx]); - - assert_eq!(candidate_files.len(), 3); - let paths: Vec = candidate_files - .iter() - .map(|p| p.to_string_lossy().to_string()) - .collect(); - assert!(paths.contains(&"B.java".to_string())); - assert!(paths.contains(&"C.java".to_string())); -} diff --git a/crates/lang-java/tests/logic_goto_type.rs b/crates/lang-java/tests/logic_goto_type.rs deleted file mode 100644 index 2b68b18..0000000 --- a/crates/lang-java/tests/logic_goto_type.rs +++ /dev/null @@ -1,80 +0,0 @@ -mod common; - -use common::{offset_to_point, setup_java_test_graph}; -use naviscope_core::features::CodeGraphLike; -use naviscope_java::JavaPlugin; -use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; - -#[test] -fn test_goto_type_definition_variable() { - let files = vec![ - ("Model.java", "public class Model {}"), - ( - "Client.java", - "public class Client { void m() { Model m = null; } }", - ), - ]; - let (index, trees) = setup_java_test_graph(files); - let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); - - let client_content = &trees[1].1; - let client_tree = &trees[1].2; - - // Resolve type of 'm' in 'Model m' - let usage_pos = client_content.find("m = null").unwrap(); - let (line, col) = offset_to_point(client_content, usage_pos); - - let res = resolver - .resolve_at(client_tree, client_content, line, col, &index) - .expect("Should resolve m"); - let type_res = resolver.resolve_type_of(&index, &res); - - assert!(!type_res.is_empty()); - let matches = resolver.find_matches(&index, &type_res[0]); - assert!(!matches.is_empty()); - let idx = *index.fqn_map().get(&matches[0]).expect("Node not found"); - assert_eq!( - index.render_fqn( - &index.topology()[idx], - Some(&naviscope_java::naming::JavaNamingConvention) - ), - "Model" - ); -} - -#[test] -fn test_goto_type_definition_method_return() { - let files = vec![ - ("Model.java", "public class Model {}"), - ( - "Service.java", - "public class Service { Model get() { return null; } }", - ), - ]; - let (index, trees) = setup_java_test_graph(files); - let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); - - let service_content = &trees[1].1; - let service_tree = &trees[1].2; - - // Resolve type of method 'get' - let usage_pos = service_content.find("get()").unwrap(); - let (line, col) = offset_to_point(service_content, usage_pos); - - let res = resolver - .resolve_at(service_tree, service_content, line, col, &index) - .expect("Should resolve get"); - let type_res = resolver.resolve_type_of(&index, &res); - - assert!(!type_res.is_empty()); - let matches = resolver.find_matches(&index, &type_res[0]); - assert!(!matches.is_empty()); - let idx = *index.fqn_map().get(&matches[0]).expect("Node not found"); - assert_eq!( - index.render_fqn( - &index.topology()[idx], - Some(&naviscope_java::naming::JavaNamingConvention) - ), - "Model" - ); -} diff --git a/crates/lang-java/tests/logic_hierarchy.rs b/crates/lang-java/tests/logic_hierarchy.rs deleted file mode 100644 index 4598026..0000000 --- a/crates/lang-java/tests/logic_hierarchy.rs +++ /dev/null @@ -1,206 +0,0 @@ -mod common; - -use common::{offset_to_point, setup_java_test_graph}; -use naviscope_core::features::CodeGraphLike; -use naviscope_core::features::discovery::DiscoveryEngine; -use naviscope_core::ingest::parser::SymbolResolution; -use naviscope_java::JavaPlugin; -use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; - -#[test] -fn test_call_hierarchy_incoming() { - let files = vec![( - "Test.java", - "public class Test { - void leaf() {} - void caller1() { leaf(); } - void caller2() { leaf(); } - void root() { caller1(); caller2(); } - }", - )]; - let (index, trees) = setup_java_test_graph(files); - let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); - - let content = &trees[0].1; - let tree = &trees[0].2; - - // Target: leaf() - let leaf_pos = content.find("void leaf").unwrap() + 5; - let (line, col) = offset_to_point(content, leaf_pos); - let res = resolver - .resolve_at(tree, content, line, col, &index) - .expect("Should resolve leaf"); - let target_fqn = resolver.find_matches(&index, &res)[0]; - let target_idx = *index.fqn_map().get(&target_fqn).expect("Node not found"); - - // Check callers using DiscoveryEngine - let discovery = DiscoveryEngine::new(&index, std::collections::HashMap::new()); - let candidate_files = discovery.scout_references(&[target_idx]); - - let mut callers = Vec::new(); - let abs_path = std::env::current_dir().unwrap().join("Test.java"); - let uri: lsp_types::Uri = format!("file://{}", abs_path.display()).parse().unwrap(); - - let semantic = JavaPlugin::new().expect("failed to create java plugin"); - for path in candidate_files { - let locations = discovery.scan_file(&semantic, content, &res, &uri); - for loc in locations { - if let Some(container_idx) = index.find_container_node_at( - &path, - loc.range.start.line as usize, - loc.range.start.character as usize, - ) { - // Skip if the occurrence is actually the definition of the target itself - if let Some(name_range) = index.topology()[target_idx].name_range() { - if name_range.start_line == loc.range.start.line as usize - && name_range.start_col == loc.range.start.character as usize - { - continue; - } - } - let node = &index.topology()[container_idx]; - let fqn = index - .render_fqn(node, Some(&naviscope_java::naming::JavaNamingConvention)) - .to_string(); - if !callers.contains(&fqn) { - callers.push(fqn); - } - } - } - } - - assert_eq!(callers.len(), 2); - assert!(callers.contains(&"Test#caller1".to_string())); - assert!(callers.contains(&"Test#caller2".to_string())); -} - -#[test] -fn test_call_hierarchy_outgoing() { - let files = vec![( - "Test.java", - "public class Test { - void root() { step1(); step2(); } - void step1() {} - void step2() {} - }", - )]; - let (index, trees) = setup_java_test_graph(files); - let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); - - let content = &trees[0].1; - let tree = &trees[0].2; - - // Target: root() - let root_pos = content.find("void root").unwrap() + 5; - let (line, col) = offset_to_point(content, root_pos); - let res = resolver - .resolve_at(tree, content, line, col, &index) - .expect("Should resolve root"); - let target_fqn = resolver.find_matches(&index, &res)[0]; - let target_idx = *index.fqn_map().get(&target_fqn).expect("Node not found"); - - // Check callees using manual walk (similar to outgoing_calls in LSP) - let container_range = index.topology()[target_idx].range().unwrap(); - let mut callees = Vec::new(); - - let mut stack = vec![tree.root_node()]; - while let Some(n) = stack.pop() { - let r = n.range(); - if r.start_point.row > container_range.end_line - || r.end_point.row < container_range.start_line - { - continue; - } - - if n.kind() == "identifier" { - if let Some(out_res) = resolver.resolve_at( - tree, - content, - r.start_point.row, - r.start_point.column, - &index, - ) { - let target_fqn = match out_res { - SymbolResolution::Global(fqn) => Some(fqn), - SymbolResolution::Precise(fqn, _) => Some(fqn), - _ => None, - }; - - if let Some(fqn) = target_fqn { - if !callees.contains(&fqn) && fqn != "Test#root" { - callees.push(fqn); - } - } - } - } - - let mut cursor = n.walk(); - for child in n.children(&mut cursor) { - stack.push(child); - } - } - - assert_eq!(callees.len(), 2); - assert!(callees.contains(&"Test#step1".to_string())); - assert!(callees.contains(&"Test#step2".to_string())); -} - -#[test] -fn test_call_hierarchy_recursion() { - let files = vec![( - "Test.java", - "public class Test { - void rec() { rec(); } - }", - )]; - let (index, trees) = setup_java_test_graph(files); - let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); - - let content = &trees[0].1; - let tree = &trees[0].2; - - let pos = content.find("void rec").unwrap() + 5; - let (line, col) = offset_to_point(content, pos); - let res = resolver - .resolve_at(tree, content, line, col, &index) - .unwrap(); - let target_fqn = resolver.find_matches(&index, &res)[0]; - let idx = *index.fqn_map().get(&target_fqn).expect("Node not found"); - - // Incoming should contain itself - let discovery = DiscoveryEngine::new(&index, std::collections::HashMap::new()); - let mut callers = Vec::new(); - let abs_path = std::env::current_dir().unwrap().join("Test.java"); - let uri: lsp_types::Uri = format!("file://{}", abs_path.display()).parse().unwrap(); - - let semantic = JavaPlugin::new().expect("failed to create java plugin"); - let locations = discovery.scan_file(&semantic, content, &res, &uri); - for loc in locations { - if let Some(c_idx) = index.find_container_node_at( - &std::path::PathBuf::from("Test.java"), - loc.range.start.line as usize, - loc.range.start.character as usize, - ) { - // Skip if the occurrence is actually the definition of the target itself - if let Some(name_range) = index.topology()[idx].name_range() { - if name_range.start_line == loc.range.start.line as usize - && name_range.start_col == loc.range.start.character as usize - { - continue; - } - } - let node = &index.topology()[c_idx]; - let fqn = index - .render_fqn( - node, - Some(&naviscope_java::naming::JavaNamingConvention::default()), - ) - .to_string(); - if !callers.contains(&fqn) { - callers.push(fqn); - } - } - } - - assert!(callers.contains(&"Test#rec".to_string())); -} diff --git a/crates/lang-java/tests/references_behavior.rs b/crates/lang-java/tests/references_behavior.rs new file mode 100644 index 0000000..fe5bba3 --- /dev/null +++ b/crates/lang-java/tests/references_behavior.rs @@ -0,0 +1,230 @@ +mod common; + +use common::{offset_to_point, setup_java_test_graph}; +use naviscope_api::models::symbol::Range; +use naviscope_core::features::discovery::DiscoveryEngine; +use naviscope_java::JavaPlugin; +use naviscope_plugin::{LspSyntaxService, SymbolQueryService, SymbolResolveService}; +use std::collections::BTreeSet; + +fn starts_set(ranges: &[Range]) -> BTreeSet<(usize, usize)> { + ranges.iter().map(|r| (r.start_line, r.start_col)).collect() +} + +fn find_ranges_for_path( + resolver: &JavaPlugin, + index: &naviscope_core::model::CodeGraph, + trees: &[(std::path::PathBuf, String, tree_sitter::Tree)], + resolution: &naviscope_api::models::SymbolResolution, + path: &str, +) -> Vec { + let (.., source, tree) = trees + .iter() + .find(|(p, _, _)| p.to_string_lossy() == path) + .expect("path exists in trees"); + resolver.find_occurrences(source, tree, resolution, Some(index)) +} + +#[test] +fn given_same_method_name_different_owner_when_find_references_then_only_target_owner_hits() { + let files = vec![ + ("A.java", "public class A { void target() {} }"), + ("B.java", "public class B { void target() {} }"), + ( + "Use.java", + "public class Use { void run(A a, B b) { a.target(); b.target(); } }", + ), + ]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let a_content = &trees[0].1; + let a_tree = &trees[0].2; + let target_pos = a_content.find("target()").expect("find A#target"); + let (line, col) = offset_to_point(a_content, target_pos); + + let resolution = resolver + .resolve_at(a_tree, a_content, line, col, &index) + .expect("resolve A#target"); + + let a_ranges = find_ranges_for_path(&resolver, &index, &trees, &resolution, "A.java"); + let b_ranges = find_ranges_for_path(&resolver, &index, &trees, &resolution, "B.java"); + let use_ranges = find_ranges_for_path(&resolver, &index, &trees, &resolution, "Use.java"); + + let a_decl_pos = a_content.find("target()").expect("find A declaration"); + let a_decl_start = offset_to_point(a_content, a_decl_pos); + + let use_content = &trees[2].1; + let use_call_pos = use_content.find("a.target()").expect("find a.target()") + 2; + let use_call_start = offset_to_point(use_content, use_call_pos); + + assert_eq!(starts_set(&a_ranges), BTreeSet::from([a_decl_start])); + assert!(b_ranges.is_empty(), "B.java should not be polluted by same-name method"); + assert_eq!(starts_set(&use_ranges), BTreeSet::from([use_call_start])); +} + +#[test] +fn given_local_shadowing_when_find_references_then_only_same_binding_hits() { + let files = vec![( + "Test.java", + "public class Test { int x = 0; void run() { int x = 1; x = 2; } }", + )]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let content = &trees[0].1; + let tree = &trees[0].2; + let usage_pos = content.find("x = 2").expect("find local usage"); + let (line, col) = offset_to_point(content, usage_pos); + + let resolution = resolver + .resolve_at(tree, content, line, col, &index) + .expect("resolve local x"); + + let ranges = find_ranges_for_path(&resolver, &index, &trees, &resolution, "Test.java"); + let starts = starts_set(&ranges); + let decl_pos = content.find("int x = 1").expect("find local declaration") + 4; + let assign_pos = content.find("x = 2").expect("find local assign"); + let expected = BTreeSet::from([ + offset_to_point(content, decl_pos), + offset_to_point(content, assign_pos), + ]); + assert_eq!(starts, expected, "local x should only match declaration + assignment"); + + let matches = resolver.find_matches(&index, &resolution); + assert!( + matches.is_empty(), + "local symbol should not map to global graph nodes" + ); +} + +#[test] +fn given_method_symbol_when_scouting_references_then_candidate_files_cover_call_sites() { + let files = vec![ + ("A.java", "public class A { public void target() {} }"), + ("B.java", "public class B { void m1(A a) { a.target(); } }"), + ("C.java", "public class C { void m2(A a) { a.target(); } }"), + ]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let a_content = &trees[0].1; + let a_tree = &trees[0].2; + let usage_pos = a_content.find("target()").expect("find declaration"); + let (line, col) = offset_to_point(a_content, usage_pos); + + let resolution = resolver + .resolve_at(a_tree, a_content, line, col, &index) + .expect("resolve method"); + let matches = resolver.find_matches(&index, &resolution); + let target_fqn = matches[0]; + let target_idx = *index.fqn_map().get(&target_fqn).expect("node exists"); + + let discovery = DiscoveryEngine::new(&index, std::collections::HashMap::new()); + let candidate_files = discovery.scout_references(&[target_idx]); + + let paths: Vec = candidate_files + .iter() + .map(|p| p.to_string_lossy().to_string()) + .collect(); + + assert_eq!(paths.len(), 3); + assert!(paths.contains(&"A.java".to_string())); + assert!(paths.contains(&"B.java".to_string())); + assert!(paths.contains(&"C.java".to_string())); +} + +#[test] +fn given_overloaded_methods_same_owner_when_find_references_then_matches_all_name_level_overloads() { + let files = vec![ + ( + "A.java", + "public class A { void target(int n) {} void target(String s) {} }", + ), + ( + "Use.java", + "public class Use { void run(A a) { a.target(1); a.target(\"x\"); } }", + ), + ]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let a_content = &trees[0].1; + let a_tree = &trees[0].2; + let target_pos = a_content.find("target(int").expect("find overloaded declaration"); + let (line, col) = offset_to_point(a_content, target_pos); + + let resolution = resolver + .resolve_at(a_tree, a_content, line, col, &index) + .expect("resolve overloaded method"); + + let a_ranges = find_ranges_for_path(&resolver, &index, &trees, &resolution, "A.java"); + let use_ranges = find_ranges_for_path(&resolver, &index, &trees, &resolution, "Use.java"); + + let a_decl_int = a_content.find("target(int n)").expect("find target(int)"); + let a_decl_str = a_content + .find("target(String s)") + .expect("find target(String)"); + let use_content = &trees[1].1; + let use_call_int = use_content.find("a.target(1)").expect("find call int") + 2; + let use_call_str = use_content.find("a.target(\"x\")").expect("find call str") + 2; + + assert_eq!( + starts_set(&a_ranges), + BTreeSet::from([ + offset_to_point(a_content, a_decl_int), + offset_to_point(a_content, a_decl_str), + ]) + ); + assert_eq!( + starts_set(&use_ranges), + BTreeSet::from([ + offset_to_point(use_content, use_call_int), + offset_to_point(use_content, use_call_str), + ]) + ); +} + +#[test] +fn given_same_class_different_arity_overloads_when_find_references_then_collects_decls_and_calls() { + let files = vec![( + "A.java", + "public class A { void target() { target(1); target(1, 2); } void target(int a) {} void target(int a, int b) {} }", + )]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let content = &trees[0].1; + let tree = &trees[0].2; + let pos = content.find("target()").expect("find zero-arity declaration"); + let (line, col) = offset_to_point(content, pos); + + let resolution = resolver + .resolve_at(tree, content, line, col, &index) + .expect("resolve overloaded member"); + + let ranges = find_ranges_for_path(&resolver, &index, &trees, &resolution, "A.java"); + let starts = starts_set(&ranges); + + let decl_zero = content.find("target() {").expect("find target()"); + let call_one = content.find("target(1);").expect("find target(1)"); + let call_two = content.find("target(1, 2);").expect("find target(1,2)"); + let decl_one = content.find("target(int a)").expect("find target(int)"); + let decl_two = content + .find("target(int a, int b)") + .expect("find target(int,int)"); + + let expected = BTreeSet::from([ + offset_to_point(content, decl_zero), + offset_to_point(content, call_one), + offset_to_point(content, call_two), + offset_to_point(content, decl_one), + offset_to_point(content, decl_two), + ]); + assert_eq!(starts, expected); +} diff --git a/crates/lang-java/tests/test_engine_facade.rs b/crates/lang-java/tests/test_engine_facade.rs index 5283609..31a535d 100644 --- a/crates/lang-java/tests/test_engine_facade.rs +++ b/crates/lang-java/tests/test_engine_facade.rs @@ -2,7 +2,10 @@ mod common; use common::{offset_to_point, setup_java_engine}; use naviscope_api::models::{PositionContext, ReferenceQuery, SymbolQuery, SymbolResolution}; -use naviscope_api::semantic::{CallHierarchyAnalyzer, ReferenceAnalyzer, SymbolNavigator}; +use naviscope_api::semantic::{ + CallHierarchyAnalyzer, ReferenceAnalyzer, SymbolInfoProvider, SymbolNavigator, +}; +use std::collections::BTreeSet; #[tokio::test] async fn test_full_engine_java_facade() { @@ -321,3 +324,120 @@ public class Use { "Reference should belong to Base.ping(), not Child.ping()" ); } + +#[tokio::test] +async fn test_find_references_same_class_overloads_different_arity_via_engine_facade() { + let temp_dir = std::env::temp_dir().join("naviscope_java_ref_overload_arity_test"); + if temp_dir.exists() { + let _ = std::fs::remove_dir_all(&temp_dir); + } + std::fs::create_dir_all(&temp_dir).unwrap(); + + let files = vec![( + "com/example/A.java", + "package com.example; public class A { void target() { target(1); target(1, 2); } void target(int a) {} void target(int a, int b) {} }", + )]; + + let handle = setup_java_engine(&temp_dir, files).await; + + let a_path = temp_dir.join("com/example/A.java"); + let content = std::fs::read_to_string(&a_path).unwrap(); + let pos = content.find("target() {").unwrap(); + let (line, col) = offset_to_point(&content, pos); + + let ctx = PositionContext { + uri: format!("file://{}", a_path.display()), + line: line as u32, + char: col as u32, + content: Some(content.clone()), + }; + + let resolution = handle + .resolve_symbol_at(&ctx) + .await + .unwrap() + .expect("Should resolve A#target"); + + let refs = handle + .find_references(&ReferenceQuery { + language: naviscope_api::models::Language::JAVA, + resolution, + include_declaration: true, + }) + .await + .unwrap(); + + assert_eq!(refs.len(), 5, "expected declarations + call sites for same-name overloads"); + assert!( + refs.iter().all(|r| r.path.to_string_lossy().contains("com/example/A.java")), + "all references should stay in A.java for this scenario" + ); + + let starts: BTreeSet<(usize, usize)> = refs + .iter() + .map(|r| (r.range.start_line, r.range.start_col)) + .collect(); + let expected: BTreeSet<(usize, usize)> = [ + content.find("target() {").unwrap(), + content.find("target(1);").unwrap(), + content.find("target(1, 2);").unwrap(), + content.find("target(int a) {}").unwrap(), + content.find("target(int a, int b) {}").unwrap(), + ] + .into_iter() + .map(|offset| offset_to_point(&content, offset)) + .collect(); + assert_eq!(starts, expected); +} + +#[tokio::test] +async fn test_resolve_method_declaration_name_and_symbol_info_for_overload_hover_input() { + let temp_dir = std::env::temp_dir().join("naviscope_java_hover_decl_overload_test"); + if temp_dir.exists() { + let _ = std::fs::remove_dir_all(&temp_dir); + } + std::fs::create_dir_all(&temp_dir).unwrap(); + + let files = vec![( + "com/example/A.java", + "package com.example; public class A { void target(int a) {} void target(int a, int b) {} }", + )]; + + let handle = setup_java_engine(&temp_dir, files).await; + + let a_path = temp_dir.join("com/example/A.java"); + let content = std::fs::read_to_string(&a_path).unwrap(); + let pos = content.find("target(int a)").unwrap(); + let (line, col) = offset_to_point(&content, pos); + + let ctx = PositionContext { + uri: format!("file://{}", a_path.display()), + line: line as u32, + char: col as u32, + content: Some(content.clone()), + }; + + let resolution = handle + .resolve_symbol_at(&ctx) + .await + .unwrap() + .expect("Should resolve method declaration name"); + + let fqn = match resolution { + SymbolResolution::Precise(fqn, _) | SymbolResolution::Global(fqn) => fqn, + SymbolResolution::Local(_, _) => panic!("method declaration should not resolve as local"), + }; + assert_eq!(fqn, "com.example.A#target"); + + let info = handle + .get_symbol_info(&fqn) + .await + .unwrap() + .expect("symbol info should exist for hover input"); + + let sig = info.signature.unwrap_or_default(); + assert!( + sig.contains("target("), + "signature should be suitable for hover at declaration name" + ); +} diff --git a/crates/lang-java/tests/type_definition_behavior.rs b/crates/lang-java/tests/type_definition_behavior.rs new file mode 100644 index 0000000..8521f94 --- /dev/null +++ b/crates/lang-java/tests/type_definition_behavior.rs @@ -0,0 +1,80 @@ +mod common; + +use common::{offset_to_point, setup_java_test_graph}; +use naviscope_core::features::CodeGraphLike; +use naviscope_java::JavaPlugin; +use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; + +#[test] +fn given_variable_usage_when_goto_type_definition_then_returns_declared_type() { + let files = vec![ + ("Model.java", "public class Model {}"), + ( + "Client.java", + "public class Client { void run() { Model model = null; } }", + ), + ]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let content = &trees[1].1; + let tree = &trees[1].2; + let pos = content.find("model = null").expect("find variable usage"); + let (line, col) = offset_to_point(content, pos); + + let resolution = resolver + .resolve_at(tree, content, line, col, &index) + .expect("resolve variable"); + let type_resolutions = resolver.resolve_type_of(&index, &resolution); + + assert_eq!(type_resolutions.len(), 1); + let matches = resolver.find_matches(&index, &type_resolutions[0]); + assert_eq!(matches.len(), 1); + + let idx = *index.fqn_map().get(&matches[0]).expect("node exists"); + assert_eq!( + index.render_fqn( + &index.topology()[idx], + Some(&naviscope_java::naming::JavaNamingConvention) + ), + "Model" + ); +} + +#[test] +fn given_method_symbol_when_goto_type_definition_then_returns_method_return_type() { + let files = vec![ + ("Model.java", "public class Model {}"), + ( + "Service.java", + "public class Service { Model get() { return null; } }", + ), + ]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let content = &trees[1].1; + let tree = &trees[1].2; + let pos = content.find("get()").expect("find method symbol"); + let (line, col) = offset_to_point(content, pos); + + let resolution = resolver + .resolve_at(tree, content, line, col, &index) + .expect("resolve method"); + let type_resolutions = resolver.resolve_type_of(&index, &resolution); + + assert_eq!(type_resolutions.len(), 1); + let matches = resolver.find_matches(&index, &type_resolutions[0]); + assert_eq!(matches.len(), 1); + + let idx = *index.fqn_map().get(&matches[0]).expect("node exists"); + assert_eq!( + index.render_fqn( + &index.topology()[idx], + Some(&naviscope_java::naming::JavaNamingConvention) + ), + "Model" + ); +} From 8abb74fab034be5908871eef8ca83a8255413fce Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Fri, 13 Feb 2026 22:16:49 +0800 Subject: [PATCH 36/49] refactor(ingest): adopt event-driven runtime with two-channel bus and integration tests --- Cargo.lock | 13 + Cargo.toml | 2 + crates/ingest/Cargo.toml | 13 + crates/ingest/README.md | 137 ++++++++++ crates/ingest/src/error.rs | 15 ++ crates/ingest/src/lib.rs | 18 ++ crates/ingest/src/runtime/kernel.rs | 322 +++++++++++++++++++++++ crates/ingest/src/runtime/mod.rs | 212 ++++++++++++++++ crates/ingest/src/traits.rs | 30 +++ crates/ingest/src/types.rs | 108 ++++++++ crates/ingest/tests/runtime_kernel.rs | 352 ++++++++++++++++++++++++++ 11 files changed, 1222 insertions(+) create mode 100644 crates/ingest/Cargo.toml create mode 100644 crates/ingest/README.md create mode 100644 crates/ingest/src/error.rs create mode 100644 crates/ingest/src/lib.rs create mode 100644 crates/ingest/src/runtime/kernel.rs create mode 100644 crates/ingest/src/runtime/mod.rs create mode 100644 crates/ingest/src/traits.rs create mode 100644 crates/ingest/src/types.rs create mode 100644 crates/ingest/tests/runtime_kernel.rs diff --git a/Cargo.lock b/Cargo.lock index 544bc52..24252f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1541,6 +1541,19 @@ dependencies = [ "walkdir", ] +[[package]] +name = "naviscope-ingest" +version = "0.1.0" +dependencies = [ + "async-trait", + "dashmap 6.1.0", + "naviscope-api", + "rayon", + "thiserror", + "tokio", + "tracing", +] + [[package]] name = "naviscope-java" version = "0.5.5" diff --git a/Cargo.toml b/Cargo.toml index 920c111..84d430c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "crates/core", + "crates/ingest", "crates/lang-java", "crates/lang-gradle", "crates/cli", @@ -14,6 +15,7 @@ members = [ [workspace.dependencies] naviscope-core = { path = "crates/core" } +naviscope-ingest = { path = "crates/ingest" } naviscope-java = { path = "crates/lang-java" } naviscope-gradle = { path = "crates/lang-gradle" } naviscope-lsp = { path = "crates/lsp" } diff --git a/crates/ingest/Cargo.toml b/crates/ingest/Cargo.toml new file mode 100644 index 0000000..8b3348d --- /dev/null +++ b/crates/ingest/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "naviscope-ingest" +version = "0.1.0" +edition = "2024" + +[dependencies] +thiserror = { workspace = true } +tokio = { workspace = true } +rayon = { workspace = true } +tracing = { workspace = true } +dashmap = { workspace = true } +naviscope-api = { workspace = true } +async-trait = { workspace = true } diff --git a/crates/ingest/README.md b/crates/ingest/README.md new file mode 100644 index 0000000..7855b02 --- /dev/null +++ b/crates/ingest/README.md @@ -0,0 +1,137 @@ +# naviscope-ingest + +`naviscope-ingest` is a resident, event-driven runtime for dependency-aware message processing. + +## Design Goals + +- Keep domain semantics in `core`; keep orchestration in `ingest`. +- Support multi-producer push ingestion with bounded backpressure. +- Model dependency handling as data (`depends_on`) and deferred replay. +- Provide deterministic commit boundaries (`epoch`) with external commit policy. + +## Scope + +In scope: + +- Message intake and bounded queueing. +- Event-driven schedule/execute/commit orchestration. +- Deferred persistence and replay trigger path. +- Runtime metrics hooks. + +Out of scope: + +- Domain/business rule evaluation. +- Source scanning / incremental detection / build-source partitioning. +- Storage engine internals. + +## Core Architecture + +The runtime is centered around: + +- `IngestRuntime` +- `RuntimeComponents` +- `PipelineEvent` +- `PipelineBus` with **two channels**: +- `intake` channel for ingress + replayed messages +- `deferred` channel for unresolved messages + +```mermaid +flowchart LR + EP[External Producers] --> IH[IntakeHandle::submit] + IH --> IQ[Intake Channel] + + IQ --> SCH[Scheduler] + SCH -->|Runnable| EXE[Executor] + SCH -->|Deferred| DQ[Deferred Channel] + + EXE -->|Executed| COM[CommitSink] + EXE -->|Deferred| DQ + EXE -->|Fatal| ERR[Runtime Error] + + DQ --> DS[DeferredStore::push] + DS --> RP[Replay Loop pop_ready] + RP --> IQ +``` + +## Event Model + +`PipelineEvent` is the runtime contract between components: + +- `Runnable(Message

)` +- `Deferred(Message

)` +- `Executed { epoch, result }` +- `Fatal { msg_id, error }` + +This keeps routing explicit and avoids stage-specific output structs. + +## Component Model + +Runtime uses object-safe component interfaces: + +- `Scheduler` +- `Executor` +- `DeferredStore

` +- `CommitSink` +- `RuntimeMetrics` +- `PipelineBus` + +They are assembled through `RuntimeComponents`. + +## Public API + +- `IngestRuntime::new(config, components)` +- `IngestRuntime::intake_handle() -> IntakeHandle

` +- `IngestRuntime::notify_dependency_ready(event)` +- `IngestRuntime::run_forever()` + +### Ingestion + +External code pushes messages through: + +- `IntakeHandle::submit(message).await` + +### Dependency Ready Notification + +External dependency resolution notifies runtime through: + +- `notify_dependency_ready(DependencyReadyEvent)` + +This unblocks replay via `DeferredStore`. + +## Runtime Flow + +1. Producers push `Message

` into intake channel. +2. Kernel batches intake messages and calls `Scheduler`. +3. `Scheduler` emits `PipelineEvent` values. +4. `Runnable` messages are batched and executed by `Executor` (rayon parallel path). +5. `Executed` events are grouped by epoch and sent to `CommitSink`. +6. `Deferred` events go to deferred channel and are persisted by deferred sink. +7. Replay loop polls `DeferredStore::pop_ready(limit)` and re-enqueues ready messages. + +## Backpressure and Memory + +- Both channels are bounded by `kernel_channel_capacity`. +- No extra ingress queue beyond bus channels. +- Deferred replay is pulled with `deferred_poll_limit`, limiting replay burst. + +## RuntimeConfig + +- `deferred_poll_limit`: max replay load per poll cycle. +- `kernel_channel_capacity`: channel capacity for intake/deferred. +- `schedule_batch_size`: scheduler batch size. +- `execute_batch_size`: executor batch size. +- `idle_sleep_ms`: replay loop sleep when no ready deferred message exists. + +## Correctness Assumptions + +- At-least-once processing; consumers should be idempotent. +- `DeferredStore` handles dedup/retry/readiness semantics. +- `CommitSink` defines commit visibility and idempotency policy. + +## Source Layout + +- `src/runtime/mod.rs`: runtime assembly, handles, lifecycle. +- `src/runtime/kernel.rs`: event routing kernel + bus + worker loops. +- `src/traits.rs`: component contracts. +- `src/types.rs`: message/event/domain-neutral runtime types. +- `src/error.rs`: runtime error types. diff --git a/crates/ingest/src/error.rs b/crates/ingest/src/error.rs new file mode 100644 index 0000000..577e2fc --- /dev/null +++ b/crates/ingest/src/error.rs @@ -0,0 +1,15 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum IngestError { + #[error("invalid message: {0}")] + InvalidMessage(String), + #[error("dependency resolution failed: {0}")] + Dependency(String), + #[error("execution failed: {0}")] + Execution(String), + #[error("commit failed: {0}")] + Commit(String), + #[error("storage failed: {0}")] + Storage(String), +} diff --git a/crates/ingest/src/lib.rs b/crates/ingest/src/lib.rs new file mode 100644 index 0000000..2e2b19b --- /dev/null +++ b/crates/ingest/src/lib.rs @@ -0,0 +1,18 @@ +pub mod error; +pub mod runtime; +pub mod traits; +pub mod types; + +pub use error::IngestError; +pub use runtime::{ + DynCommitSink, DynDeferredStore, DynExecutor, DynPipelineBus, DynRuntimeMetrics, + DynScheduler, IngestRuntime, IntakeHandle, KernelConfig, PipelineBus, RuntimeComponents, + TokioPipelineBus, +}; +pub use traits::{ + CommitSink, DeferredStore, Executor, RuntimeMetrics, Scheduler, +}; +pub use types::{ + DependencyKind, DependencyReadyEvent, DependencyRef, ExecutionResult, ExecutionStatus, Message, + MessageGroup, MessageId, OperationBatch, PipelineEvent, ResourceKey, RuntimeConfig, Topic, +}; diff --git a/crates/ingest/src/runtime/kernel.rs b/crates/ingest/src/runtime/kernel.rs new file mode 100644 index 0000000..657efc8 --- /dev/null +++ b/crates/ingest/src/runtime/kernel.rs @@ -0,0 +1,322 @@ +use std::collections::BTreeMap; +use std::sync::Arc; + +use rayon::prelude::*; +use tokio::sync::mpsc; +use tracing::warn; + +use crate::error::IngestError; +use crate::traits::{CommitSink, DeferredStore, Executor, RuntimeMetrics, Scheduler}; +use crate::types::{ExecutionResult, Message, PipelineEvent, RuntimeConfig}; + +pub struct BusChannels

+where + P: Clone + Send + Sync + 'static, +{ + pub intake_tx: mpsc::Sender>, + pub intake_rx: mpsc::Receiver>, + pub deferred_tx: mpsc::Sender>, + pub deferred_rx: mpsc::Receiver>, +} + +pub trait PipelineBus: Send + Sync +where + P: Clone + Send + Sync + 'static, + Op: Send + Sync + 'static, +{ + fn open_channels(&self, capacity: usize) -> BusChannels

; +} + +#[derive(Default)] +pub struct TokioPipelineBus; + +impl PipelineBus for TokioPipelineBus +where + P: Clone + Send + Sync + 'static, + Op: Send + Sync + 'static, +{ + fn open_channels(&self, capacity: usize) -> BusChannels

{ + let cap = capacity.max(1); + let (intake_tx, intake_rx) = mpsc::channel::>(cap); + let (deferred_tx, deferred_rx) = mpsc::channel::>(cap); + + BusChannels { + intake_tx, + intake_rx, + deferred_tx, + deferred_rx, + } + } +} + +#[derive(Clone)] +pub struct KernelConfig { + pub channel_capacity: usize, + pub schedule_batch_size: usize, + pub execute_batch_size: usize, + pub idle_sleep_ms: u64, +} + +impl Default for KernelConfig { + fn default() -> Self { + Self { + channel_capacity: 256, + schedule_batch_size: 256, + execute_batch_size: 256, + idle_sleep_ms: 10, + } + } +} + +impl From<&RuntimeConfig> for KernelConfig { + fn from(value: &RuntimeConfig) -> Self { + Self { + channel_capacity: value.kernel_channel_capacity, + schedule_batch_size: value.schedule_batch_size, + execute_batch_size: value.execute_batch_size, + idle_sleep_ms: value.idle_sleep_ms, + } + } +} + +#[derive(Debug, Default)] +pub struct KernelRunStats { + pub runnable_messages: usize, + pub deferred_from_schedule: usize, + pub deferred_from_execute: usize, + pub deferred_persisted: usize, + pub committed_batches: usize, +} + +pub async fn run_pipeline( + channels: BusChannels

, + scheduler: Arc, + executor: Arc, + deferred_store: Arc, + commit_sink: Arc, + metrics: Arc, + config: &KernelConfig, +) -> Result +where + P: Clone + Send + Sync + 'static, + Op: Send + Sync + 'static, + SCH: Scheduler + Send + Sync + 'static + ?Sized, + EX: Executor + Send + Sync + 'static + ?Sized, + DS: DeferredStore

+ Send + Sync + 'static + ?Sized, + C: CommitSink + Send + Sync + 'static + ?Sized, + RM: RuntimeMetrics + Send + Sync + 'static + ?Sized, +{ + let BusChannels { + intake_tx, + mut intake_rx, + deferred_tx, + mut deferred_rx, + } = channels; + drop(intake_tx); + + let deferred_handle = { + let store = Arc::clone(&deferred_store); + tokio::spawn(async move { + let mut persisted = 0usize; + while let Some(msg) = deferred_rx.recv().await { + let store_cloned = Arc::clone(&store); + tokio::task::spawn_blocking(move || store_cloned.push(msg)) + .await + .map_err(|e| { + IngestError::Execution(format!("deferred sink join failure: {e}")) + })??; + persisted += 1; + } + Ok::(persisted) + }) + }; + + let schedule_batch_size = config.schedule_batch_size.max(1); + let execute_batch_size = config.execute_batch_size.max(1); + let mut stats = KernelRunStats::default(); + let mut schedule_batch = Vec::with_capacity(schedule_batch_size); + let mut execute_batch = Vec::with_capacity(execute_batch_size); + + while let Some(msg) = intake_rx.recv().await { + schedule_batch.push(msg); + if schedule_batch.len() < schedule_batch_size { + continue; + } + + let scheduler_cloned = Arc::clone(&scheduler); + let input = std::mem::take(&mut schedule_batch); + let schedule_events = tokio::task::spawn_blocking(move || scheduler_cloned.schedule(input)) + .await + .map_err(|e| IngestError::Execution(format!("schedule join failure: {e}")))??; + + for event in schedule_events { + match event { + PipelineEvent::Runnable(msg) => { + stats.runnable_messages += 1; + execute_batch.push(msg); + } + PipelineEvent::Deferred(msg) => { + stats.deferred_from_schedule += 1; + deferred_tx.send(msg).await.map_err(|_| { + IngestError::Execution("kernel deferred channel closed".to_string()) + })?; + } + _ => { + return Err(IngestError::Execution( + "scheduler emitted invalid event".to_string(), + )); + } + } + } + + if execute_batch.len() < execute_batch_size { + continue; + } + + let executor_cloned = Arc::clone(&executor); + let input = std::mem::take(&mut execute_batch); + let events_by_msg = tokio::task::spawn_blocking(move || { + let raw: Vec>, IngestError>> = + input.into_par_iter().map(|m| executor_cloned.execute(m)).collect(); + let mut out = Vec::with_capacity(raw.len()); + for item in raw { + out.push(item?); + } + Ok::>>, IngestError>(out) + }) + .await + .map_err(|e| IngestError::Execution(format!("execute join failure: {e}")))??; + + let mut by_epoch: BTreeMap>> = BTreeMap::new(); + for events in events_by_msg { + for event in events { + match event { + PipelineEvent::Executed { epoch, result } => { + by_epoch.entry(epoch).or_default().push(result); + } + PipelineEvent::Deferred(msg) => { + stats.deferred_from_execute += 1; + deferred_tx.send(msg).await.map_err(|_| { + IngestError::Execution("kernel deferred channel closed".to_string()) + })?; + } + PipelineEvent::Fatal { msg_id, error } => { + let emsg = error.unwrap_or_else(|| "unknown fatal error".to_string()); + warn!("fatal execute event for {msg_id}: {emsg}"); + return Err(IngestError::Execution(format!( + "fatal execute event for {msg_id}: {emsg}" + ))); + } + PipelineEvent::Runnable(_) => { + return Err(IngestError::Execution( + "executor emitted invalid event".to_string(), + )); + } + } + } + } + + for (epoch, results) in by_epoch { + let sink = Arc::clone(&commit_sink); + let committed = tokio::task::spawn_blocking(move || sink.commit_epoch(epoch, results)) + .await + .map_err(|e| IngestError::Execution(format!("commit join failure: {e}")))??; + stats.committed_batches += committed; + } + } + + if !schedule_batch.is_empty() { + let scheduler_cloned = Arc::clone(&scheduler); + let input = std::mem::take(&mut schedule_batch); + let schedule_events = tokio::task::spawn_blocking(move || scheduler_cloned.schedule(input)) + .await + .map_err(|e| IngestError::Execution(format!("schedule join failure: {e}")))??; + + for event in schedule_events { + match event { + PipelineEvent::Runnable(msg) => { + stats.runnable_messages += 1; + execute_batch.push(msg); + } + PipelineEvent::Deferred(msg) => { + stats.deferred_from_schedule += 1; + deferred_tx.send(msg).await.map_err(|_| { + IngestError::Execution("kernel deferred channel closed".to_string()) + })?; + } + _ => { + return Err(IngestError::Execution( + "scheduler emitted invalid event".to_string(), + )); + } + } + } + } + + if !execute_batch.is_empty() { + let executor_cloned = Arc::clone(&executor); + let input = std::mem::take(&mut execute_batch); + let events_by_msg = tokio::task::spawn_blocking(move || { + let raw: Vec>, IngestError>> = + input.into_par_iter().map(|m| executor_cloned.execute(m)).collect(); + let mut out = Vec::with_capacity(raw.len()); + for item in raw { + out.push(item?); + } + Ok::>>, IngestError>(out) + }) + .await + .map_err(|e| IngestError::Execution(format!("execute join failure: {e}")))??; + + let mut by_epoch: BTreeMap>> = BTreeMap::new(); + for events in events_by_msg { + for event in events { + match event { + PipelineEvent::Executed { epoch, result } => { + by_epoch.entry(epoch).or_default().push(result); + } + PipelineEvent::Deferred(msg) => { + stats.deferred_from_execute += 1; + deferred_tx.send(msg).await.map_err(|_| { + IngestError::Execution("kernel deferred channel closed".to_string()) + })?; + } + PipelineEvent::Fatal { msg_id, error } => { + let emsg = error.unwrap_or_else(|| "unknown fatal error".to_string()); + warn!("fatal execute event for {msg_id}: {emsg}"); + return Err(IngestError::Execution(format!( + "fatal execute event for {msg_id}: {emsg}" + ))); + } + PipelineEvent::Runnable(_) => { + return Err(IngestError::Execution( + "executor emitted invalid event".to_string(), + )); + } + } + } + } + + for (epoch, results) in by_epoch { + let sink = Arc::clone(&commit_sink); + let committed = tokio::task::spawn_blocking(move || sink.commit_epoch(epoch, results)) + .await + .map_err(|e| IngestError::Execution(format!("commit join failure: {e}")))??; + stats.committed_batches += committed; + } + } + + drop(deferred_tx); + stats.deferred_persisted = deferred_handle + .await + .map_err(|e| IngestError::Execution(format!("kernel deferred join failure: {e}")))??; + + metrics.observe_queue_depth("kernel_runnable_total", stats.runnable_messages); + metrics.observe_queue_depth( + "kernel_deferred_total", + stats.deferred_from_schedule + stats.deferred_from_execute, + ); + metrics.observe_queue_depth("kernel_deferred_persisted", stats.deferred_persisted); + + Ok(stats) +} diff --git a/crates/ingest/src/runtime/mod.rs b/crates/ingest/src/runtime/mod.rs new file mode 100644 index 0000000..4602804 --- /dev/null +++ b/crates/ingest/src/runtime/mod.rs @@ -0,0 +1,212 @@ +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::{Mutex, mpsc}; + +use crate::error::IngestError; +use crate::traits::{CommitSink, DeferredStore, Executor, RuntimeMetrics, Scheduler}; +use crate::types::{DependencyReadyEvent, Message, RuntimeConfig}; + +pub mod kernel; + +pub use kernel::{KernelConfig, PipelineBus, TokioPipelineBus}; + +pub type DynScheduler = Arc + Send + Sync>; +pub type DynExecutor = Arc + Send + Sync>; +pub type DynDeferredStore

= Arc + Send + Sync>; +pub type DynCommitSink = Arc + Send + Sync>; +pub type DynPipelineBus = Arc + Send + Sync>; +pub type DynRuntimeMetrics = Arc; + +pub struct RuntimeComponents +where + P: Clone + Send + Sync + 'static, + Op: Send + Sync + 'static, +{ + pub scheduler: DynScheduler, + pub executor: DynExecutor, + pub deferred_store: DynDeferredStore

, + pub commit_sink: DynCommitSink, + pub bus: DynPipelineBus, + pub metrics: DynRuntimeMetrics, +} + +impl RuntimeComponents +where + P: Clone + Send + Sync + 'static, + Op: Send + Sync + 'static, +{ + pub fn with_tokio_bus( + scheduler: DynScheduler, + executor: DynExecutor, + deferred_store: DynDeferredStore

, + commit_sink: DynCommitSink, + metrics: DynRuntimeMetrics, + ) -> Self { + Self { + scheduler, + executor, + deferred_store, + commit_sink, + bus: Arc::new(TokioPipelineBus), + metrics, + } + } +} + +#[derive(Clone)] +pub struct IntakeHandle

{ + tx: mpsc::Sender>, +} + +impl

IntakeHandle

+where + P: Clone + Send + Sync + 'static, +{ + pub async fn submit(&self, message: Message

) -> Result<(), IngestError> { + self.tx + .send(message) + .await + .map_err(|_| IngestError::Execution("ingest intake handle closed".to_string())) + } +} + +pub struct IngestRuntime +where + P: Clone + Send + Sync + 'static, + Op: Send + Sync + 'static, +{ + pub scheduler: DynScheduler, + pub executor: DynExecutor, + pub deferred_store: DynDeferredStore

, + pub commit_sink: DynCommitSink, + pub bus: DynPipelineBus, + pub metrics: DynRuntimeMetrics, + pub kernel_config: KernelConfig, + deferred_poll_limit: usize, + intake_tx: mpsc::Sender>, + pipeline_channels: Mutex>>, + _marker: std::marker::PhantomData, +} + +impl IngestRuntime +where + P: Clone + Send + Sync + 'static, + Op: Send + Sync + 'static, +{ + pub fn new(config: RuntimeConfig, components: RuntimeComponents) -> Self { + let kernel_config = KernelConfig::from(&config); + let channels = components.bus.open_channels(kernel_config.channel_capacity); + let intake_tx = channels.intake_tx.clone(); + + Self { + scheduler: components.scheduler, + executor: components.executor, + deferred_store: components.deferred_store, + commit_sink: components.commit_sink, + bus: components.bus, + metrics: components.metrics, + kernel_config, + deferred_poll_limit: config.deferred_poll_limit.max(1), + intake_tx, + pipeline_channels: Mutex::new(Some(channels)), + _marker: std::marker::PhantomData, + } + } + + pub fn intake_handle(&self) -> IntakeHandle

{ + IntakeHandle { + tx: self.intake_tx.clone(), + } + } + + pub async fn notify_dependency_ready( + &self, + event: DependencyReadyEvent, + ) -> Result<(), IngestError> { + let store = Arc::clone(&self.deferred_store); + tokio::task::spawn_blocking(move || store.notify_ready(event)) + .await + .map_err(|e| IngestError::Execution(format!("notify task join failure: {e}")))? + } + + pub async fn run_forever(&self) -> Result<(), IngestError> { + let channels = self + .pipeline_channels + .lock() + .await + .take() + .ok_or_else(|| IngestError::Execution("runtime already started".to_string()))?; + let replay_tx = self.intake_tx.clone(); + + let deferred_store = Arc::clone(&self.deferred_store); + let deferred_poll_limit = self.deferred_poll_limit; + let replay_task = { + let idle_sleep = Duration::from_millis(self.kernel_config.idle_sleep_ms.max(1)); + tokio::spawn(async move { + loop { + let store = Arc::clone(&deferred_store); + let ready = + tokio::task::spawn_blocking(move || store.pop_ready(deferred_poll_limit)) + .await + .map_err(|e| { + IngestError::Execution(format!( + "deferred replay join failure: {e}" + )) + })??; + + if ready.is_empty() { + tokio::time::sleep(idle_sleep).await; + continue; + } + for msg in ready { + replay_tx.send(msg).await.map_err(|_| { + IngestError::Execution( + "deferred replay failed: intake channel closed".to_string(), + ) + })?; + } + } + #[allow(unreachable_code)] + Ok::<(), IngestError>(()) + }) + }; + + let kernel_config = self.kernel_config.clone(); + let scheduler = Arc::clone(&self.scheduler); + let executor = Arc::clone(&self.executor); + let deferred_store = Arc::clone(&self.deferred_store); + let commit_sink = Arc::clone(&self.commit_sink); + let metrics = Arc::clone(&self.metrics); + let kernel_task = tokio::spawn(async move { + kernel::run_pipeline( + channels, + scheduler, + executor, + deferred_store, + commit_sink, + metrics, + &kernel_config, + ) + .await + }); + + tokio::select! { + res = replay_task => { + res.map_err(|e| IngestError::Execution(format!("replay task join failure: {e}")))??; + Err(IngestError::Execution("replay task exited unexpectedly".to_string())) + } + res = kernel_task => { + let stats = res.map_err(|e| IngestError::Execution(format!("kernel task join failure: {e}")))??; + Err(IngestError::Execution(format!( + "kernel task exited unexpectedly (runnable={}, deferred_sched={}, deferred_exec={}, deferred_persisted={}, committed={})", + stats.runnable_messages, + stats.deferred_from_schedule, + stats.deferred_from_execute, + stats.deferred_persisted, + stats.committed_batches + ))) + } + } + } +} diff --git a/crates/ingest/src/traits.rs b/crates/ingest/src/traits.rs new file mode 100644 index 0000000..0382e72 --- /dev/null +++ b/crates/ingest/src/traits.rs @@ -0,0 +1,30 @@ +use crate::error::IngestError; +use crate::types::{ + DependencyReadyEvent, ExecutionResult, Message, PipelineEvent, +}; + +pub trait Scheduler: Send + Sync { + fn schedule(&self, messages: Vec>) -> Result>, IngestError>; +} + +pub trait Executor: Send + Sync { + fn execute(&self, message: Message

) -> Result>, IngestError>; +} + +pub trait DeferredStore

: Send + Sync { + fn push(&self, message: Message

) -> Result<(), IngestError>; + fn pop_ready(&self, limit: usize) -> Result>, IngestError>; + fn notify_ready(&self, event: DependencyReadyEvent) -> Result<(), IngestError>; +} + +pub trait CommitSink: Send + Sync { + fn commit_epoch(&self, epoch: u64, results: Vec>) + -> Result; +} + +pub trait RuntimeMetrics: Send + Sync { + fn observe_queue_depth(&self, queue: &'static str, depth: usize); + fn observe_throughput(&self, stage: &'static str, count: usize); + fn observe_latency_ms(&self, stage: &'static str, p95_ms: u64, p99_ms: u64); + fn observe_replay_result(&self, ok: bool); +} diff --git a/crates/ingest/src/types.rs b/crates/ingest/src/types.rs new file mode 100644 index 0000000..43a1eb4 --- /dev/null +++ b/crates/ingest/src/types.rs @@ -0,0 +1,108 @@ +use std::collections::BTreeMap; + +pub type MessageId = String; +pub type Topic = String; +pub type MessageGroup = String; +pub type ResourceKey = String; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DependencyKind { + Message, + Resource, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct DependencyRef { + pub kind: DependencyKind, + pub target: String, + pub min_version: Option, +} + +impl DependencyRef { + pub fn message(msg_id: impl Into) -> Self { + Self { + kind: DependencyKind::Message, + target: msg_id.into(), + min_version: None, + } + } + + pub fn resource(resource_key: impl Into, min_version: Option) -> Self { + Self { + kind: DependencyKind::Resource, + target: resource_key.into(), + min_version, + } + } +} + +#[derive(Debug, Clone)] +pub struct Message

> { + pub msg_id: MessageId, + pub topic: Topic, + pub message_group: MessageGroup, + pub version: u64, + pub depends_on: Vec, + pub epoch: u64, + pub payload: P, + pub metadata: BTreeMap, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExecutionStatus { + Done, + Deferred, + RetryableError, + FatalError, +} + +#[derive(Debug, Clone)] +pub struct ExecutionResult { + pub msg_id: MessageId, + pub status: ExecutionStatus, + pub operations: Vec, + pub next_dependencies: Vec, + pub error: Option, +} + +#[derive(Debug, Clone)] +pub struct OperationBatch { + pub batch_id: String, + pub epoch: u64, + pub operations: Vec, + pub source_msgs: Vec, +} + +#[derive(Debug, Clone)] +pub struct DependencyReadyEvent { + pub dependency: DependencyRef, +} + +#[derive(Debug, Clone)] +pub enum PipelineEvent { + Runnable(Message

), + Deferred(Message

), + Executed { epoch: u64, result: ExecutionResult }, + Fatal { msg_id: MessageId, error: Option }, +} + +#[derive(Debug, Clone)] +pub struct RuntimeConfig { + pub deferred_poll_limit: usize, + pub kernel_channel_capacity: usize, + pub schedule_batch_size: usize, + pub execute_batch_size: usize, + pub idle_sleep_ms: u64, +} + +impl Default for RuntimeConfig { + fn default() -> Self { + Self { + deferred_poll_limit: 256, + kernel_channel_capacity: 256, + schedule_batch_size: 256, + execute_batch_size: 256, + idle_sleep_ms: 10, + } + } +} diff --git a/crates/ingest/tests/runtime_kernel.rs b/crates/ingest/tests/runtime_kernel.rs new file mode 100644 index 0000000..d90fc87 --- /dev/null +++ b/crates/ingest/tests/runtime_kernel.rs @@ -0,0 +1,352 @@ +use std::collections::BTreeMap; +use std::sync::{Arc, Mutex}; + +use naviscope_ingest::runtime::kernel; +use naviscope_ingest::{ + CommitSink, DeferredStore, Executor, IngestError, IngestRuntime, KernelConfig, PipelineBus, + PipelineEvent, RuntimeComponents, RuntimeConfig, RuntimeMetrics, Scheduler, TokioPipelineBus, +}; +use naviscope_ingest::{DependencyReadyEvent, DependencyRef, ExecutionResult, ExecutionStatus, Message}; + +fn message(id: &str, epoch: u64, payload: u8) -> Message { + Message { + msg_id: id.to_string(), + topic: "t".to_string(), + message_group: "g".to_string(), + version: 1, + depends_on: vec![], + epoch, + payload, + metadata: BTreeMap::new(), + } +} + +struct TestScheduler; +impl Scheduler for TestScheduler { + fn schedule(&self, messages: Vec>) -> Result>, IngestError> { + Ok(messages + .into_iter() + .map(|m| { + if m.payload == 0 { + PipelineEvent::Deferred(m) + } else { + PipelineEvent::Runnable(m) + } + }) + .collect()) + } +} + +struct TestExecutor; +impl Executor for TestExecutor { + fn execute(&self, message: Message) -> Result>, IngestError> { + let event = match message.payload { + 2 => PipelineEvent::Deferred(message), + 3 => PipelineEvent::Fatal { + msg_id: message.msg_id, + error: Some("fatal".to_string()), + }, + _ => PipelineEvent::Executed { + epoch: message.epoch, + result: ExecutionResult { + msg_id: message.msg_id, + status: ExecutionStatus::Done, + operations: vec!["op".to_string()], + next_dependencies: vec![], + error: None, + }, + }, + }; + Ok(vec![event]) + } +} + +#[derive(Default)] +struct TestDeferredStore { + pushed: Mutex>, + notified: Mutex>, + ready: Mutex>>, +} +impl DeferredStore for TestDeferredStore { + fn push(&self, message: Message) -> Result<(), IngestError> { + self.pushed.lock().expect("lock poisoned").push(message.msg_id); + Ok(()) + } + + fn pop_ready(&self, limit: usize) -> Result>, IngestError> { + let mut guard = self.ready.lock().expect("lock poisoned"); + let n = limit.min(guard.len()); + Ok(guard.drain(0..n).collect()) + } + + fn notify_ready(&self, event: DependencyReadyEvent) -> Result<(), IngestError> { + self.notified + .lock() + .expect("lock poisoned") + .push(event.dependency.target); + Ok(()) + } +} + +#[derive(Default)] +struct TestCommitSink { + commits: Mutex>, +} +impl CommitSink for TestCommitSink { + fn commit_epoch( + &self, + epoch: u64, + results: Vec>, + ) -> Result { + let size = results.len(); + self.commits.lock().expect("lock poisoned").push((epoch, size)); + Ok(usize::from(size > 0)) + } +} + +struct TestMetrics; +impl RuntimeMetrics for TestMetrics { + fn observe_queue_depth(&self, _queue: &'static str, _depth: usize) {} + fn observe_throughput(&self, _stage: &'static str, _count: usize) {} + fn observe_latency_ms(&self, _stage: &'static str, _p95_ms: u64, _p99_ms: u64) {} + fn observe_replay_result(&self, _ok: bool) {} +} + +struct InvalidEventScheduler; +impl Scheduler for InvalidEventScheduler { + fn schedule(&self, messages: Vec>) -> Result>, IngestError> { + let m = messages + .into_iter() + .next() + .expect("test should provide one message"); + Ok(vec![PipelineEvent::Executed { + epoch: m.epoch, + result: ExecutionResult { + msg_id: m.msg_id, + status: ExecutionStatus::Done, + operations: vec!["op".to_string()], + next_dependencies: vec![], + error: None, + }, + }]) + } +} + +#[tokio::test] +async fn kernel_commits_runnable_messages() { + let scheduler = Arc::new(TestScheduler); + let executor = Arc::new(TestExecutor); + let store = Arc::new(TestDeferredStore::default()); + let sink = Arc::new(TestCommitSink::default()); + let metrics = Arc::new(TestMetrics); + let bus = TokioPipelineBus; + let channels = >::open_channels(&bus, 8); + let tx = channels.intake_tx.clone(); + + tx.send(message("m1", 7, 1)).await.expect("send should work"); + drop(tx); + + let stats = kernel::run_pipeline( + channels, + scheduler, + executor, + store, + sink.clone(), + metrics, + &KernelConfig { + channel_capacity: 8, + schedule_batch_size: 1, + execute_batch_size: 1, + idle_sleep_ms: 1, + }, + ) + .await + .expect("pipeline should complete"); + + assert_eq!(stats.runnable_messages, 1); + assert_eq!(stats.committed_batches, 1); + assert_eq!(sink.commits.lock().expect("lock poisoned").as_slice(), &[(7, 1)]); +} + +#[tokio::test] +async fn kernel_persists_deferred_from_both_paths() { + let scheduler = Arc::new(TestScheduler); + let executor = Arc::new(TestExecutor); + let store = Arc::new(TestDeferredStore::default()); + let sink = Arc::new(TestCommitSink::default()); + let metrics = Arc::new(TestMetrics); + let bus = TokioPipelineBus; + let channels = >::open_channels(&bus, 8); + let tx = channels.intake_tx.clone(); + + tx.send(message("m_sched_deferred", 1, 0)) + .await + .expect("send should work"); + tx.send(message("m_exec_deferred", 1, 2)) + .await + .expect("send should work"); + drop(tx); + + let stats = kernel::run_pipeline( + channels, + scheduler, + executor, + store.clone(), + sink, + metrics, + &KernelConfig { + channel_capacity: 8, + schedule_batch_size: 1, + execute_batch_size: 1, + idle_sleep_ms: 1, + }, + ) + .await + .expect("pipeline should complete"); + + assert_eq!(stats.deferred_from_schedule, 1); + assert_eq!(stats.deferred_from_execute, 1); + assert_eq!(stats.deferred_persisted, 2); + let pushed = store.pushed.lock().expect("lock poisoned").clone(); + assert!(pushed.contains(&"m_sched_deferred".to_string())); + assert!(pushed.contains(&"m_exec_deferred".to_string())); +} + +#[tokio::test] +async fn runtime_notify_dependency_ready_delegates_to_store() { + let store = Arc::new(TestDeferredStore::default()); + let runtime = IngestRuntime::new( + RuntimeConfig::default(), + RuntimeComponents::with_tokio_bus( + Arc::new(TestScheduler), + Arc::new(TestExecutor), + store.clone(), + Arc::new(TestCommitSink::default()), + Arc::new(TestMetrics), + ), + ); + + runtime + .notify_dependency_ready(DependencyReadyEvent { + dependency: DependencyRef::message("dep-1"), + }) + .await + .expect("notify should work"); + + assert_eq!( + store.notified.lock().expect("lock poisoned").as_slice(), + &["dep-1".to_string()] + ); +} + +#[tokio::test] +async fn kernel_flushes_partial_batches_on_channel_close() { + let scheduler = Arc::new(TestScheduler); + let executor = Arc::new(TestExecutor); + let store = Arc::new(TestDeferredStore::default()); + let sink = Arc::new(TestCommitSink::default()); + let metrics = Arc::new(TestMetrics); + let bus = TokioPipelineBus; + let channels = >::open_channels(&bus, 8); + let tx = channels.intake_tx.clone(); + + tx.send(message("m_tail", 9, 1)) + .await + .expect("send should work"); + drop(tx); + + let stats = kernel::run_pipeline( + channels, + scheduler, + executor, + store, + sink.clone(), + metrics, + &KernelConfig { + channel_capacity: 8, + schedule_batch_size: 16, + execute_batch_size: 16, + idle_sleep_ms: 1, + }, + ) + .await + .expect("pipeline should flush tail batch"); + + assert_eq!(stats.runnable_messages, 1); + assert_eq!(stats.committed_batches, 1); + assert_eq!(sink.commits.lock().expect("lock poisoned").as_slice(), &[(9, 1)]); +} + +#[tokio::test] +async fn kernel_errors_on_executor_fatal_event() { + let scheduler = Arc::new(TestScheduler); + let executor = Arc::new(TestExecutor); + let store = Arc::new(TestDeferredStore::default()); + let sink = Arc::new(TestCommitSink::default()); + let metrics = Arc::new(TestMetrics); + let bus = TokioPipelineBus; + let channels = >::open_channels(&bus, 8); + let tx = channels.intake_tx.clone(); + + tx.send(message("m_fatal", 1, 3)) + .await + .expect("send should work"); + drop(tx); + + let err = kernel::run_pipeline( + channels, + scheduler, + executor, + store, + sink, + metrics, + &KernelConfig { + channel_capacity: 8, + schedule_batch_size: 1, + execute_batch_size: 1, + idle_sleep_ms: 1, + }, + ) + .await + .expect_err("fatal event should fail pipeline"); + + let msg = err.to_string(); + assert!(msg.contains("fatal execute event")); + assert!(msg.contains("m_fatal")); +} + +#[tokio::test] +async fn kernel_errors_on_invalid_scheduler_event() { + let scheduler = Arc::new(InvalidEventScheduler); + let executor = Arc::new(TestExecutor); + let store = Arc::new(TestDeferredStore::default()); + let sink = Arc::new(TestCommitSink::default()); + let metrics = Arc::new(TestMetrics); + let bus = TokioPipelineBus; + let channels = >::open_channels(&bus, 8); + let tx = channels.intake_tx.clone(); + + tx.send(message("m_bad_sched", 1, 1)) + .await + .expect("send should work"); + drop(tx); + + let err = kernel::run_pipeline( + channels, + scheduler, + executor, + store, + sink, + metrics, + &KernelConfig { + channel_capacity: 8, + schedule_batch_size: 1, + execute_batch_size: 1, + idle_sleep_ms: 1, + }, + ) + .await + .expect_err("invalid scheduler event should fail pipeline"); + + assert!(err.to_string().contains("scheduler emitted invalid event")); +} From cf21f804b7c3a286ef367a3da9ec4dffb7afc8cc Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Sat, 14 Feb 2026 12:17:57 +0800 Subject: [PATCH 37/49] refactor(ingest): unify flow control in streaming kernel --- crates/ingest/README.md | 11 +- crates/ingest/src/lib.rs | 8 +- crates/ingest/src/runtime/flow_control.rs | 70 ++++ crates/ingest/src/runtime/kernel.rs | 459 ++++++++++++---------- crates/ingest/src/runtime/mod.rs | 82 +--- crates/ingest/src/traits.rs | 14 +- crates/ingest/src/types.rs | 16 +- crates/ingest/tests/runtime_kernel.rs | 73 ++-- 8 files changed, 410 insertions(+), 323 deletions(-) create mode 100644 crates/ingest/src/runtime/flow_control.rs diff --git a/crates/ingest/README.md b/crates/ingest/README.md index 7855b02..9345b6d 100644 --- a/crates/ingest/README.md +++ b/crates/ingest/README.md @@ -101,12 +101,12 @@ This unblocks replay via `DeferredStore`. ## Runtime Flow 1. Producers push `Message

` into intake channel. -2. Kernel batches intake messages and calls `Scheduler`. +2. Kernel applies flow control (`max_in_flight`) and schedules each message. 3. `Scheduler` emits `PipelineEvent` values. -4. `Runnable` messages are batched and executed by `Executor` (rayon parallel path). -5. `Executed` events are grouped by epoch and sent to `CommitSink`. +4. `Runnable` messages are executed immediately by `Executor`. +5. `Executed` events are grouped by epoch (within one message execution) and sent to `CommitSink`. 6. `Deferred` events go to deferred channel and are persisted by deferred sink. -7. Replay loop polls `DeferredStore::pop_ready(limit)` and re-enqueues ready messages. +7. Kernel replay loop polls `DeferredStore::pop_ready(limit)` and reinjects ready messages. ## Backpressure and Memory @@ -118,8 +118,7 @@ This unblocks replay via `DeferredStore`. - `deferred_poll_limit`: max replay load per poll cycle. - `kernel_channel_capacity`: channel capacity for intake/deferred. -- `schedule_batch_size`: scheduler batch size. -- `execute_batch_size`: executor batch size. +- `max_in_flight`: max concurrent message workers inside kernel. - `idle_sleep_ms`: replay loop sleep when no ready deferred message exists. ## Correctness Assumptions diff --git a/crates/ingest/src/lib.rs b/crates/ingest/src/lib.rs index 2e2b19b..e4d0fc9 100644 --- a/crates/ingest/src/lib.rs +++ b/crates/ingest/src/lib.rs @@ -5,13 +5,11 @@ pub mod types; pub use error::IngestError; pub use runtime::{ - DynCommitSink, DynDeferredStore, DynExecutor, DynPipelineBus, DynRuntimeMetrics, - DynScheduler, IngestRuntime, IntakeHandle, KernelConfig, PipelineBus, RuntimeComponents, + DynCommitSink, DynDeferredStore, DynExecutor, DynPipelineBus, DynRuntimeMetrics, DynScheduler, + FlowControlConfig, IngestRuntime, IntakeHandle, PipelineBus, RuntimeComponents, TokioPipelineBus, }; -pub use traits::{ - CommitSink, DeferredStore, Executor, RuntimeMetrics, Scheduler, -}; +pub use traits::{CommitSink, DeferredStore, Executor, RuntimeMetrics, Scheduler}; pub use types::{ DependencyKind, DependencyReadyEvent, DependencyRef, ExecutionResult, ExecutionStatus, Message, MessageGroup, MessageId, OperationBatch, PipelineEvent, ResourceKey, RuntimeConfig, Topic, diff --git a/crates/ingest/src/runtime/flow_control.rs b/crates/ingest/src/runtime/flow_control.rs new file mode 100644 index 0000000..57362e0 --- /dev/null +++ b/crates/ingest/src/runtime/flow_control.rs @@ -0,0 +1,70 @@ +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; + +use crate::error::IngestError; +use crate::types::RuntimeConfig; + +#[derive(Clone)] +pub struct FlowControlConfig { + pub channel_capacity: usize, + pub max_in_flight: usize, + pub deferred_poll_limit: usize, + pub idle_sleep_ms: u64, +} + +impl Default for FlowControlConfig { + fn default() -> Self { + Self { + channel_capacity: 256, + max_in_flight: 256, + deferred_poll_limit: 256, + idle_sleep_ms: 10, + } + } +} + +impl From<&RuntimeConfig> for FlowControlConfig { + fn from(value: &RuntimeConfig) -> Self { + Self { + channel_capacity: value.kernel_channel_capacity, + max_in_flight: value.max_in_flight, + deferred_poll_limit: value.deferred_poll_limit, + idle_sleep_ms: value.idle_sleep_ms, + } + } +} + +#[derive(Clone)] +pub struct FlowController { + in_flight: Arc, + deferred_poll_limit: usize, + idle_sleep: Duration, +} + +impl FlowController { + pub fn new(config: &FlowControlConfig) -> Self { + Self { + in_flight: Arc::new(Semaphore::new(config.max_in_flight.max(1))), + deferred_poll_limit: config.deferred_poll_limit.max(1), + idle_sleep: Duration::from_millis(config.idle_sleep_ms.max(1)), + } + } + + pub async fn acquire_in_flight(&self) -> Result { + self.in_flight + .clone() + .acquire_owned() + .await + .map_err(|_| IngestError::Execution("in-flight flow controller closed".to_string())) + } + + pub fn deferred_poll_limit(&self) -> usize { + self.deferred_poll_limit + } + + pub fn idle_sleep(&self) -> Duration { + self.idle_sleep + } +} diff --git a/crates/ingest/src/runtime/kernel.rs b/crates/ingest/src/runtime/kernel.rs index 657efc8..760f991 100644 --- a/crates/ingest/src/runtime/kernel.rs +++ b/crates/ingest/src/runtime/kernel.rs @@ -1,13 +1,16 @@ use std::collections::BTreeMap; use std::sync::Arc; -use rayon::prelude::*; use tokio::sync::mpsc; +use tokio::task::JoinSet; use tracing::warn; use crate::error::IngestError; -use crate::traits::{CommitSink, DeferredStore, Executor, RuntimeMetrics, Scheduler}; -use crate::types::{ExecutionResult, Message, PipelineEvent, RuntimeConfig}; +use crate::runtime::flow_control::{FlowControlConfig, FlowController}; +use crate::runtime::{ + DynCommitSink, DynDeferredStore, DynExecutor, DynRuntimeMetrics, DynScheduler, +}; +use crate::types::{ExecutionResult, Message, PipelineEvent}; pub struct BusChannels

where @@ -49,36 +52,6 @@ where } } -#[derive(Clone)] -pub struct KernelConfig { - pub channel_capacity: usize, - pub schedule_batch_size: usize, - pub execute_batch_size: usize, - pub idle_sleep_ms: u64, -} - -impl Default for KernelConfig { - fn default() -> Self { - Self { - channel_capacity: 256, - schedule_batch_size: 256, - execute_batch_size: 256, - idle_sleep_ms: 10, - } - } -} - -impl From<&RuntimeConfig> for KernelConfig { - fn from(value: &RuntimeConfig) -> Self { - Self { - channel_capacity: value.kernel_channel_capacity, - schedule_batch_size: value.schedule_batch_size, - execute_batch_size: value.execute_batch_size, - idle_sleep_ms: value.idle_sleep_ms, - } - } -} - #[derive(Debug, Default)] pub struct KernelRunStats { pub runnable_messages: usize, @@ -88,23 +61,35 @@ pub struct KernelRunStats { pub committed_batches: usize, } -pub async fn run_pipeline( +#[derive(Default)] +struct MessageRunStats { + runnable_messages: usize, + deferred_from_schedule: usize, + deferred_from_execute: usize, + committed_batches: usize, +} + +impl KernelRunStats { + fn merge_message_stats(&mut self, msg: MessageRunStats) { + self.runnable_messages += msg.runnable_messages; + self.deferred_from_schedule += msg.deferred_from_schedule; + self.deferred_from_execute += msg.deferred_from_execute; + self.committed_batches += msg.committed_batches; + } +} + +pub async fn run_pipeline( channels: BusChannels

, - scheduler: Arc, - executor: Arc, - deferred_store: Arc, - commit_sink: Arc, - metrics: Arc, - config: &KernelConfig, + scheduler: DynScheduler, + executor: DynExecutor, + deferred_store: DynDeferredStore

, + commit_sink: DynCommitSink, + metrics: DynRuntimeMetrics, + config: &FlowControlConfig, ) -> Result where P: Clone + Send + Sync + 'static, Op: Send + Sync + 'static, - SCH: Scheduler + Send + Sync + 'static + ?Sized, - EX: Executor + Send + Sync + 'static + ?Sized, - DS: DeferredStore

+ Send + Sync + 'static + ?Sized, - C: CommitSink + Send + Sync + 'static + ?Sized, - RM: RuntimeMetrics + Send + Sync + 'static + ?Sized, { let BusChannels { intake_tx, @@ -114,209 +99,255 @@ where } = channels; drop(intake_tx); - let deferred_handle = { - let store = Arc::clone(&deferred_store); - tokio::spawn(async move { - let mut persisted = 0usize; - while let Some(msg) = deferred_rx.recv().await { - let store_cloned = Arc::clone(&store); - tokio::task::spawn_blocking(move || store_cloned.push(msg)) - .await - .map_err(|e| { - IngestError::Execution(format!("deferred sink join failure: {e}")) - })??; - persisted += 1; - } - Ok::(persisted) - }) - }; + let flow = FlowController::new(config); + let mut replay_tick = tokio::time::interval(flow.idle_sleep()); - let schedule_batch_size = config.schedule_batch_size.max(1); - let execute_batch_size = config.execute_batch_size.max(1); let mut stats = KernelRunStats::default(); - let mut schedule_batch = Vec::with_capacity(schedule_batch_size); - let mut execute_batch = Vec::with_capacity(execute_batch_size); + let mut workers = JoinSet::new(); + let mut intake_closed = false; - while let Some(msg) = intake_rx.recv().await { - schedule_batch.push(msg); - if schedule_batch.len() < schedule_batch_size { - continue; - } + loop { + // Central event loop: + // - waits on worker completions, deferred persistence, deferred replay ticks, and intake; + // - picks exactly one ready branch per iteration, then loops again; + // - `biased;` gives priority in source order to reduce completed-worker lag. + tokio::select! { + biased; - let scheduler_cloned = Arc::clone(&scheduler); - let input = std::mem::take(&mut schedule_batch); - let schedule_events = tokio::task::spawn_blocking(move || scheduler_cloned.schedule(input)) - .await - .map_err(|e| IngestError::Execution(format!("schedule join failure: {e}")))??; - - for event in schedule_events { - match event { - PipelineEvent::Runnable(msg) => { - stats.runnable_messages += 1; - execute_batch.push(msg); - } - PipelineEvent::Deferred(msg) => { - stats.deferred_from_schedule += 1; - deferred_tx.send(msg).await.map_err(|_| { - IngestError::Execution("kernel deferred channel closed".to_string()) - })?; + joined = workers.join_next(), if !workers.is_empty() => { + match joined { + Some(joined) => { + let msg_stats = joined + .map_err(|e| IngestError::Execution(format!("worker join failure: {e}")))??; + stats.merge_message_stats(msg_stats); + } + None => {} } - _ => { - return Err(IngestError::Execution( - "scheduler emitted invalid event".to_string(), - )); + } + + maybe_msg = deferred_rx.recv() => { + match maybe_msg { + Some(msg) => { + persist_deferred(Arc::clone(&deferred_store), msg).await?; + stats.deferred_persisted += 1; + } + None => { + if intake_closed && workers.is_empty() { + break; + } + } } } - } - if execute_batch.len() < execute_batch_size { - continue; - } + _ = replay_tick.tick(), if !intake_closed => { + let ready = pop_ready_messages( + Arc::clone(&deferred_store), + flow.deferred_poll_limit(), + ).await?; + if !ready.is_empty() { + metrics.observe_replay_result(true); + } - let executor_cloned = Arc::clone(&executor); - let input = std::mem::take(&mut execute_batch); - let events_by_msg = tokio::task::spawn_blocking(move || { - let raw: Vec>, IngestError>> = - input.into_par_iter().map(|m| executor_cloned.execute(m)).collect(); - let mut out = Vec::with_capacity(raw.len()); - for item in raw { - out.push(item?); + for msg in ready { + let permit = flow.acquire_in_flight().await?; + let scheduler_cloned = Arc::clone(&scheduler); + let executor_cloned = Arc::clone(&executor); + let commit_sink_cloned = Arc::clone(&commit_sink); + let deferred_tx_cloned = deferred_tx.clone(); + let metrics_cloned = Arc::clone(&metrics); + workers.spawn(async move { + let _permit = permit; + process_message( + msg, + scheduler_cloned, + executor_cloned, + commit_sink_cloned, + deferred_tx_cloned, + metrics_cloned, + ) + .await + }); + } } - Ok::>>, IngestError>(out) - }) - .await - .map_err(|e| IngestError::Execution(format!("execute join failure: {e}")))??; - let mut by_epoch: BTreeMap>> = BTreeMap::new(); - for events in events_by_msg { - for event in events { - match event { - PipelineEvent::Executed { epoch, result } => { - by_epoch.entry(epoch).or_default().push(result); - } - PipelineEvent::Deferred(msg) => { - stats.deferred_from_execute += 1; - deferred_tx.send(msg).await.map_err(|_| { - IngestError::Execution("kernel deferred channel closed".to_string()) - })?; - } - PipelineEvent::Fatal { msg_id, error } => { - let emsg = error.unwrap_or_else(|| "unknown fatal error".to_string()); - warn!("fatal execute event for {msg_id}: {emsg}"); - return Err(IngestError::Execution(format!( - "fatal execute event for {msg_id}: {emsg}" - ))); - } - PipelineEvent::Runnable(_) => { - return Err(IngestError::Execution( - "executor emitted invalid event".to_string(), - )); + maybe_msg = intake_rx.recv(), if !intake_closed => { + match maybe_msg { + Some(msg) => { + let permit = flow.acquire_in_flight().await?; + let scheduler_cloned = Arc::clone(&scheduler); + let executor_cloned = Arc::clone(&executor); + let commit_sink_cloned = Arc::clone(&commit_sink); + let deferred_tx_cloned = deferred_tx.clone(); + let metrics_cloned = Arc::clone(&metrics); + workers.spawn(async move { + let _permit = permit; + process_message( + msg, + scheduler_cloned, + executor_cloned, + commit_sink_cloned, + deferred_tx_cloned, + metrics_cloned, + ) + .await + }); } + None => intake_closed = true, } } } - for (epoch, results) in by_epoch { - let sink = Arc::clone(&commit_sink); - let committed = tokio::task::spawn_blocking(move || sink.commit_epoch(epoch, results)) - .await - .map_err(|e| IngestError::Execution(format!("commit join failure: {e}")))??; - stats.committed_batches += committed; + if intake_closed && workers.is_empty() { + while let Ok(msg) = deferred_rx.try_recv() { + persist_deferred(Arc::clone(&deferred_store), msg).await?; + stats.deferred_persisted += 1; + } + + drop(deferred_tx); + while let Some(msg) = deferred_rx.recv().await { + persist_deferred(Arc::clone(&deferred_store), msg).await?; + stats.deferred_persisted += 1; + } + break; } } - if !schedule_batch.is_empty() { - let scheduler_cloned = Arc::clone(&scheduler); - let input = std::mem::take(&mut schedule_batch); - let schedule_events = tokio::task::spawn_blocking(move || scheduler_cloned.schedule(input)) + Ok(stats) +} + +async fn process_message( + message: Message

, + scheduler: DynScheduler, + executor: DynExecutor, + commit_sink: DynCommitSink, + deferred_tx: mpsc::Sender>, + metrics: DynRuntimeMetrics, +) -> Result +where + P: Clone + Send + Sync + 'static, + Op: Send + Sync + 'static, +{ + let mut stats = MessageRunStats::default(); + + let scheduler_cloned = Arc::clone(&scheduler); + let schedule_events = + tokio::task::spawn_blocking(move || scheduler_cloned.schedule(vec![message])) .await .map_err(|e| IngestError::Execution(format!("schedule join failure: {e}")))??; - for event in schedule_events { - match event { - PipelineEvent::Runnable(msg) => { - stats.runnable_messages += 1; - execute_batch.push(msg); - } - PipelineEvent::Deferred(msg) => { - stats.deferred_from_schedule += 1; - deferred_tx.send(msg).await.map_err(|_| { - IngestError::Execution("kernel deferred channel closed".to_string()) - })?; - } - _ => { - return Err(IngestError::Execution( - "scheduler emitted invalid event".to_string(), - )); - } + for event in schedule_events { + match event { + PipelineEvent::Runnable(msg) => { + stats.runnable_messages += 1; + let msg_stats = execute_runnable( + msg, + Arc::clone(&executor), + Arc::clone(&commit_sink), + deferred_tx.clone(), + ) + .await?; + stats.deferred_from_execute += msg_stats.deferred_from_execute; + stats.committed_batches += msg_stats.committed_batches; + } + PipelineEvent::Deferred(msg) => { + stats.deferred_from_schedule += 1; + deferred_tx.send(msg).await.map_err(|_| { + IngestError::Execution("kernel deferred channel closed".to_string()) + })?; + } + _ => { + return Err(IngestError::Execution( + "scheduler emitted invalid event".to_string(), + )); } } } - if !execute_batch.is_empty() { - let executor_cloned = Arc::clone(&executor); - let input = std::mem::take(&mut execute_batch); - let events_by_msg = tokio::task::spawn_blocking(move || { - let raw: Vec>, IngestError>> = - input.into_par_iter().map(|m| executor_cloned.execute(m)).collect(); - let mut out = Vec::with_capacity(raw.len()); - for item in raw { - out.push(item?); - } - Ok::>>, IngestError>(out) - }) + metrics.observe_throughput("kernel_message", 1); + Ok(stats) +} + +#[derive(Default)] +struct RunnableRunStats { + deferred_from_execute: usize, + committed_batches: usize, +} + +async fn execute_runnable( + message: Message

, + executor: DynExecutor, + commit_sink: DynCommitSink, + deferred_tx: mpsc::Sender>, +) -> Result +where + P: Clone + Send + Sync + 'static, + Op: Send + Sync + 'static, +{ + let mut stats = RunnableRunStats::default(); + + let executor_cloned = Arc::clone(&executor); + let execute_events = tokio::task::spawn_blocking(move || executor_cloned.execute(message)) .await .map_err(|e| IngestError::Execution(format!("execute join failure: {e}")))??; - let mut by_epoch: BTreeMap>> = BTreeMap::new(); - for events in events_by_msg { - for event in events { - match event { - PipelineEvent::Executed { epoch, result } => { - by_epoch.entry(epoch).or_default().push(result); - } - PipelineEvent::Deferred(msg) => { - stats.deferred_from_execute += 1; - deferred_tx.send(msg).await.map_err(|_| { - IngestError::Execution("kernel deferred channel closed".to_string()) - })?; - } - PipelineEvent::Fatal { msg_id, error } => { - let emsg = error.unwrap_or_else(|| "unknown fatal error".to_string()); - warn!("fatal execute event for {msg_id}: {emsg}"); - return Err(IngestError::Execution(format!( - "fatal execute event for {msg_id}: {emsg}" - ))); - } - PipelineEvent::Runnable(_) => { - return Err(IngestError::Execution( - "executor emitted invalid event".to_string(), - )); - } - } + let mut by_epoch: BTreeMap>> = BTreeMap::new(); + for event in execute_events { + match event { + PipelineEvent::Executed { epoch, result } => { + by_epoch.entry(epoch).or_default().push(result); + } + PipelineEvent::Deferred(msg) => { + stats.deferred_from_execute += 1; + deferred_tx.send(msg).await.map_err(|_| { + IngestError::Execution("kernel deferred channel closed".to_string()) + })?; + } + PipelineEvent::Fatal { msg_id, error } => { + let emsg = error.unwrap_or_else(|| "unknown fatal error".to_string()); + warn!("fatal execute event for {msg_id}: {emsg}"); + return Err(IngestError::Execution(format!( + "fatal execute event for {msg_id}: {emsg}" + ))); + } + PipelineEvent::Runnable(_) => { + return Err(IngestError::Execution( + "executor emitted invalid event".to_string(), + )); } } + } - for (epoch, results) in by_epoch { - let sink = Arc::clone(&commit_sink); - let committed = tokio::task::spawn_blocking(move || sink.commit_epoch(epoch, results)) - .await - .map_err(|e| IngestError::Execution(format!("commit join failure: {e}")))??; - stats.committed_batches += committed; - } + for (epoch, results) in by_epoch { + let sink = Arc::clone(&commit_sink); + let committed = tokio::task::spawn_blocking(move || sink.commit_epoch(epoch, results)) + .await + .map_err(|e| IngestError::Execution(format!("commit join failure: {e}")))??; + stats.committed_batches += committed; } - drop(deferred_tx); - stats.deferred_persisted = deferred_handle - .await - .map_err(|e| IngestError::Execution(format!("kernel deferred join failure: {e}")))??; + Ok(stats) +} - metrics.observe_queue_depth("kernel_runnable_total", stats.runnable_messages); - metrics.observe_queue_depth( - "kernel_deferred_total", - stats.deferred_from_schedule + stats.deferred_from_execute, - ); - metrics.observe_queue_depth("kernel_deferred_persisted", stats.deferred_persisted); +async fn persist_deferred

( + deferred_store: DynDeferredStore

, + message: Message

, +) -> Result<(), IngestError> +where + P: Clone + Send + Sync + 'static, +{ + tokio::task::spawn_blocking(move || deferred_store.push(message)) + .await + .map_err(|e| IngestError::Execution(format!("deferred sink join failure: {e}")))? +} - Ok(stats) +async fn pop_ready_messages

( + deferred_store: DynDeferredStore

, + limit: usize, +) -> Result>, IngestError> +where + P: Clone + Send + Sync + 'static, +{ + tokio::task::spawn_blocking(move || deferred_store.pop_ready(limit.max(1))) + .await + .map_err(|e| IngestError::Execution(format!("deferred replay join failure: {e}")))? } diff --git a/crates/ingest/src/runtime/mod.rs b/crates/ingest/src/runtime/mod.rs index 4602804..a77bad4 100644 --- a/crates/ingest/src/runtime/mod.rs +++ b/crates/ingest/src/runtime/mod.rs @@ -1,5 +1,4 @@ use std::sync::Arc; -use std::time::Duration; use tokio::sync::{Mutex, mpsc}; @@ -7,9 +6,11 @@ use crate::error::IngestError; use crate::traits::{CommitSink, DeferredStore, Executor, RuntimeMetrics, Scheduler}; use crate::types::{DependencyReadyEvent, Message, RuntimeConfig}; +pub mod flow_control; pub mod kernel; -pub use kernel::{KernelConfig, PipelineBus, TokioPipelineBus}; +pub use flow_control::FlowControlConfig; +pub use kernel::{PipelineBus, TokioPipelineBus}; pub type DynScheduler = Arc + Send + Sync>; pub type DynExecutor = Arc + Send + Sync>; @@ -82,8 +83,7 @@ where pub commit_sink: DynCommitSink, pub bus: DynPipelineBus, pub metrics: DynRuntimeMetrics, - pub kernel_config: KernelConfig, - deferred_poll_limit: usize, + pub flow_control: FlowControlConfig, intake_tx: mpsc::Sender>, pipeline_channels: Mutex>>, _marker: std::marker::PhantomData, @@ -95,8 +95,8 @@ where Op: Send + Sync + 'static, { pub fn new(config: RuntimeConfig, components: RuntimeComponents) -> Self { - let kernel_config = KernelConfig::from(&config); - let channels = components.bus.open_channels(kernel_config.channel_capacity); + let flow_control = FlowControlConfig::from(&config); + let channels = components.bus.open_channels(flow_control.channel_capacity); let intake_tx = channels.intake_tx.clone(); Self { @@ -106,8 +106,7 @@ where commit_sink: components.commit_sink, bus: components.bus, metrics: components.metrics, - kernel_config, - deferred_poll_limit: config.deferred_poll_limit.max(1), + flow_control, intake_tx, pipeline_channels: Mutex::new(Some(channels)), _marker: std::marker::PhantomData, @@ -137,42 +136,8 @@ where .await .take() .ok_or_else(|| IngestError::Execution("runtime already started".to_string()))?; - let replay_tx = self.intake_tx.clone(); - let deferred_store = Arc::clone(&self.deferred_store); - let deferred_poll_limit = self.deferred_poll_limit; - let replay_task = { - let idle_sleep = Duration::from_millis(self.kernel_config.idle_sleep_ms.max(1)); - tokio::spawn(async move { - loop { - let store = Arc::clone(&deferred_store); - let ready = - tokio::task::spawn_blocking(move || store.pop_ready(deferred_poll_limit)) - .await - .map_err(|e| { - IngestError::Execution(format!( - "deferred replay join failure: {e}" - )) - })??; - - if ready.is_empty() { - tokio::time::sleep(idle_sleep).await; - continue; - } - for msg in ready { - replay_tx.send(msg).await.map_err(|_| { - IngestError::Execution( - "deferred replay failed: intake channel closed".to_string(), - ) - })?; - } - } - #[allow(unreachable_code)] - Ok::<(), IngestError>(()) - }) - }; - - let kernel_config = self.kernel_config.clone(); + let flow_control = self.flow_control.clone(); let scheduler = Arc::clone(&self.scheduler); let executor = Arc::clone(&self.executor); let deferred_store = Arc::clone(&self.deferred_store); @@ -186,27 +151,22 @@ where deferred_store, commit_sink, metrics, - &kernel_config, + &flow_control, ) .await }); - tokio::select! { - res = replay_task => { - res.map_err(|e| IngestError::Execution(format!("replay task join failure: {e}")))??; - Err(IngestError::Execution("replay task exited unexpectedly".to_string())) - } - res = kernel_task => { - let stats = res.map_err(|e| IngestError::Execution(format!("kernel task join failure: {e}")))??; - Err(IngestError::Execution(format!( - "kernel task exited unexpectedly (runnable={}, deferred_sched={}, deferred_exec={}, deferred_persisted={}, committed={})", - stats.runnable_messages, - stats.deferred_from_schedule, - stats.deferred_from_execute, - stats.deferred_persisted, - stats.committed_batches - ))) - } - } + let stats = kernel_task + .await + .map_err(|e| IngestError::Execution(format!("kernel task join failure: {e}")))??; + + Err(IngestError::Execution(format!( + "kernel task exited unexpectedly (runnable={}, deferred_sched={}, deferred_exec={}, deferred_persisted={}, committed={})", + stats.runnable_messages, + stats.deferred_from_schedule, + stats.deferred_from_execute, + stats.deferred_persisted, + stats.committed_batches + ))) } } diff --git a/crates/ingest/src/traits.rs b/crates/ingest/src/traits.rs index 0382e72..4388a84 100644 --- a/crates/ingest/src/traits.rs +++ b/crates/ingest/src/traits.rs @@ -1,10 +1,9 @@ use crate::error::IngestError; -use crate::types::{ - DependencyReadyEvent, ExecutionResult, Message, PipelineEvent, -}; +use crate::types::{DependencyReadyEvent, ExecutionResult, Message, PipelineEvent}; pub trait Scheduler: Send + Sync { - fn schedule(&self, messages: Vec>) -> Result>, IngestError>; + fn schedule(&self, messages: Vec>) + -> Result>, IngestError>; } pub trait Executor: Send + Sync { @@ -18,8 +17,11 @@ pub trait DeferredStore

: Send + Sync { } pub trait CommitSink: Send + Sync { - fn commit_epoch(&self, epoch: u64, results: Vec>) - -> Result; + fn commit_epoch( + &self, + epoch: u64, + results: Vec>, + ) -> Result; } pub trait RuntimeMetrics: Send + Sync { diff --git a/crates/ingest/src/types.rs b/crates/ingest/src/types.rs index 43a1eb4..9ee51f3 100644 --- a/crates/ingest/src/types.rs +++ b/crates/ingest/src/types.rs @@ -82,16 +82,21 @@ pub struct DependencyReadyEvent { pub enum PipelineEvent { Runnable(Message

), Deferred(Message

), - Executed { epoch: u64, result: ExecutionResult }, - Fatal { msg_id: MessageId, error: Option }, + Executed { + epoch: u64, + result: ExecutionResult, + }, + Fatal { + msg_id: MessageId, + error: Option, + }, } #[derive(Debug, Clone)] pub struct RuntimeConfig { pub deferred_poll_limit: usize, pub kernel_channel_capacity: usize, - pub schedule_batch_size: usize, - pub execute_batch_size: usize, + pub max_in_flight: usize, pub idle_sleep_ms: u64, } @@ -100,8 +105,7 @@ impl Default for RuntimeConfig { Self { deferred_poll_limit: 256, kernel_channel_capacity: 256, - schedule_batch_size: 256, - execute_batch_size: 256, + max_in_flight: 256, idle_sleep_ms: 10, } } diff --git a/crates/ingest/tests/runtime_kernel.rs b/crates/ingest/tests/runtime_kernel.rs index d90fc87..c62988f 100644 --- a/crates/ingest/tests/runtime_kernel.rs +++ b/crates/ingest/tests/runtime_kernel.rs @@ -3,10 +3,13 @@ use std::sync::{Arc, Mutex}; use naviscope_ingest::runtime::kernel; use naviscope_ingest::{ - CommitSink, DeferredStore, Executor, IngestError, IngestRuntime, KernelConfig, PipelineBus, - PipelineEvent, RuntimeComponents, RuntimeConfig, RuntimeMetrics, Scheduler, TokioPipelineBus, + CommitSink, DeferredStore, Executor, FlowControlConfig, IngestError, IngestRuntime, + PipelineBus, PipelineEvent, RuntimeComponents, RuntimeConfig, RuntimeMetrics, Scheduler, + TokioPipelineBus, +}; +use naviscope_ingest::{ + DependencyReadyEvent, DependencyRef, ExecutionResult, ExecutionStatus, Message, }; -use naviscope_ingest::{DependencyReadyEvent, DependencyRef, ExecutionResult, ExecutionStatus, Message}; fn message(id: &str, epoch: u64, payload: u8) -> Message { Message { @@ -23,7 +26,10 @@ fn message(id: &str, epoch: u64, payload: u8) -> Message { struct TestScheduler; impl Scheduler for TestScheduler { - fn schedule(&self, messages: Vec>) -> Result>, IngestError> { + fn schedule( + &self, + messages: Vec>, + ) -> Result>, IngestError> { Ok(messages .into_iter() .map(|m| { @@ -69,7 +75,10 @@ struct TestDeferredStore { } impl DeferredStore for TestDeferredStore { fn push(&self, message: Message) -> Result<(), IngestError> { - self.pushed.lock().expect("lock poisoned").push(message.msg_id); + self.pushed + .lock() + .expect("lock poisoned") + .push(message.msg_id); Ok(()) } @@ -99,7 +108,10 @@ impl CommitSink for TestCommitSink { results: Vec>, ) -> Result { let size = results.len(); - self.commits.lock().expect("lock poisoned").push((epoch, size)); + self.commits + .lock() + .expect("lock poisoned") + .push((epoch, size)); Ok(usize::from(size > 0)) } } @@ -114,7 +126,10 @@ impl RuntimeMetrics for TestMetrics { struct InvalidEventScheduler; impl Scheduler for InvalidEventScheduler { - fn schedule(&self, messages: Vec>) -> Result>, IngestError> { + fn schedule( + &self, + messages: Vec>, + ) -> Result>, IngestError> { let m = messages .into_iter() .next() @@ -143,7 +158,9 @@ async fn kernel_commits_runnable_messages() { let channels = >::open_channels(&bus, 8); let tx = channels.intake_tx.clone(); - tx.send(message("m1", 7, 1)).await.expect("send should work"); + tx.send(message("m1", 7, 1)) + .await + .expect("send should work"); drop(tx); let stats = kernel::run_pipeline( @@ -153,10 +170,10 @@ async fn kernel_commits_runnable_messages() { store, sink.clone(), metrics, - &KernelConfig { + &FlowControlConfig { channel_capacity: 8, - schedule_batch_size: 1, - execute_batch_size: 1, + max_in_flight: 8, + deferred_poll_limit: 8, idle_sleep_ms: 1, }, ) @@ -165,7 +182,10 @@ async fn kernel_commits_runnable_messages() { assert_eq!(stats.runnable_messages, 1); assert_eq!(stats.committed_batches, 1); - assert_eq!(sink.commits.lock().expect("lock poisoned").as_slice(), &[(7, 1)]); + assert_eq!( + sink.commits.lock().expect("lock poisoned").as_slice(), + &[(7, 1)] + ); } #[tokio::test] @@ -194,10 +214,10 @@ async fn kernel_persists_deferred_from_both_paths() { store.clone(), sink, metrics, - &KernelConfig { + &FlowControlConfig { channel_capacity: 8, - schedule_batch_size: 1, - execute_batch_size: 1, + max_in_flight: 8, + deferred_poll_limit: 8, idle_sleep_ms: 1, }, ) @@ -262,10 +282,10 @@ async fn kernel_flushes_partial_batches_on_channel_close() { store, sink.clone(), metrics, - &KernelConfig { + &FlowControlConfig { channel_capacity: 8, - schedule_batch_size: 16, - execute_batch_size: 16, + max_in_flight: 8, + deferred_poll_limit: 8, idle_sleep_ms: 1, }, ) @@ -274,7 +294,10 @@ async fn kernel_flushes_partial_batches_on_channel_close() { assert_eq!(stats.runnable_messages, 1); assert_eq!(stats.committed_batches, 1); - assert_eq!(sink.commits.lock().expect("lock poisoned").as_slice(), &[(9, 1)]); + assert_eq!( + sink.commits.lock().expect("lock poisoned").as_slice(), + &[(9, 1)] + ); } #[tokio::test] @@ -300,10 +323,10 @@ async fn kernel_errors_on_executor_fatal_event() { store, sink, metrics, - &KernelConfig { + &FlowControlConfig { channel_capacity: 8, - schedule_batch_size: 1, - execute_batch_size: 1, + max_in_flight: 8, + deferred_poll_limit: 8, idle_sleep_ms: 1, }, ) @@ -338,10 +361,10 @@ async fn kernel_errors_on_invalid_scheduler_event() { store, sink, metrics, - &KernelConfig { + &FlowControlConfig { channel_capacity: 8, - schedule_batch_size: 1, - execute_batch_size: 1, + max_in_flight: 8, + deferred_poll_limit: 8, idle_sleep_ms: 1, }, ) From 2db6614d1752a1f172a6b47ac933cf5e5484f9ef Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Sat, 14 Feb 2026 13:31:16 +0800 Subject: [PATCH 38/49] refactor(core): integrate streaming ingest runtime and split orchestrator --- Cargo.lock | 1 + crates/core/Cargo.toml | 1 + crates/core/src/ingest/mod.rs | 1 - crates/core/src/ingest/pipeline.rs | 53 -- crates/core/src/ingest/resolver/engine.rs | 19 - crates/core/src/runtime/ingest_adapter.rs | 280 ++++++ crates/core/src/runtime/mod.rs | 1 + crates/core/src/runtime/orchestrator.rs | 875 ------------------ .../core/src/runtime/orchestrator/indexing.rs | 202 ++++ crates/core/src/runtime/orchestrator/mod.rs | 321 +++++++ .../core/src/runtime/orchestrator/storage.rs | 167 ++++ .../src/runtime/orchestrator/stub_worker.rs | 115 +++ crates/core/src/runtime/orchestrator/watch.rs | 72 ++ 13 files changed, 1160 insertions(+), 948 deletions(-) delete mode 100644 crates/core/src/ingest/pipeline.rs create mode 100644 crates/core/src/runtime/ingest_adapter.rs delete mode 100644 crates/core/src/runtime/orchestrator.rs create mode 100644 crates/core/src/runtime/orchestrator/indexing.rs create mode 100644 crates/core/src/runtime/orchestrator/mod.rs create mode 100644 crates/core/src/runtime/orchestrator/storage.rs create mode 100644 crates/core/src/runtime/orchestrator/stub_worker.rs create mode 100644 crates/core/src/runtime/orchestrator/watch.rs diff --git a/Cargo.lock b/Cargo.lock index 24252f0..9ca7bb9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1492,6 +1492,7 @@ dependencies = [ "log", "lsp-types 0.97.0", "naviscope-api", + "naviscope-ingest", "naviscope-java", "naviscope-plugin", "notify", diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index fa49ec5..aa3e484 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -30,6 +30,7 @@ lasso = { workspace = true } zstd = { workspace = true } naviscope-api = { workspace = true } naviscope-plugin = { workspace = true } +naviscope-ingest = { workspace = true } async-trait = { workspace = true } url = { workspace = true } dirs = { workspace = true } diff --git a/crates/core/src/ingest/mod.rs b/crates/core/src/ingest/mod.rs index 09d11cf..f278b9c 100644 --- a/crates/core/src/ingest/mod.rs +++ b/crates/core/src/ingest/mod.rs @@ -1,6 +1,5 @@ pub mod builder; pub mod parser; -pub mod pipeline; pub mod resolver; pub mod scanner; diff --git a/crates/core/src/ingest/pipeline.rs b/crates/core/src/ingest/pipeline.rs deleted file mode 100644 index d585fb0..0000000 --- a/crates/core/src/ingest/pipeline.rs +++ /dev/null @@ -1,53 +0,0 @@ -use crate::error::Result; -use crate::model::GraphOp; -use std::path::PathBuf; - -/// Ingest task context, used to share state between batches -pub trait PipelineContext: Send + Sync + 'static {} -impl PipelineContext for naviscope_plugin::ProjectContext {} - -/// A processing stage of the pipeline -pub trait PipelineStage: Send + Sync { - type Output; - - /// Processes a batch of paths - fn process(&self, context: &C, paths: Vec) -> Result>; -} - -/// Ingest pipeline engine -pub struct IngestPipeline { - batch_size: usize, -} - -impl IngestPipeline { - pub fn new(batch_size: usize) -> Self { - Self { - batch_size: if batch_size == 0 { 100 } else { batch_size }, - } - } - - /// Executes the pipeline: processes paths in chunks and commits the products - pub fn execute( - &self, - context: &C, - paths: Vec, - stage: &S, - mut committer: F, - ) -> Result<()> - where - C: PipelineContext, - S: PipelineStage, - F: FnMut(Vec) -> Result<()>, - { - for chunk in paths.chunks(self.batch_size) { - // 1. Process the current batch - let outputs = stage.process(context, chunk.to_vec())?; - - // 2. Commit the products - committer(outputs)?; - - // End of batch, local variables are cleaned up - } - Ok(()) - } -} diff --git a/crates/core/src/ingest/resolver/engine.rs b/crates/core/src/ingest/resolver/engine.rs index dc0eea1..d3fbcc9 100644 --- a/crates/core/src/ingest/resolver/engine.rs +++ b/crates/core/src/ingest/resolver/engine.rs @@ -269,22 +269,3 @@ impl IndexResolver { Ok(all_ops) } } - -impl crate::ingest::pipeline::PipelineStage for IndexResolver { - type Output = GraphOp; - - fn process( - &self, - context: &ProjectContext, - paths: Vec, - ) -> Result> { - let files = - crate::ingest::scanner::Scanner::scan_files(paths, &std::collections::HashMap::new()); - let (mut all_ops, _build, source) = self.prepare_and_partition(files); - - let source_ops = self.resolve_source_batch(&source, context)?; - all_ops.extend(source_ops); - - Ok(all_ops) - } -} diff --git a/crates/core/src/runtime/ingest_adapter.rs b/crates/core/src/runtime/ingest_adapter.rs new file mode 100644 index 0000000..8697865 --- /dev/null +++ b/crates/core/src/runtime/ingest_adapter.rs @@ -0,0 +1,280 @@ +use std::collections::{BTreeMap, HashMap, VecDeque}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use naviscope_ingest::runtime::kernel; +use naviscope_ingest::{ + CommitSink, DeferredStore, DependencyReadyEvent, ExecutionResult, ExecutionStatus, + FlowControlConfig, IngestError, PipelineBus, PipelineEvent, RuntimeConfig, RuntimeMetrics, + Scheduler, +}; +use naviscope_plugin::{LanguageCaps, ParsedFile, ProjectContext}; + +use crate::error::{NaviscopeError, Result}; +use crate::ingest::resolver::{IndexResolver, StubRequest}; +use crate::model::{CodeGraph, GraphOp}; + +#[derive(Clone)] +pub struct IndexWorkItem { + pub file: ParsedFile, +} + +struct IndexWorkScheduler; + +impl Scheduler for IndexWorkScheduler { + fn schedule( + &self, + messages: Vec>, + ) -> std::result::Result>, IngestError> { + Ok(messages.into_iter().map(PipelineEvent::Runnable).collect()) + } +} + +struct IndexWorkExecutor { + resolver: Arc, + project_context: Arc, +} + +impl naviscope_ingest::Executor for IndexWorkExecutor { + fn execute( + &self, + message: naviscope_ingest::Message, + ) -> std::result::Result>, IngestError> { + let file = message.payload.file; + let path = file.path().to_path_buf(); + + let mut ops = Vec::with_capacity(4); + ops.push(GraphOp::RemovePath { + path: Arc::from(path.as_path()), + }); + ops.push(GraphOp::UpdateFile { + metadata: file.file.clone(), + }); + + let source_ops = self + .resolver + .resolve_source_batch(std::slice::from_ref(&file), &self.project_context) + .map_err(naviscope_to_ingest_error)?; + ops.extend(source_ops); + + Ok(vec![PipelineEvent::Executed { + epoch: message.epoch, + result: ExecutionResult { + msg_id: message.msg_id, + status: ExecutionStatus::Done, + operations: ops, + next_dependencies: Vec::new(), + error: None, + }, + }]) + } +} + +#[derive(Default)] +struct InMemoryDeferredQueue { + items: Mutex>>, +} + +impl DeferredStore for InMemoryDeferredQueue { + fn push( + &self, + message: naviscope_ingest::Message, + ) -> std::result::Result<(), IngestError> { + let mut guard = self + .items + .lock() + .map_err(|_| IngestError::Execution("deferred queue poisoned".to_string()))?; + guard.push_back(message); + Ok(()) + } + + fn pop_ready( + &self, + limit: usize, + ) -> std::result::Result>, IngestError> { + let mut guard = self + .items + .lock() + .map_err(|_| IngestError::Execution("deferred queue poisoned".to_string()))?; + let mut out = Vec::new(); + let max = limit.max(1); + for _ in 0..max { + if let Some(item) = guard.pop_front() { + out.push(item); + } else { + break; + } + } + Ok(out) + } + + fn notify_ready(&self, _event: DependencyReadyEvent) -> std::result::Result<(), IngestError> { + Ok(()) + } +} + +struct IndexWorkCommitter { + builder: Arc>, + pending_stubs: Arc>>, + resolver: Arc, + routes: Arc>>, +} + +impl CommitSink for IndexWorkCommitter { + fn commit_epoch( + &self, + _epoch: u64, + results: Vec>, + ) -> std::result::Result { + if results.is_empty() { + return Ok(0); + } + + for result in results { + let operations = result.operations; + { + let mut guard = self + .builder + .lock() + .map_err(|_| IngestError::Execution("graph builder poisoned".to_string()))?; + guard + .apply_ops(operations.clone()) + .map_err(naviscope_to_ingest_error)?; + } + + let requests = self + .resolver + .resolve_stubs(&operations, self.routes.as_ref()); + if !requests.is_empty() { + let mut pending = self + .pending_stubs + .lock() + .map_err(|_| IngestError::Execution("stub queue poisoned".to_string()))?; + pending.extend(requests); + } + } + + Ok(1) + } +} + +#[derive(Default)] +struct NoopRuntimeMetrics; + +impl RuntimeMetrics for NoopRuntimeMetrics { + fn observe_queue_depth(&self, _queue: &'static str, _depth: usize) {} + fn observe_throughput(&self, _stage: &'static str, _count: usize) {} + fn observe_latency_ms(&self, _stage: &'static str, _p95_ms: u64, _p99_ms: u64) {} + fn observe_replay_result(&self, _ok: bool) {} +} + +pub fn run_source_ingest( + base_graph: &CodeGraph, + initial_ops: Vec, + source_files: Vec, + resolver: Arc, + project_context: Arc, + routes: Arc>>, + lang_caps: Arc>, + runtime_config: RuntimeConfig, +) -> Result<(CodeGraph, Vec)> { + let mut builder = base_graph.to_builder(); + for caps in lang_caps.iter() { + if let Some(nc) = caps.presentation.naming_convention() { + builder.naming_conventions.insert(caps.language.clone(), nc); + } + } + builder.apply_ops(initial_ops)?; + + let shared_builder = Arc::new(Mutex::new(builder)); + let pending_stubs = Arc::new(Mutex::new(Vec::new())); + + let scheduler: naviscope_ingest::DynScheduler = + Arc::new(IndexWorkScheduler); + let executor: naviscope_ingest::DynExecutor = + Arc::new(IndexWorkExecutor { + resolver: Arc::clone(&resolver), + project_context, + }); + let deferred_store: naviscope_ingest::DynDeferredStore = + Arc::new(InMemoryDeferredQueue::default()); + let commit_sink_impl = Arc::new(IndexWorkCommitter { + builder: Arc::clone(&shared_builder), + pending_stubs: Arc::clone(&pending_stubs), + resolver, + routes, + }); + let commit_sink: naviscope_ingest::DynCommitSink = commit_sink_impl; + let metrics: naviscope_ingest::DynRuntimeMetrics = Arc::new(NoopRuntimeMetrics); + + let flow_config = FlowControlConfig::from(&runtime_config); + let bus = naviscope_ingest::TokioPipelineBus; + let channels = + >::open_channels( + &bus, + flow_config.channel_capacity, + ); + + let intake_tx = channels.intake_tx.clone(); + let rt = tokio::runtime::Handle::current(); + rt.block_on(async move { + let producer = async move { + for (index, file) in source_files.into_iter().enumerate() { + let msg = naviscope_ingest::Message { + msg_id: format!("src:{}", file.path().display()), + topic: "source-index".to_string(), + message_group: file.path().to_string_lossy().to_string(), + version: 1, + depends_on: Vec::new(), + epoch: index as u64, + payload: IndexWorkItem { file }, + metadata: BTreeMap::new(), + }; + intake_tx + .send(msg) + .await + .map_err(|_| IngestError::Execution("ingest intake closed".to_string()))?; + } + Ok::<(), IngestError>(()) + }; + + let consumer = kernel::run_pipeline( + channels, + scheduler, + executor, + deferred_store, + commit_sink, + metrics, + &flow_config, + ); + + let (_produced, _stats) = tokio::try_join!(producer, consumer)?; + Ok::<(), IngestError>(()) + }) + .map_err(ingest_to_naviscope_error)?; + + let graph = { + let mut guard = shared_builder + .lock() + .map_err(|_| NaviscopeError::Internal("graph builder poisoned".to_string()))?; + let builder = std::mem::take(&mut *guard); + builder.build() + }; + + let stubs = { + let mut guard = pending_stubs + .lock() + .map_err(|_| NaviscopeError::Internal("stub queue poisoned".to_string()))?; + std::mem::take(&mut *guard) + }; + + Ok((graph, stubs)) +} + +fn naviscope_to_ingest_error(err: NaviscopeError) -> IngestError { + IngestError::Execution(err.to_string()) +} + +fn ingest_to_naviscope_error(err: IngestError) -> NaviscopeError { + NaviscopeError::Internal(err.to_string()) +} diff --git a/crates/core/src/runtime/mod.rs b/crates/core/src/runtime/mod.rs index 13b95a4..d505a1e 100644 --- a/crates/core/src/runtime/mod.rs +++ b/crates/core/src/runtime/mod.rs @@ -1,3 +1,4 @@ +pub mod ingest_adapter; pub mod orchestrator; pub mod watcher; diff --git a/crates/core/src/runtime/orchestrator.rs b/crates/core/src/runtime/orchestrator.rs deleted file mode 100644 index 756b857..0000000 --- a/crates/core/src/runtime/orchestrator.rs +++ /dev/null @@ -1,875 +0,0 @@ -//! Core indexing engine with MVCC support - -use crate::asset::service::AssetStubService; -use crate::error::{NaviscopeError, Result}; -use crate::ingest::builder::CodeGraphBuilder; -use crate::ingest::resolver::{IndexResolver, StubRequest, StubbingManager}; -use crate::ingest::scanner::Scanner; -use crate::model::CodeGraph; -use crate::model::GraphOp; -use naviscope_plugin::{ - AssetDiscoverer, AssetEntry, AssetIndexer, AssetSource, AssetSourceLocator, BuildCaps, - LanguageCaps, NamingConvention, -}; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use tokio::sync::RwLock; -use xxhash_rust::xxh3::xxh3_64; - -/// Naviscope indexing engine -/// -/// Manages the current version of the code graph using MVCC: -/// - Readers get cheap snapshots (Arc clone) -/// - Writers create new versions and atomically swap -/// - No blocking during index updates -pub struct NaviscopeEngine { - /// Current version of the graph (double Arc for MVCC) - current: Arc>>, - - /// Project root path - project_root: PathBuf, - - /// Index storage path - index_path: PathBuf, - - /// Registered capabilities - build_caps: Arc>, - lang_caps: Arc>, - - /// Runtime registry: language name -> naming convention - naming_conventions: Arc>>, - - /// Cancellation token for background tasks (like watcher) - cancel_token: tokio_util::sync::CancellationToken, - - /// Background stubbing channel - stub_tx: tokio::sync::mpsc::UnboundedSender, - - /// Global stub cache - stub_cache: Arc, - - /// Global asset service (new architecture) - asset_service: Option>, -} - -pub struct NaviscopeEngineBuilder { - project_root: PathBuf, - build_caps: Vec, - lang_caps: Vec, -} - -impl NaviscopeEngineBuilder { - pub fn new(project_root: PathBuf) -> Self { - Self { - project_root, - build_caps: Vec::new(), - lang_caps: Vec::new(), - } - } - - pub fn with_language_caps(mut self, caps: LanguageCaps) -> Self { - self.lang_caps.push(caps); - self - } - - pub fn with_build_caps(mut self, caps: BuildCaps) -> Self { - self.build_caps.push(caps); - self - } - - pub fn build(self) -> NaviscopeEngine { - let canonical_root = self - .project_root - .canonicalize() - .unwrap_or_else(|_| self.project_root.clone()); - let index_path = NaviscopeEngine::compute_index_path(&canonical_root); - - let (stub_tx, stub_rx) = tokio::sync::mpsc::unbounded_channel(); - let cancel_token = tokio_util::sync::CancellationToken::new(); - // Initialize global cache once - let stub_cache = Arc::new(crate::cache::GlobalStubCache::at_default_location()); - - // Process naming conventions - let mut conventions = HashMap::new(); - for caps in &self.lang_caps { - if let Some(nc) = caps.presentation.naming_convention() { - conventions.insert(caps.language.to_string(), nc); - } - } - - // Collect asset indexers from language plugins - let indexers: Vec> = self - .lang_caps - .iter() - .filter_map(|c| c.asset.asset_indexer()) - .collect(); - - // Collect asset discoverers from all plugins - let mut discoverers: Vec> = Vec::new(); - - // From language plugins (e.g., JdkDiscoverer from Java) - for caps in &self.lang_caps { - if let Some(d) = caps.asset.global_asset_discoverer() { - discoverers.push(d); - } - } - - // From build tool plugins (e.g., GradleCacheDiscoverer from Gradle) - for caps in &self.build_caps { - if let Some(d) = caps.asset.global_asset_discoverer() { - discoverers.push(d); - } - } - - // Collect asset source locators from all plugins - let mut source_locators: Vec> = Vec::new(); - for caps in &self.lang_caps { - if let Some(locator) = caps.asset.asset_source_locator() { - source_locators.push(locator); - } - } - for caps in &self.build_caps { - if let Some(locator) = caps.asset.asset_source_locator() { - source_locators.push(locator); - } - } - - // Project-local asset discoverers (optional hook) - for caps in &self.lang_caps { - if let Some(d) = caps.asset.project_asset_discoverer(&canonical_root) { - discoverers.push(d); - } - } - - for caps in &self.build_caps { - if let Some(d) = caps.asset.project_asset_discoverer(&canonical_root) { - discoverers.push(d); - } - } - - // Create asset service with discoverers from plugins - let asset_service = if !indexers.is_empty() && !discoverers.is_empty() { - Some(Arc::new(AssetStubService::new( - discoverers, - indexers, - vec![], // Generators will be added later - source_locators, - ))) - } else { - None - }; - - let engine = NaviscopeEngine { - current: Arc::new(RwLock::new(Arc::new(CodeGraph::empty()))), - project_root: canonical_root, - index_path, - build_caps: Arc::new(self.build_caps), - lang_caps: Arc::new(self.lang_caps), - naming_conventions: Arc::new(conventions), - cancel_token: cancel_token.clone(), - stub_tx, - stub_cache: stub_cache.clone(), - asset_service, - }; - - engine.spawn_stub_worker(stub_rx, cancel_token, stub_cache); - - engine - } -} - -impl Drop for NaviscopeEngine { - fn drop(&mut self) { - self.cancel_token.cancel(); - } -} - -impl NaviscopeEngine { - /// Create a builder for the engine - pub fn builder(project_root: PathBuf) -> NaviscopeEngineBuilder { - NaviscopeEngineBuilder::new(project_root) - } - - // ... helper methods ... - - /// Get the project root path - pub fn root_path(&self) -> &Path { - &self.project_root - } - - /// Get the index resolver configured with current plugins - pub fn get_resolver(&self) -> IndexResolver { - IndexResolver::with_caps((*self.build_caps).clone(), (*self.lang_caps).clone()) - .with_stubbing(StubbingManager::new(self.stub_tx.clone())) - } - - /// Get naming conventions registry (cheap Arc clone) - pub(crate) fn naming_conventions( - &self, - ) -> Arc>> { - self.naming_conventions.clone() - } - - /// Get the asset service (if available) - pub fn asset_service(&self) -> Option<&Arc> { - self.asset_service.as_ref() - } - - /// Request on-demand stub generation for a single FQN. - /// Returns true if a request was successfully enqueued. - pub fn request_stub_for_fqn(&self, fqn: &str) -> bool { - let Some(service) = &self.asset_service else { - return false; - }; - let Some(candidate_paths) = service.lookup_paths(fqn) else { - return false; - }; - if candidate_paths.is_empty() { - return false; - } - self.stub_tx - .send(StubRequest { - fqn: fqn.to_string(), - candidate_paths, - }) - .is_ok() - } - - /// Run the global asset scan and populate routes - /// Returns the scan result with statistics - pub async fn scan_global_assets(&self) -> Option { - if let Some(service) = &self.asset_service { - let service = service.clone(); - let result = tokio::task::spawn_blocking(move || service.scan_sync()) - .await - .ok(); - result - } else { - None - } - } - - /// Get global asset routes snapshot (for passing to resolvers) - pub fn global_asset_routes(&self) -> HashMap> { - if let Some(service) = &self.asset_service { - service.routes_snapshot() - } else { - HashMap::new() - } - } - - /// Compute index storage path for a project - fn compute_index_path(project_root: &Path) -> PathBuf { - let base_dir = Self::get_base_index_dir(); - let abs_path = project_root - .canonicalize() - .unwrap_or_else(|_| project_root.to_path_buf()); - let hash = xxh3_64(abs_path.to_string_lossy().as_bytes()); - base_dir.join(format!("{:016x}.bin", hash)) - } - - /// Get a snapshot of the current graph (cheap operation) - pub async fn snapshot(&self) -> CodeGraph { - let lock = self.current.read().await; - (**lock).clone() // CodeGraph clone is Arc clone of inner - } - - /// Load index from disk - pub async fn load(&self) -> Result { - let path = self.index_path.clone(); - let lang_caps = self.lang_caps.clone(); - let build_caps = self.build_caps.clone(); - - // Load in blocking pool - let graph_opt = - tokio::task::spawn_blocking(move || Self::load_from_disk(&path, lang_caps, build_caps)) - .await - .map_err(|e| NaviscopeError::Internal(e.to_string()))??; - - if let Some(graph) = graph_opt { - // Atomically update current - let mut lock = self.current.write().await; - *lock = Arc::new(graph); - Ok(true) - } else { - Ok(false) - } - } - - /// Save current graph to disk - pub async fn save(&self) -> Result<()> { - let graph = self.snapshot().await; - let path = self.index_path.clone(); - let lang_caps = self.lang_caps.clone(); - let build_caps = self.build_caps.clone(); - - tokio::task::spawn_blocking(move || { - Self::save_to_disk(&graph, &path, lang_caps, build_caps) - }) - .await - .map_err(|e| NaviscopeError::Internal(e.to_string()))? - } - - /// Rebuild the index from scratch - pub async fn rebuild(&self) -> Result<()> { - let _ = self.scan_global_assets().await; - let project_root = self.project_root.clone(); - let build_caps = self.build_caps.clone(); - let lang_caps = self.lang_caps.clone(); - let global_routes = self.global_asset_routes(); - - let stub_tx = self.stub_tx.clone(); - let (new_graph, stubs) = tokio::task::spawn_blocking(move || { - Self::build_index(&project_root, build_caps, lang_caps, stub_tx, global_routes) - }) - .await - .map_err(|e| NaviscopeError::Internal(e.to_string()))??; - - // Atomically update (write lock held for microseconds) - { - let mut lock = self.current.write().await; - *lock = Arc::new(new_graph); - } - - // Schedule stubs AFTER graph update using explicit requests - for req in stubs { - // We use a fresh stub_tx here, actually we need access to self.stub_tx? - // self.stub_tx is async channel. send is async or blocking? - // UnboundedSender::send is non-blocking. - if let Err(e) = self.stub_tx.send(req.clone()) { - tracing::warn!("Failed to schedule stub: {}", e); - } - } - - // Save to disk - self.save().await?; - - Ok(()) - } - - /// Update specific files incrementally - pub async fn update_files(&self, files: Vec) -> Result<()> { - let _ = self.scan_global_assets().await; - let base_graph = self.snapshot().await; - let build_caps = self.build_caps.clone(); - let lang_caps = self.lang_caps.clone(); - let global_routes = Arc::new(self.global_asset_routes()); - - // Prepare existing file metadata for change detection - let mut existing_metadata = std::collections::HashMap::new(); - for (path, entry) in base_graph.file_index() { - existing_metadata.insert( - PathBuf::from(base_graph.symbols().resolve(&path.0)), - entry.metadata.clone(), - ); - } - - let current_lock = self.current.clone(); - let stub_tx = self.stub_tx.clone(); - - // Processing in blocking pool - tokio::task::spawn_blocking(move || -> Result<()> { - let mut manual_ops = Vec::new(); - let mut to_scan = Vec::new(); - - for path in files { - if path.exists() { - to_scan.push(path); - } else { - // File was deleted - manual_ops.push(GraphOp::RemovePath { - path: Arc::from(path.as_path()), - }); - } - } - - // 1. Initial scan to identify file types and changes - let scan_results = Scanner::scan_files(to_scan, &existing_metadata); - if scan_results.is_empty() && manual_ops.is_empty() { - return Ok(()); - } - - // Partition into build and source - let (build_files, source_files): (Vec<_>, Vec<_>) = - scan_results.into_iter().partition(|f| f.is_build()); - - let resolver = IndexResolver::with_caps((*build_caps).clone(), (*lang_caps).clone()) - .with_stubbing(StubbingManager::new(stub_tx.clone())); - - // 2. Phase 1: Heavy Build Resolution (Global Context) - let mut project_context_inner = crate::ingest::resolver::ProjectContext::new(); - let mut initial_ops = manual_ops; - - // IMPORTANT: RemovePath MUST come before AddNode for the same paths. - // Add RemovePath and UpdateFile for build files up front. - for bf in &build_files { - initial_ops.push(GraphOp::RemovePath { - path: Arc::from(bf.path()), - }); - initial_ops.push(GraphOp::UpdateFile { - metadata: bf.file.clone(), - }); - } - - // For build files, we still process them up front because they define the structure - let build_ops = - resolver.resolve_build_batch(&build_files, &mut project_context_inner)?; - initial_ops.extend(build_ops); - - let project_context = Arc::new(project_context_inner); - let routes = global_routes.clone(); - - // 3. Phase 2: Pipeline Batch Processing for source files - let pipeline = crate::ingest::pipeline::IngestPipeline::new(500); // 500 files per batch - let source_paths: Vec = source_files - .into_iter() - .map(|f| f.path().to_path_buf()) - .collect(); - - let mut builder = base_graph.to_builder(); - - // Register naming conventions - for caps in lang_caps.iter() { - if let Some(nc) = caps.presentation.naming_convention() { - builder.naming_conventions.insert(caps.language.clone(), nc); - } - } - - builder.apply_ops(initial_ops)?; - - let mut pending_stubs = Vec::new(); - // Note: We are in a blocking thread, resolver and context are Thread-safe. - pipeline.execute(&*project_context, source_paths, &resolver, |batch_ops| { - builder.apply_ops(batch_ops.clone())?; - let reqs = resolver.resolve_stubs(&batch_ops, routes.as_ref()); - pending_stubs.extend(reqs); - Ok(()) - })?; - - // 4. Final Swap - let final_graph = Arc::new(builder.build()); - let rt = tokio::runtime::Handle::current(); - rt.block_on(async { - let mut lock = current_lock.write().await; - *lock = final_graph; - }); - - // 5. Schedule stubs - for req in pending_stubs { - if let Err(e) = stub_tx.send(req) { - tracing::warn!("Failed to schedule stub: {}", e); - } - } - - Ok(()) - }) - .await - .map_err(|e| crate::error::NaviscopeError::Internal(e.to_string()))??; - - // Save at the very end - self.save().await?; - - Ok(()) - } - - /// Refresh index (detect changes and update) - pub async fn refresh(&self) -> Result<()> { - let project_root = self.project_root.clone(); - - // Scan for all current files and update incrementally - let paths = tokio::task::spawn_blocking(move || Scanner::collect_paths(&project_root)) - .await - .map_err(|e| NaviscopeError::Internal(e.to_string()))?; - - self.update_files(paths).await - } - - /// Watch for filesystem changes and update incrementally. - /// The watcher task exits when `cancel_token` is cancelled. - pub async fn start_watch_with_token( - self: Arc, - cancel_token: tokio_util::sync::CancellationToken, - ) -> Result<()> { - use crate::runtime::watcher::Watcher; - use std::collections::HashSet; - use std::time::Duration; - - let root = self.project_root.clone(); - let mut watcher = - Watcher::new(&root).map_err(|e| NaviscopeError::Internal(e.to_string()))?; - - let engine_weak = Arc::downgrade(&self); - - tokio::spawn(async move { - tracing::info!("Started watching {}", root.display()); - let mut pending_events: Vec = Vec::new(); - let debounce_interval = Duration::from_millis(500); - - loop { - tokio::select! { - _ = cancel_token.cancelled() => { - break; - } - event = watcher.next_event_async() => { - match event { - Some(e) => pending_events.push(e), - None => break, - } - } - _ = tokio::time::sleep(debounce_interval), if !pending_events.is_empty() => { - let mut paths = HashSet::new(); - for event in &pending_events { - for path in &event.paths { - if crate::ingest::is_relevant_path(path) { - paths.insert(path.clone()); - } - } - } - pending_events.clear(); - - if !paths.is_empty() { - if let Some(engine) = engine_weak.upgrade() { - let path_vec: Vec<_> = paths.into_iter().collect(); - tracing::info!("Detected changes in {} files. Updating...", path_vec.len()); - if let Err(err) = engine.update_files(path_vec).await { - tracing::error!("Failed to update files: {}", err); - } - } else { - break; - } - } - } - } - } - tracing::info!("File watcher task ended for {}", root.display()); - }); - - Ok(()) - } - - /// Backward-compatible helper that uses the engine-wide cancellation token. - pub async fn watch(self: Arc) -> Result<()> { - let cancel_token = self.cancel_token.clone(); - self.start_watch_with_token(cancel_token).await - } - - /// Start the background stubbing worker - fn spawn_stub_worker( - &self, - mut rx: tokio::sync::mpsc::UnboundedReceiver, - cancel_token: tokio_util::sync::CancellationToken, - stub_cache: Arc, - ) { - let current = self.current.clone(); - let lang_caps = self.lang_caps.clone(); - let naming_conventions = self.naming_conventions.clone(); - - tokio::spawn(async move { - tracing::info!("Stubbing worker started"); - let mut seen_fqns = std::collections::HashSet::new(); - - loop { - tokio::select! { - _ = cancel_token.cancelled() => break, - Some(req) = rx.recv() => { - // Skip if already seen in this session to avoid redundant work - if !seen_fqns.insert(req.fqn.clone()) { - continue; - } - - // Check if node already exists and is resolved - { - let lock = current.read().await; - let graph = &**lock; - if let Some(idx) = graph.find_node(&req.fqn) { - if let Some(node) = graph.get_node(idx) { - if node.status == naviscope_api::models::graph::ResolutionStatus::Resolved { - continue; - } - } - } - } - - // Resolve - let mut ops = Vec::new(); - - for asset_path in req.candidate_paths { - // Try to create asset key for cache lookup - let asset_key = crate::cache::AssetKey::from_path(&asset_path).ok(); - - // Check cache first - if let Some(ref key) = asset_key { - if let Some(cached_stub) = stub_cache.lookup(key, &req.fqn) { - tracing::trace!("Cache hit for {}", req.fqn); - ops.push(GraphOp::AddNode { - data: Some(cached_stub), - }); - break; // Found it - } - } - - // If not in cache, generate stub - for caps in lang_caps.iter() { - let Some(generator) = caps.asset.stub_generator() else { - continue; - }; - if !generator.can_generate(&asset_path) { - continue; - } - - let entry = - AssetEntry::new(asset_path.clone(), AssetSource::Unknown); - match generator.generate(&req.fqn, &entry) { - Ok(stub) => { - // Store in cache for future use - if let Some(ref key) = asset_key { - stub_cache.store(key, &stub); - tracing::trace!("Cached stub for {}", req.fqn); - } - ops.push(GraphOp::AddNode { data: Some(stub) }); - break; - } - Err(e) => { - tracing::debug!( - "Failed to generate stub for {}: {}", - req.fqn, - e - ); - } - } - } - - if !ops.is_empty() { - break; - } - } - - if !ops.is_empty() { - let mut lock = current.write().await; - let mut builder = (**lock).to_builder(); - - // Load naming conventions - let conventions = (*naming_conventions).clone(); - for (lang, nc) in conventions { - builder.naming_conventions.insert(naviscope_api::models::Language::from(lang), nc); - } - - if let Ok(()) = builder.apply_ops(ops) { - *lock = Arc::new(builder.build()); - tracing::trace!("Applied stub for {}", req.fqn); - } - } - } - } - } - tracing::info!("Stubbing worker stopped"); - }); - } - - /// Clear the index for the current project - pub async fn clear_project_index(&self) -> Result<()> { - let path = self.index_path.clone(); - if path.exists() { - tokio::fs::remove_file(path).await?; - } - - // Reset current graph - let mut lock = self.current.write().await; - *lock = Arc::new(CodeGraph::empty()); - - Ok(()) - } - - /// Clear all indices - pub fn clear_all_indices() -> Result<()> { - let base_dir = Self::get_base_index_dir(); - if base_dir.exists() { - std::fs::remove_dir_all(&base_dir)?; - } - Ok(()) - } - - /// Gets the base directory for storing indices, supporting NAVISCOPE_INDEX_DIR env var. - pub fn get_base_index_dir() -> PathBuf { - if let Ok(env_dir) = std::env::var("NAVISCOPE_INDEX_DIR") { - return PathBuf::from(env_dir); - } - - let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); - Path::new(&home).join(super::DEFAULT_INDEX_DIR) - } - - // ---- Helper methods ---- - - fn load_from_disk( - path: &Path, - lang_caps: Arc>, - build_caps: Arc>, - ) -> Result> { - if !path.exists() { - return Ok(None); - } - - let bytes = std::fs::read(path)?; - - let get_codec = |lang: &str| -> Option> { - for caps in lang_caps.iter() { - if caps.language.as_str() == lang { - return caps.metadata_codec.metadata_codec(); - } - } - for caps in build_caps.iter() { - if caps.build_tool.as_str() == lang { - return caps.metadata_codec.metadata_codec(); - } - } - None - }; - - match CodeGraph::deserialize(&bytes, get_codec) { - Ok(graph) => { - if graph.version() != crate::model::graph::CURRENT_VERSION { - tracing::warn!( - "Index version mismatch at {} (found {}, expected {}). Will rebuild.", - path.display(), - graph.version(), - crate::model::graph::CURRENT_VERSION - ); - let _ = std::fs::remove_file(path); - return Ok(None); - } - tracing::info!("Loaded index from {}", path.display()); - Ok(Some(graph)) - } - Err(e) => { - tracing::warn!( - "Failed to parse index at {}: {:?}. Will rebuild.", - path.display(), - e - ); - let _ = std::fs::remove_file(path); - Ok(None) - } - } - } - - fn save_to_disk( - graph: &CodeGraph, - path: &Path, - lang_caps: Arc>, - build_caps: Arc>, - ) -> Result<()> { - // Ensure directory exists - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - - let get_codec = |lang: &str| -> Option> { - for caps in lang_caps.iter() { - if caps.language.as_str() == lang { - return caps.metadata_codec.metadata_codec(); - } - } - for caps in build_caps.iter() { - if caps.build_tool.as_str() == lang { - return caps.metadata_codec.metadata_codec(); - } - } - None - }; - - // Serialize the graph - let bytes = graph.serialize(get_codec)?; - - // Write to file atomically (write to temp, then rename) - let temp_path = path.with_extension("tmp"); - std::fs::write(&temp_path, bytes)?; - std::fs::rename(temp_path, path)?; - - tracing::info!("Saved index to {}", path.display()); - - Ok(()) - } - - fn build_index( - project_root: &Path, - build_caps: Arc>, - lang_caps: Arc>, - stub_tx: tokio::sync::mpsc::UnboundedSender, - global_routes: HashMap>, - ) -> Result<(CodeGraph, Vec)> { - // Scan and parse - let parse_results = - Scanner::scan_and_parse(project_root, &std::collections::HashMap::new()); - - // Resolve - let resolver = IndexResolver::with_caps((*build_caps).clone(), (*lang_caps).clone()) - .with_stubbing(StubbingManager::new(stub_tx)); - - // resolve() now returns both ops and the filled ProjectContext - let (ops, _project_context) = resolver.resolve(parse_results)?; - - // Build graph - let mut builder = CodeGraphBuilder::new(); - - // Register naming conventions - for caps in lang_caps.iter() { - if let Some(nc) = caps.presentation.naming_convention() { - builder.naming_conventions.insert(caps.language.clone(), nc); - } - } - - builder.apply_ops(ops.clone())?; - - let stubs = resolver.resolve_stubs(&ops, &global_routes); - - Ok((builder.build(), stubs)) - } - - pub fn get_stub_cache(&self) -> Arc { - self.stub_cache.clone() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_snapshot_is_fast() { - let engine = NaviscopeEngine::builder(PathBuf::from(".")).build(); - - let start = std::time::Instant::now(); - for _ in 0..1000 { - let _graph = engine.snapshot().await; - } - let elapsed = start.elapsed(); - - // 1000 snapshots should be very fast - assert!(elapsed.as_millis() < 100, "Snapshots should be fast"); - } - - #[tokio::test] - async fn test_concurrent_snapshots() { - use tokio::task::JoinSet; - - let engine = Arc::new(NaviscopeEngine::builder(PathBuf::from(".")).build()); - - let mut set = JoinSet::new(); - - for _ in 0..10 { - let e = Arc::clone(&engine); - set.spawn(async move { - for _ in 0..10 { - let graph = e.snapshot().await; - assert_eq!(graph.node_count(), 0); - } - }); - } - - while let Some(result) = set.join_next().await { - result.unwrap(); - } - } -} diff --git a/crates/core/src/runtime/orchestrator/indexing.rs b/crates/core/src/runtime/orchestrator/indexing.rs new file mode 100644 index 0000000..4ba2a84 --- /dev/null +++ b/crates/core/src/runtime/orchestrator/indexing.rs @@ -0,0 +1,202 @@ +use super::*; + +impl NaviscopeEngine { + /// Load index from disk + pub async fn load(&self) -> Result { + let path = self.index_path.clone(); + let lang_caps = self.lang_caps.clone(); + let build_caps = self.build_caps.clone(); + + // Load in blocking pool + let graph_opt = + tokio::task::spawn_blocking(move || Self::load_from_disk(&path, lang_caps, build_caps)) + .await + .map_err(|e| NaviscopeError::Internal(e.to_string()))??; + + if let Some(graph) = graph_opt { + // Atomically update current + let mut lock = self.current.write().await; + *lock = Arc::new(graph); + Ok(true) + } else { + Ok(false) + } + } + + /// Save current graph to disk + pub async fn save(&self) -> Result<()> { + let graph = self.snapshot().await; + let path = self.index_path.clone(); + let lang_caps = self.lang_caps.clone(); + let build_caps = self.build_caps.clone(); + + tokio::task::spawn_blocking(move || { + Self::save_to_disk(&graph, &path, lang_caps, build_caps) + }) + .await + .map_err(|e| NaviscopeError::Internal(e.to_string()))? + } + + /// Rebuild the index from scratch + pub async fn rebuild(&self) -> Result<()> { + let _ = self.scan_global_assets().await; + let project_root = self.project_root.clone(); + let build_caps = self.build_caps.clone(); + let lang_caps = self.lang_caps.clone(); + let global_routes = self.global_asset_routes(); + + let stub_tx = self.stub_tx.clone(); + let (new_graph, stubs) = tokio::task::spawn_blocking(move || { + Self::build_index(&project_root, build_caps, lang_caps, stub_tx, global_routes) + }) + .await + .map_err(|e| NaviscopeError::Internal(e.to_string()))??; + + // Atomically update (write lock held for microseconds) + { + let mut lock = self.current.write().await; + *lock = Arc::new(new_graph); + } + + // Schedule stubs AFTER graph update using explicit requests + for req in stubs { + if let Err(e) = self.stub_tx.send(req.clone()) { + tracing::warn!("Failed to schedule stub: {}", e); + } + } + + // Save to disk + self.save().await?; + + Ok(()) + } + + /// Update specific files incrementally + pub async fn update_files(&self, files: Vec) -> Result<()> { + let _ = self.scan_global_assets().await; + let base_graph = self.snapshot().await; + let build_caps = self.build_caps.clone(); + let lang_caps = self.lang_caps.clone(); + let global_routes = Arc::new(self.global_asset_routes()); + + // Prepare existing file metadata for change detection + let mut existing_metadata = std::collections::HashMap::new(); + for (path, entry) in base_graph.file_index() { + existing_metadata.insert( + PathBuf::from(base_graph.symbols().resolve(&path.0)), + entry.metadata.clone(), + ); + } + + let current_lock = self.current.clone(); + let stub_tx = self.stub_tx.clone(); + + // Processing in blocking pool + tokio::task::spawn_blocking(move || -> Result<()> { + let mut manual_ops = Vec::new(); + let mut to_scan = Vec::new(); + + for path in files { + if path.exists() { + to_scan.push(path); + } else { + // File was deleted + manual_ops.push(GraphOp::RemovePath { + path: Arc::from(path.as_path()), + }); + } + } + + // 1. Initial scan to identify file types and changes + let scan_results = Scanner::scan_files(to_scan, &existing_metadata); + if scan_results.is_empty() && manual_ops.is_empty() { + return Ok(()); + } + + // Partition into build and source + let (build_files, source_files): (Vec<_>, Vec<_>) = + scan_results.into_iter().partition(|f| f.is_build()); + + let resolver = Arc::new( + IndexResolver::with_caps((*build_caps).clone(), (*lang_caps).clone()) + .with_stubbing(StubbingManager::new(stub_tx.clone())), + ); + + // 2. Phase 1: Heavy Build Resolution (Global Context) + let mut project_context_inner = crate::ingest::resolver::ProjectContext::new(); + let mut initial_ops = manual_ops; + + // IMPORTANT: RemovePath MUST come before AddNode for the same paths. + // Add RemovePath and UpdateFile for build files up front. + for bf in &build_files { + initial_ops.push(GraphOp::RemovePath { + path: Arc::from(bf.path()), + }); + initial_ops.push(GraphOp::UpdateFile { + metadata: bf.file.clone(), + }); + } + + // For build files, we still process them up front because they define the structure + let build_ops = + resolver.resolve_build_batch(&build_files, &mut project_context_inner)?; + initial_ops.extend(build_ops); + + let project_context = Arc::new(project_context_inner); + let routes = global_routes.clone(); + + // 3. Phase 2: Source processing via naviscope-ingest streaming runtime. + let (final_graph, pending_stubs) = crate::runtime::ingest_adapter::run_source_ingest( + &base_graph, + initial_ops, + source_files, + Arc::clone(&resolver), + project_context, + routes, + lang_caps, + naviscope_ingest::RuntimeConfig { + kernel_channel_capacity: 500, + max_in_flight: 256, + deferred_poll_limit: 256, + idle_sleep_ms: 10, + }, + )?; + + // 4. Final Swap + let final_graph = Arc::new(final_graph); + let rt = tokio::runtime::Handle::current(); + rt.block_on(async { + let mut lock = current_lock.write().await; + *lock = final_graph; + }); + + // 5. Schedule stubs + for req in pending_stubs { + if let Err(e) = stub_tx.send(req) { + tracing::warn!("Failed to schedule stub: {}", e); + } + } + + Ok(()) + }) + .await + .map_err(|e| crate::error::NaviscopeError::Internal(e.to_string()))??; + + // Save at the very end + self.save().await?; + + Ok(()) + } + + /// Refresh index (detect changes and update) + pub async fn refresh(&self) -> Result<()> { + let project_root = self.project_root.clone(); + + // Scan for all current files and update incrementally + let paths = tokio::task::spawn_blocking(move || Scanner::collect_paths(&project_root)) + .await + .map_err(|e| NaviscopeError::Internal(e.to_string()))?; + + self.update_files(paths).await + } +} diff --git a/crates/core/src/runtime/orchestrator/mod.rs b/crates/core/src/runtime/orchestrator/mod.rs new file mode 100644 index 0000000..020b29b --- /dev/null +++ b/crates/core/src/runtime/orchestrator/mod.rs @@ -0,0 +1,321 @@ +//! Core indexing engine with MVCC support + +use crate::asset::service::AssetStubService; +use crate::error::{NaviscopeError, Result}; +use crate::ingest::builder::CodeGraphBuilder; +use crate::ingest::resolver::{IndexResolver, StubRequest, StubbingManager}; +use crate::ingest::scanner::Scanner; +use crate::model::{CodeGraph, GraphOp}; +use naviscope_plugin::{ + AssetDiscoverer, AssetEntry, AssetIndexer, AssetSource, AssetSourceLocator, BuildCaps, + LanguageCaps, NamingConvention, +}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tokio::sync::RwLock; +use xxhash_rust::xxh3::xxh3_64; + +mod indexing; +mod storage; +mod stub_worker; +mod watch; + +/// Naviscope indexing engine +/// +/// Manages the current version of the code graph using MVCC: +/// - Readers get cheap snapshots (Arc clone) +/// - Writers create new versions and atomically swap +/// - No blocking during index updates +pub struct NaviscopeEngine { + /// Current version of the graph (double Arc for MVCC) + current: Arc>>, + + /// Project root path + project_root: PathBuf, + + /// Index storage path + index_path: PathBuf, + + /// Registered capabilities + build_caps: Arc>, + lang_caps: Arc>, + + /// Runtime registry: language name -> naming convention + naming_conventions: Arc>>, + + /// Cancellation token for background tasks (like watcher) + cancel_token: tokio_util::sync::CancellationToken, + + /// Background stubbing channel + stub_tx: tokio::sync::mpsc::UnboundedSender, + + /// Global stub cache + stub_cache: Arc, + + /// Global asset service (new architecture) + asset_service: Option>, +} + +pub struct NaviscopeEngineBuilder { + project_root: PathBuf, + build_caps: Vec, + lang_caps: Vec, +} + +impl NaviscopeEngineBuilder { + pub fn new(project_root: PathBuf) -> Self { + Self { + project_root, + build_caps: Vec::new(), + lang_caps: Vec::new(), + } + } + + pub fn with_language_caps(mut self, caps: LanguageCaps) -> Self { + self.lang_caps.push(caps); + self + } + + pub fn with_build_caps(mut self, caps: BuildCaps) -> Self { + self.build_caps.push(caps); + self + } + + pub fn build(self) -> NaviscopeEngine { + let canonical_root = self + .project_root + .canonicalize() + .unwrap_or_else(|_| self.project_root.clone()); + let index_path = NaviscopeEngine::compute_index_path(&canonical_root); + + let (stub_tx, stub_rx) = tokio::sync::mpsc::unbounded_channel(); + let cancel_token = tokio_util::sync::CancellationToken::new(); + // Initialize global cache once + let stub_cache = Arc::new(crate::cache::GlobalStubCache::at_default_location()); + + // Process naming conventions + let mut conventions = HashMap::new(); + for caps in &self.lang_caps { + if let Some(nc) = caps.presentation.naming_convention() { + conventions.insert(caps.language.to_string(), nc); + } + } + + // Collect asset indexers from language plugins + let indexers: Vec> = self + .lang_caps + .iter() + .filter_map(|c| c.asset.asset_indexer()) + .collect(); + + // Collect asset discoverers from all plugins + let mut discoverers: Vec> = Vec::new(); + + // From language plugins (e.g., JdkDiscoverer from Java) + for caps in &self.lang_caps { + if let Some(d) = caps.asset.global_asset_discoverer() { + discoverers.push(d); + } + } + + // From build tool plugins (e.g., GradleCacheDiscoverer from Gradle) + for caps in &self.build_caps { + if let Some(d) = caps.asset.global_asset_discoverer() { + discoverers.push(d); + } + } + + // Collect asset source locators from all plugins + let mut source_locators: Vec> = Vec::new(); + for caps in &self.lang_caps { + if let Some(locator) = caps.asset.asset_source_locator() { + source_locators.push(locator); + } + } + for caps in &self.build_caps { + if let Some(locator) = caps.asset.asset_source_locator() { + source_locators.push(locator); + } + } + + // Project-local asset discoverers (optional hook) + for caps in &self.lang_caps { + if let Some(d) = caps.asset.project_asset_discoverer(&canonical_root) { + discoverers.push(d); + } + } + + for caps in &self.build_caps { + if let Some(d) = caps.asset.project_asset_discoverer(&canonical_root) { + discoverers.push(d); + } + } + + // Create asset service with discoverers from plugins + let asset_service = if !indexers.is_empty() && !discoverers.is_empty() { + Some(Arc::new(AssetStubService::new( + discoverers, + indexers, + vec![], // Generators will be added later + source_locators, + ))) + } else { + None + }; + + let engine = NaviscopeEngine { + current: Arc::new(RwLock::new(Arc::new(CodeGraph::empty()))), + project_root: canonical_root, + index_path, + build_caps: Arc::new(self.build_caps), + lang_caps: Arc::new(self.lang_caps), + naming_conventions: Arc::new(conventions), + cancel_token: cancel_token.clone(), + stub_tx, + stub_cache: stub_cache.clone(), + asset_service, + }; + + engine.spawn_stub_worker(stub_rx, cancel_token, stub_cache); + + engine + } +} + +impl Drop for NaviscopeEngine { + fn drop(&mut self) { + self.cancel_token.cancel(); + } +} + +impl NaviscopeEngine { + /// Create a builder for the engine + pub fn builder(project_root: PathBuf) -> NaviscopeEngineBuilder { + NaviscopeEngineBuilder::new(project_root) + } + + /// Get the project root path + pub fn root_path(&self) -> &Path { + &self.project_root + } + + /// Get the index resolver configured with current plugins + pub fn get_resolver(&self) -> IndexResolver { + IndexResolver::with_caps((*self.build_caps).clone(), (*self.lang_caps).clone()) + .with_stubbing(StubbingManager::new(self.stub_tx.clone())) + } + + /// Get naming conventions registry (cheap Arc clone) + pub(crate) fn naming_conventions( + &self, + ) -> Arc>> { + self.naming_conventions.clone() + } + + /// Get the asset service (if available) + pub fn asset_service(&self) -> Option<&Arc> { + self.asset_service.as_ref() + } + + /// Request on-demand stub generation for a single FQN. + /// Returns true if a request was successfully enqueued. + pub fn request_stub_for_fqn(&self, fqn: &str) -> bool { + let Some(service) = &self.asset_service else { + return false; + }; + let Some(candidate_paths) = service.lookup_paths(fqn) else { + return false; + }; + if candidate_paths.is_empty() { + return false; + } + self.stub_tx + .send(StubRequest { + fqn: fqn.to_string(), + candidate_paths, + }) + .is_ok() + } + + /// Run the global asset scan and populate routes + /// Returns the scan result with statistics + pub async fn scan_global_assets(&self) -> Option { + if let Some(service) = &self.asset_service { + let service = service.clone(); + let result = tokio::task::spawn_blocking(move || service.scan_sync()) + .await + .ok(); + result + } else { + None + } + } + + /// Get global asset routes snapshot (for passing to resolvers) + pub fn global_asset_routes(&self) -> HashMap> { + if let Some(service) = &self.asset_service { + service.routes_snapshot() + } else { + HashMap::new() + } + } + + /// Compute index storage path for a project + fn compute_index_path(project_root: &Path) -> PathBuf { + let base_dir = Self::get_base_index_dir(); + let abs_path = project_root + .canonicalize() + .unwrap_or_else(|_| project_root.to_path_buf()); + let hash = xxh3_64(abs_path.to_string_lossy().as_bytes()); + base_dir.join(format!("{:016x}.bin", hash)) + } + + /// Get a snapshot of the current graph (cheap operation) + pub async fn snapshot(&self) -> CodeGraph { + let lock = self.current.read().await; + (**lock).clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_snapshot_is_fast() { + let engine = NaviscopeEngine::builder(PathBuf::from(".")).build(); + + let start = std::time::Instant::now(); + for _ in 0..1000 { + let _graph = engine.snapshot().await; + } + let elapsed = start.elapsed(); + + // 1000 snapshots should be very fast + assert!(elapsed.as_millis() < 100, "Snapshots should be fast"); + } + + #[tokio::test] + async fn test_concurrent_snapshots() { + use tokio::task::JoinSet; + + let engine = Arc::new(NaviscopeEngine::builder(PathBuf::from(".")).build()); + + let mut set = JoinSet::new(); + + for _ in 0..10 { + let e = Arc::clone(&engine); + set.spawn(async move { + for _ in 0..10 { + let graph = e.snapshot().await; + assert_eq!(graph.node_count(), 0); + } + }); + } + + while let Some(result) = set.join_next().await { + result.unwrap(); + } + } +} diff --git a/crates/core/src/runtime/orchestrator/storage.rs b/crates/core/src/runtime/orchestrator/storage.rs new file mode 100644 index 0000000..62b8fe1 --- /dev/null +++ b/crates/core/src/runtime/orchestrator/storage.rs @@ -0,0 +1,167 @@ +use super::*; + +impl NaviscopeEngine { + /// Clear the index for the current project + pub async fn clear_project_index(&self) -> Result<()> { + let path = self.index_path.clone(); + if path.exists() { + tokio::fs::remove_file(path).await?; + } + + // Reset current graph + let mut lock = self.current.write().await; + *lock = Arc::new(CodeGraph::empty()); + + Ok(()) + } + + /// Clear all indices + pub fn clear_all_indices() -> Result<()> { + let base_dir = Self::get_base_index_dir(); + if base_dir.exists() { + std::fs::remove_dir_all(&base_dir)?; + } + Ok(()) + } + + /// Gets the base directory for storing indices, supporting NAVISCOPE_INDEX_DIR env var. + pub fn get_base_index_dir() -> PathBuf { + if let Ok(env_dir) = std::env::var("NAVISCOPE_INDEX_DIR") { + return PathBuf::from(env_dir); + } + + let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); + Path::new(&home).join(super::super::DEFAULT_INDEX_DIR) + } + + // ---- Helper methods ---- + + pub(super) fn load_from_disk( + path: &Path, + lang_caps: Arc>, + build_caps: Arc>, + ) -> Result> { + if !path.exists() { + return Ok(None); + } + + let bytes = std::fs::read(path)?; + + let get_codec = |lang: &str| -> Option> { + for caps in lang_caps.iter() { + if caps.language.as_str() == lang { + return caps.metadata_codec.metadata_codec(); + } + } + for caps in build_caps.iter() { + if caps.build_tool.as_str() == lang { + return caps.metadata_codec.metadata_codec(); + } + } + None + }; + + match CodeGraph::deserialize(&bytes, get_codec) { + Ok(graph) => { + if graph.version() != crate::model::graph::CURRENT_VERSION { + tracing::warn!( + "Index version mismatch at {} (found {}, expected {}). Will rebuild.", + path.display(), + graph.version(), + crate::model::graph::CURRENT_VERSION + ); + let _ = std::fs::remove_file(path); + return Ok(None); + } + tracing::info!("Loaded index from {}", path.display()); + Ok(Some(graph)) + } + Err(e) => { + tracing::warn!( + "Failed to parse index at {}: {:?}. Will rebuild.", + path.display(), + e + ); + let _ = std::fs::remove_file(path); + Ok(None) + } + } + } + + pub(super) fn save_to_disk( + graph: &CodeGraph, + path: &Path, + lang_caps: Arc>, + build_caps: Arc>, + ) -> Result<()> { + // Ensure directory exists + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let get_codec = |lang: &str| -> Option> { + for caps in lang_caps.iter() { + if caps.language.as_str() == lang { + return caps.metadata_codec.metadata_codec(); + } + } + for caps in build_caps.iter() { + if caps.build_tool.as_str() == lang { + return caps.metadata_codec.metadata_codec(); + } + } + None + }; + + // Serialize the graph + let bytes = graph.serialize(get_codec)?; + + // Write to file atomically (write to temp, then rename) + let temp_path = path.with_extension("tmp"); + std::fs::write(&temp_path, bytes)?; + std::fs::rename(temp_path, path)?; + + tracing::info!("Saved index to {}", path.display()); + + Ok(()) + } + + pub(super) fn build_index( + project_root: &Path, + build_caps: Arc>, + lang_caps: Arc>, + stub_tx: tokio::sync::mpsc::UnboundedSender, + global_routes: HashMap>, + ) -> Result<(CodeGraph, Vec)> { + // Scan and parse + let parse_results = + Scanner::scan_and_parse(project_root, &std::collections::HashMap::new()); + + // Resolve + let resolver = IndexResolver::with_caps((*build_caps).clone(), (*lang_caps).clone()) + .with_stubbing(StubbingManager::new(stub_tx)); + + // resolve() now returns both ops and the filled ProjectContext + let (ops, _project_context) = resolver.resolve(parse_results)?; + + // Build graph + let mut builder = CodeGraphBuilder::new(); + + // Register naming conventions + for caps in lang_caps.iter() { + if let Some(nc) = caps.presentation.naming_convention() { + builder.naming_conventions.insert(caps.language.clone(), nc); + } + } + + builder.apply_ops(ops.clone())?; + + let stubs = resolver.resolve_stubs(&ops, &global_routes); + + Ok((builder.build(), stubs)) + } + + pub fn get_stub_cache(&self) -> Arc { + self.stub_cache.clone() + } +} diff --git a/crates/core/src/runtime/orchestrator/stub_worker.rs b/crates/core/src/runtime/orchestrator/stub_worker.rs new file mode 100644 index 0000000..550c187 --- /dev/null +++ b/crates/core/src/runtime/orchestrator/stub_worker.rs @@ -0,0 +1,115 @@ +use super::*; + +impl NaviscopeEngine { + /// Start the background stubbing worker + pub(super) fn spawn_stub_worker( + &self, + mut rx: tokio::sync::mpsc::UnboundedReceiver, + cancel_token: tokio_util::sync::CancellationToken, + stub_cache: Arc, + ) { + let current = self.current.clone(); + let lang_caps = self.lang_caps.clone(); + let naming_conventions = self.naming_conventions.clone(); + + tokio::spawn(async move { + tracing::info!("Stubbing worker started"); + let mut seen_fqns = std::collections::HashSet::new(); + + loop { + tokio::select! { + _ = cancel_token.cancelled() => break, + Some(req) = rx.recv() => { + // Skip if already seen in this session to avoid redundant work + if !seen_fqns.insert(req.fqn.clone()) { + continue; + } + + // Check if node already exists and is resolved + { + let lock = current.read().await; + let graph = &**lock; + if let Some(idx) = graph.find_node(&req.fqn) { + if let Some(node) = graph.get_node(idx) { + if node.status == naviscope_api::models::graph::ResolutionStatus::Resolved { + continue; + } + } + } + } + + // Resolve + let mut ops = Vec::new(); + + for asset_path in req.candidate_paths { + // Try to create asset key for cache lookup + let asset_key = crate::cache::AssetKey::from_path(&asset_path).ok(); + + // Check cache first + if let Some(ref key) = asset_key { + if let Some(cached_stub) = stub_cache.lookup(key, &req.fqn) { + tracing::trace!("Cache hit for {}", req.fqn); + ops.push(GraphOp::AddNode { + data: Some(cached_stub), + }); + break; // Found it + } + } + + // If not in cache, generate stub + for caps in lang_caps.iter() { + let Some(generator) = caps.asset.stub_generator() else { + continue; + }; + if !generator.can_generate(&asset_path) { + continue; + } + + let entry = AssetEntry::new(asset_path.clone(), AssetSource::Unknown); + match generator.generate(&req.fqn, &entry) { + Ok(stub) => { + // Store in cache for future use + if let Some(ref key) = asset_key { + stub_cache.store(key, &stub); + tracing::trace!("Cached stub for {}", req.fqn); + } + ops.push(GraphOp::AddNode { data: Some(stub) }); + break; + } + Err(e) => { + tracing::debug!( + "Failed to generate stub for {}: {}", + req.fqn, + e + ); + } + } + } + + if !ops.is_empty() { + break; + } + } + + if !ops.is_empty() { + let mut lock = current.write().await; + let mut builder = (**lock).to_builder(); + + // Load naming conventions + let conventions = (*naming_conventions).clone(); + for (lang, nc) in conventions { + builder.naming_conventions.insert(naviscope_api::models::Language::from(lang), nc); + } + + if let Ok(()) = builder.apply_ops(ops) { + *lock = Arc::new(builder.build()); + tracing::trace!("Applied stub for {}", req.fqn); + } + } + } + } + } + tracing::info!("Stubbing worker stopped"); + }); + } +} diff --git a/crates/core/src/runtime/orchestrator/watch.rs b/crates/core/src/runtime/orchestrator/watch.rs new file mode 100644 index 0000000..69c9326 --- /dev/null +++ b/crates/core/src/runtime/orchestrator/watch.rs @@ -0,0 +1,72 @@ +use super::*; + +impl NaviscopeEngine { + /// Watch for filesystem changes and update incrementally. + /// The watcher task exits when `cancel_token` is cancelled. + pub async fn start_watch_with_token( + self: Arc, + cancel_token: tokio_util::sync::CancellationToken, + ) -> Result<()> { + use crate::runtime::watcher::Watcher; + use std::collections::HashSet; + use std::time::Duration; + + let root = self.project_root.clone(); + let mut watcher = + Watcher::new(&root).map_err(|e| NaviscopeError::Internal(e.to_string()))?; + + let engine_weak = Arc::downgrade(&self); + + tokio::spawn(async move { + tracing::info!("Started watching {}", root.display()); + let mut pending_events: Vec = Vec::new(); + let debounce_interval = Duration::from_millis(500); + + loop { + tokio::select! { + _ = cancel_token.cancelled() => { + break; + } + event = watcher.next_event_async() => { + match event { + Some(e) => pending_events.push(e), + None => break, + } + } + _ = tokio::time::sleep(debounce_interval), if !pending_events.is_empty() => { + let mut paths = HashSet::new(); + for event in &pending_events { + for path in &event.paths { + if crate::ingest::is_relevant_path(path) { + paths.insert(path.clone()); + } + } + } + pending_events.clear(); + + if !paths.is_empty() { + if let Some(engine) = engine_weak.upgrade() { + let path_vec: Vec<_> = paths.into_iter().collect(); + tracing::info!("Detected changes in {} files. Updating...", path_vec.len()); + if let Err(err) = engine.update_files(path_vec).await { + tracing::error!("Failed to update files: {}", err); + } + } else { + break; + } + } + } + } + } + tracing::info!("File watcher task ended for {}", root.display()); + }); + + Ok(()) + } + + /// Backward-compatible helper that uses the engine-wide cancellation token. + pub async fn watch(self: Arc) -> Result<()> { + let cancel_token = self.cancel_token.clone(); + self.start_watch_with_token(cancel_token).await + } +} From f763db81cf3a13250157030de9b9f7f455303bdb Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Sat, 14 Feb 2026 16:19:01 +0800 Subject: [PATCH 39/49] Refactor Naviscope Engine: Remove stub worker, introduce storage and watch functionality - Deleted the `stub_worker.rs` file and its associated stubbing logic. - Added `storage.rs` to handle project index clearing and disk operations for code graphs. - Introduced `watch.rs` for filesystem change detection, allowing the engine to update incrementally. - Removed the old `watcher.rs` file and replaced it with a new implementation in `watch.rs`. - Updated tests to reflect changes in the stubbing workflow and engine structure. - Adjusted dependencies and versioning in `Cargo.toml` files across crates. - Introduced a new integration plan for a type system architecture, emphasizing semantic decoupling. --- Cargo.lock | 25 +- Cargo.toml | 2 +- crates/core/Cargo.toml | 3 +- crates/core/src/asset/service.rs | 40 +- crates/core/src/bridge/mod.rs | 5 - crates/core/src/facade/mod.rs | 18 +- crates/core/src/features/query.rs | 2 +- crates/core/src/indexing/compiler.rs | 73 ++++ crates/core/src/indexing/mod.rs | 26 ++ crates/core/src/indexing/scanner.rs | 103 +++++ crates/core/src/indexing/stub_planner.rs | 60 +++ crates/core/src/ingest/batch_tracker.rs | 67 ++++ crates/core/src/ingest/mod.rs | 207 +++++++++- crates/core/src/ingest/parser/mod.rs | 16 - crates/core/src/ingest/parser/output.rs | 2 - crates/core/src/ingest/parser/utils.rs | 139 ------- crates/core/src/ingest/resolver/engine.rs | 271 ------------- crates/core/src/ingest/resolver/mod.rs | 7 - crates/core/src/ingest/resolver/scope.rs | 15 - crates/core/src/ingest/resolver/stub.rs | 33 -- crates/core/src/ingest/scanner.rs | 113 ------ crates/core/src/ingest/workers.rs | 281 +++++++++++++ crates/core/src/lib.rs | 2 +- crates/core/src/{ingest => model}/builder.rs | 8 +- crates/core/src/model/graph.rs | 8 +- crates/core/src/model/mod.rs | 1 + crates/core/src/model/storage/converter.rs | 2 +- crates/core/src/runtime/ingest_adapter.rs | 280 ------------- crates/core/src/runtime/lifecycle.rs | 234 +++++++++++ crates/core/src/runtime/mod.rs | 379 +++++++++++++++++- .../core/src/runtime/orchestrator/indexing.rs | 202 ---------- crates/core/src/runtime/orchestrator/mod.rs | 321 --------------- .../src/runtime/orchestrator/stub_worker.rs | 115 ------ .../src/runtime/{orchestrator => }/storage.rs | 41 +- .../src/runtime/{orchestrator => }/watch.rs | 38 +- crates/core/src/runtime/watcher.rs | 54 --- crates/core/tests/async_stubbing.rs | 56 +-- crates/core/tests/engine_api.rs | 2 +- crates/core/tests/global_assets.rs | 2 +- crates/core/tests/indexing.rs | 2 +- crates/core/tests/semantic_traits.rs | 2 +- crates/ingest/Cargo.toml | 4 +- crates/ingest/src/runtime/mod.rs | 11 + .../tests/call_hierarchy_behavior.rs | 2 +- crates/lang-java/tests/common/mod.rs | 4 +- .../tests/goto_definition_behavior.rs | 2 +- crates/lang-java/tests/java_integration.rs | 24 +- crates/plugin/Cargo.toml | 2 +- crates/runtime/src/lib.rs | 4 +- type_system_integration_plan.md | 95 +++++ 50 files changed, 1615 insertions(+), 1790 deletions(-) delete mode 100644 crates/core/src/bridge/mod.rs create mode 100644 crates/core/src/indexing/compiler.rs create mode 100644 crates/core/src/indexing/mod.rs create mode 100644 crates/core/src/indexing/scanner.rs create mode 100644 crates/core/src/indexing/stub_planner.rs create mode 100644 crates/core/src/ingest/batch_tracker.rs delete mode 100644 crates/core/src/ingest/parser/mod.rs delete mode 100644 crates/core/src/ingest/parser/output.rs delete mode 100644 crates/core/src/ingest/parser/utils.rs delete mode 100644 crates/core/src/ingest/resolver/engine.rs delete mode 100644 crates/core/src/ingest/resolver/mod.rs delete mode 100644 crates/core/src/ingest/resolver/scope.rs delete mode 100644 crates/core/src/ingest/resolver/stub.rs delete mode 100644 crates/core/src/ingest/scanner.rs create mode 100644 crates/core/src/ingest/workers.rs rename crates/core/src/{ingest => model}/builder.rs (98%) delete mode 100644 crates/core/src/runtime/ingest_adapter.rs create mode 100644 crates/core/src/runtime/lifecycle.rs delete mode 100644 crates/core/src/runtime/orchestrator/indexing.rs delete mode 100644 crates/core/src/runtime/orchestrator/mod.rs delete mode 100644 crates/core/src/runtime/orchestrator/stub_worker.rs rename crates/core/src/runtime/{orchestrator => }/storage.rs (72%) rename crates/core/src/runtime/{orchestrator => }/watch.rs (72%) delete mode 100644 crates/core/src/runtime/watcher.rs create mode 100644 type_system_integration_plan.md diff --git a/Cargo.lock b/Cargo.lock index 9ca7bb9..446cbbf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1498,7 +1498,6 @@ dependencies = [ "notify", "once_cell", "petgraph", - "rayon", "regex", "rmp-serde", "schemars", @@ -1544,12 +1543,10 @@ dependencies = [ [[package]] name = "naviscope-ingest" -version = "0.1.0" +version = "0.5.5" dependencies = [ "async-trait", - "dashmap 6.1.0", "naviscope-api", - "rayon", "thiserror", "tokio", "tracing", @@ -1923,26 +1920,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "rayon" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - [[package]] name = "redox_syscall" version = "0.5.18" diff --git a/Cargo.toml b/Cargo.toml index 84d430c..d604f6b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,11 +28,11 @@ petgraph = { version = "0.8", features = ["serde-1"] } tree-sitter = "0.26" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +serde_bytes = "0.11.19" thiserror = "2.0" walkdir = "2.5" log = "0.4" ignore = "0.4.25" -rayon = "1.11.0" notify = "8.2.0" xxhash-rust = { version = "0.8.15", features = ["xxh3"] } regex = "1.11.1" diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index aa3e484..272b2c6 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -13,7 +13,6 @@ thiserror = { workspace = true } walkdir = { workspace = true } log = { workspace = true } ignore = { workspace = true } -rayon = { workspace = true } notify = { workspace = true } xxhash-rust = { workspace = true } regex = { workspace = true } @@ -34,7 +33,7 @@ naviscope-ingest = { workspace = true } async-trait = { workspace = true } url = { workspace = true } dirs = { workspace = true } -serde_bytes = "0.11.19" +serde_bytes = { workspace = true } [dev-dependencies] tree-sitter-java = { workspace = true } diff --git a/crates/core/src/asset/service.rs b/crates/core/src/asset/service.rs index 454b00a..6cd8681 100644 --- a/crates/core/src/asset/service.rs +++ b/crates/core/src/asset/service.rs @@ -9,15 +9,13 @@ use crate::asset::registry::InMemoryRouteRegistry; use crate::asset::scanner::{AssetScanner, ScanResult}; use naviscope_plugin::{ AssetDiscoverer, AssetEntry, AssetIndexer, AssetRouteRegistry, AssetSourceLocator, - RegistryStats, StubGenerator, StubRequest, + RegistryStats, StubGenerator, }; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::RwLock; -use tokio::sync::mpsc; use tokio::task::JoinHandle; -use tracing::debug; /// Unified Asset/Stub service pub struct AssetStubService { @@ -30,9 +28,6 @@ pub struct AssetStubService { /// Stub generators (from language plugins) generators: Vec>, - /// Channel for stub requests - stub_tx: Option>, - /// Mapping from binary asset path to source asset path (if available) source_map: Arc>>, @@ -56,7 +51,6 @@ impl AssetStubService { registry: Arc::new(InMemoryRouteRegistry::new()), scanner, generators, - stub_tx: None, source_map: Arc::new(RwLock::new(HashMap::new())), source_locators, } @@ -78,18 +72,11 @@ impl AssetStubService { registry, scanner, generators, - stub_tx: None, source_map: Arc::new(RwLock::new(HashMap::new())), source_locators, } } - /// Set the stub request channel - pub fn with_stub_channel(mut self, tx: mpsc::UnboundedSender) -> Self { - self.stub_tx = Some(tx); - self - } - /// Get a reference to the registry pub fn registry(&self) -> Arc { self.registry.clone() @@ -144,18 +131,6 @@ impl AssetStubService { .and_then(|map| map.get(binary_path).cloned()) } - /// Request stub generation (async, non-blocking) - pub fn request_stub(&self, fqn: String, candidate_entries: Vec) { - if let Some(tx) = &self.stub_tx { - let request = StubRequest::new(fqn.clone(), candidate_entries); - if let Err(e) = tx.send(request) { - tracing::warn!("Failed to send stub request for {}: {}", fqn, e); - } else { - debug!("Sent stub request for {}", fqn); - } - } - } - /// Get a snapshot of all routes (for serialization or passing to resolver) pub fn routes_snapshot(&self) -> HashMap> { self.registry @@ -229,7 +204,6 @@ pub struct AssetStubServiceBuilder { generators: Vec>, source_locators: Vec>, registry: Option>, - stub_tx: Option>, } impl AssetStubServiceBuilder { @@ -240,7 +214,6 @@ impl AssetStubServiceBuilder { generators: Vec::new(), source_locators: Vec::new(), registry: None, - stub_tx: None, } } @@ -269,13 +242,8 @@ impl AssetStubServiceBuilder { self } - pub fn with_stub_channel(mut self, tx: mpsc::UnboundedSender) -> Self { - self.stub_tx = Some(tx); - self - } - pub fn build(self) -> AssetStubService { - let mut service = if let Some(registry) = self.registry { + let service = if let Some(registry) = self.registry { AssetStubService::with_registry( registry, self.discoverers, @@ -292,10 +260,6 @@ impl AssetStubServiceBuilder { ) }; - if let Some(tx) = self.stub_tx { - service = service.with_stub_channel(tx); - } - service } } diff --git a/crates/core/src/bridge/mod.rs b/crates/core/src/bridge/mod.rs deleted file mode 100644 index 20d5e1a..0000000 --- a/crates/core/src/bridge/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub use naviscope_api::models::graph::DisplayGraphNode; -pub use naviscope_plugin::{ - BuildCaps, BuildContent, BuildParseResult, LanguageCaps, NamingConvention, NodeMetadataCodec, - NodePresenter, -}; diff --git a/crates/core/src/facade/mod.rs b/crates/core/src/facade/mod.rs index 2d81985..749e49d 100644 --- a/crates/core/src/facade/mod.rs +++ b/crates/core/src/facade/mod.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use crate::error::Result; use crate::model::CodeGraph; -use crate::runtime::orchestrator::NaviscopeEngine as InternalEngine; +use crate::runtime::NaviscopeEngine as InternalEngine; use naviscope_api::NaviscopeEngine; mod graph; @@ -47,28 +47,28 @@ impl EngineHandle { &self, language: crate::model::source::Language, ) -> Option> { - self.engine.get_resolver().get_semantic_cap(language) + self.engine.semantic_cap(language) } pub fn get_node_presenter( &self, language: crate::model::source::Language, - ) -> Option> { - self.engine.get_resolver().get_node_presenter(language) + ) -> Option> { + self.engine.node_presenter(language) } pub fn get_metadata_codec( &self, language: crate::model::source::Language, - ) -> Option> { - self.engine.get_resolver().get_metadata_codec(language) + ) -> Option> { + self.engine.metadata_codec(language) } pub fn get_language_for_path( &self, path: &std::path::Path, ) -> Option { - self.engine.get_resolver().get_language_for_path(path) + self.engine.language_for_path(path) } pub fn get_services_for_path( @@ -79,8 +79,8 @@ impl EngineHandle { crate::model::source::Language, )> { let lang = self.get_language_for_path(path)?; - let resolver = self.get_semantic_resolver(lang.clone())?; - Some((resolver, lang)) + let semantic_cap = self.get_semantic_resolver(lang.clone())?; + Some((semantic_cap, lang)) } /// Get naming convention for a specific language diff --git a/crates/core/src/features/query.rs b/crates/core/src/features/query.rs index bf7e3d7..3cd5cf7 100644 --- a/crates/core/src/features/query.rs +++ b/crates/core/src/features/query.rs @@ -1,7 +1,7 @@ +use naviscope_plugin::NodePresenter; use crate::error::{NaviscopeError, Result}; use crate::model::source::Language; use crate::model::{DisplayGraphNode, EdgeType, NodeKind}; -use crate::bridge::NodePresenter; pub use naviscope_api::models::{GraphQuery, QueryResult, QueryResultEdge}; use petgraph::Direction as PetDirection; use regex::RegexBuilder; diff --git a/crates/core/src/indexing/compiler.rs b/crates/core/src/indexing/compiler.rs new file mode 100644 index 0000000..fd85d52 --- /dev/null +++ b/crates/core/src/indexing/compiler.rs @@ -0,0 +1,73 @@ +use crate::error::Result; +use crate::indexing::scanner::ParsedFile; +use crate::model::{GraphOp, ResolvedUnit}; +use naviscope_plugin::{BuildCaps, LanguageCaps, ProjectContext}; + +pub struct BatchCompiler { + build_caps: Vec, + lang_caps: Vec, +} + +impl BatchCompiler { + pub fn with_caps(build_caps: Vec, lang_caps: Vec) -> Self { + Self { + build_caps, + lang_caps, + } + } + + pub fn compile_build_batch( + &self, + build_files: &[ParsedFile], + context: &mut ProjectContext, + ) -> Result> { + let mut all_ops = Vec::new(); + for caps in &self.build_caps { + let tool_files: Vec<&ParsedFile> = build_files + .iter() + .filter(|f| caps.matcher.supports_path(f.path())) + .collect(); + + if !tool_files.is_empty() { + let (unit, ctx) = caps + .indexing + .compile_build(&tool_files) + .map_err(crate::error::NaviscopeError::from)?; + all_ops.extend(unit.ops); + context.path_to_module.extend(ctx.path_to_module); + } + } + Ok(all_ops) + } + + pub fn compile_source_batch( + &self, + source_files: &[ParsedFile], + context: &ProjectContext, + ) -> Result> { + let source_results: Vec> = source_files + .iter() + .map(|file| { + let caps = self + .lang_caps + .iter() + .find(|c| c.matcher.supports_path(file.path())); + + if let Some(c) = caps { + c.indexing + .compile_source(file, context) + .map_err(crate::error::NaviscopeError::from) + } else { + Ok(ResolvedUnit::new()) + } + }) + .collect(); + + let mut all_ops = Vec::new(); + for result in source_results { + let unit = result?; + all_ops.extend(unit.ops); + } + Ok(all_ops) + } +} diff --git a/crates/core/src/indexing/mod.rs b/crates/core/src/indexing/mod.rs new file mode 100644 index 0000000..b4f6521 --- /dev/null +++ b/crates/core/src/indexing/mod.rs @@ -0,0 +1,26 @@ +pub mod compiler; +pub mod scanner; +pub mod stub_planner; + +pub use naviscope_plugin::IndexNode; + +/// A request to asynchronously generate a stub for an external FQN. +#[derive(Debug, Clone)] +pub struct StubRequest { + pub fqn: String, + pub candidate_paths: Vec, +} + +use std::path::Path; + +pub fn is_relevant_path(path: &Path) -> bool { + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if name.starts_with('.') { + return false; + } + if name == "target" || name == "build" || name == "node_modules" { + return false; + } + } + true +} diff --git a/crates/core/src/indexing/scanner.rs b/crates/core/src/indexing/scanner.rs new file mode 100644 index 0000000..0f568f1 --- /dev/null +++ b/crates/core/src/indexing/scanner.rs @@ -0,0 +1,103 @@ +use super::is_relevant_path; + +use crate::model::source::SourceFile; +use ignore::WalkBuilder; +use std::collections::HashMap; +use std::fs; +use std::hash::Hasher; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; +use xxhash_rust::xxh3::Xxh3; + +pub use naviscope_plugin::{ParsedContent, ParsedFile}; + +pub struct Scanner; + +impl Scanner { + pub fn scan_files_iter<'a>( + paths: Vec, + existing_files: &'a HashMap, + ) -> impl Iterator + 'a { + paths + .into_iter() + .filter_map(|path| Self::parse_path(&path, existing_files)) + } + + pub(crate) fn collect_paths(root: &Path) -> Vec { + WalkBuilder::new(root) + .build() + .filter_map(|entry| { + let entry = entry.ok()?; + let path = entry.path(); + if path.is_file() && is_relevant_path(path) { + return Some(path.to_path_buf()); + } + None + }) + .collect() + } + + fn process_file_with_mtime(path: &Path, mtime: u64) -> Option<(SourceFile, Vec)> { + let content = fs::read(path).ok()?; + let mut hasher = Xxh3::new(); + hasher.write(&content); + let hash = hasher.finish(); + + Some(( + SourceFile { + path: path.to_path_buf(), + content_hash: hash, + last_modified: mtime, + }, + content, + )) + } + + fn parse_path(path: &Path, existing_files: &HashMap) -> Option { + // 1. Check metadata (mtime) first + let metadata = fs::metadata(path).ok()?; + let modified = metadata + .modified() + .unwrap_or(SystemTime::UNIX_EPOCH) + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or(std::time::Duration::ZERO) + .as_secs(); + + if let Some(existing) = existing_files.get(path) { + if existing.last_modified == modified { + return None; + } + } + + // 2. Read and hash content + let (source_file, content) = Self::process_file_with_mtime(path, modified)?; + + // 3. Double check hash (mtime might change but content remains same) + if let Some(existing) = existing_files.get(path) { + if existing.content_hash == source_file.content_hash { + return None; + } + } + + let content_str = String::from_utf8(content).ok()?; + let file_name = path.file_name()?.to_str()?; + + if file_name == "build.gradle" + || file_name == "build.gradle.kts" + || file_name == "settings.gradle" + || file_name == "settings.gradle.kts" + { + Some(ParsedFile { + file: source_file, + content: ParsedContent::Unparsed(content_str), + }) + } else if path.extension().is_some() { + Some(ParsedFile { + file: source_file, + content: ParsedContent::Lazy, + }) + } else { + None + } + } +} diff --git a/crates/core/src/indexing/stub_planner.rs b/crates/core/src/indexing/stub_planner.rs new file mode 100644 index 0000000..fb90e9b --- /dev/null +++ b/crates/core/src/indexing/stub_planner.rs @@ -0,0 +1,60 @@ +use crate::indexing::StubRequest; +use crate::model::GraphOp; +use naviscope_api::models::graph::NodeSource; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; + +pub struct StubPlanner; + +impl StubPlanner { + pub fn plan(ops: &[GraphOp], routes: &HashMap>) -> Vec { + let mut requests = Vec::new(); + let mut seen_fqns = HashSet::new(); + + for op in ops { + match op { + GraphOp::AddEdge { to_id, .. } => { + seen_fqns.insert(to_id.to_string()); + } + GraphOp::AddNode { + data: Some(node_data), + } if node_data.source == NodeSource::External => { + seen_fqns.insert(node_data.id.to_string()); + } + _ => {} + } + } + + if seen_fqns.is_empty() || routes.is_empty() { + return requests; + } + + for fqn in seen_fqns { + if let Some(paths) = Self::find_asset_for_fqn(&fqn, routes) { + requests.push(StubRequest { + fqn, + candidate_paths: paths.clone(), + }); + } + } + requests + } + + fn find_asset_for_fqn<'a>( + fqn: &str, + routes: &'a HashMap>, + ) -> Option<&'a Vec> { + let mut current = fqn.to_string(); + while !current.is_empty() { + if let Some(paths) = routes.get(¤t) { + return Some(paths); + } + if let Some(idx) = current.rfind('.') { + current.truncate(idx); + } else { + break; + } + } + None + } +} diff --git a/crates/core/src/ingest/batch_tracker.rs b/crates/core/src/ingest/batch_tracker.rs new file mode 100644 index 0000000..9aac0e3 --- /dev/null +++ b/crates/core/src/ingest/batch_tracker.rs @@ -0,0 +1,67 @@ +use std::collections::HashMap; +use std::sync::Mutex; + +use tokio::sync::oneshot; + +#[derive(Default)] +pub struct BatchTracker { + inner: Mutex, +} + +#[derive(Default)] +struct BatchTrackerInner { + next_batch: u64, + msg_to_batch: HashMap, + batches: HashMap, +} + +struct BatchState { + remaining: usize, + done_tx: Option>, +} + +impl BatchTracker { + pub fn register_batch(&self, msg_ids: &[String]) -> (u64, oneshot::Receiver<()>) { + let mut guard = self.inner.lock().expect("batch tracker lock poisoned"); + let batch_id = guard.next_batch; + guard.next_batch += 1; + + let (tx, rx) = oneshot::channel(); + guard.batches.insert( + batch_id, + BatchState { + remaining: msg_ids.len(), + done_tx: Some(tx), + }, + ); + for msg_id in msg_ids { + guard.msg_to_batch.insert(msg_id.clone(), batch_id); + } + + (batch_id, rx) + } + + pub fn mark_done(&self, msg_id: &str) { + let mut guard = self.inner.lock().expect("batch tracker lock poisoned"); + let Some(batch_id) = guard.msg_to_batch.remove(msg_id) else { + return; + }; + + let mut should_remove = false; + if let Some(state) = guard.batches.get_mut(&batch_id) { + if state.remaining > 0 { + state.remaining -= 1; + } + if state.remaining == 0 { + if let Some(done_tx) = state.done_tx.take() { + let _ = done_tx.send(()); + } + should_remove = true; + } + } + + if should_remove { + guard.batches.remove(&batch_id); + } + } +} diff --git a/crates/core/src/ingest/mod.rs b/crates/core/src/ingest/mod.rs index f278b9c..594cb3e 100644 --- a/crates/core/src/ingest/mod.rs +++ b/crates/core/src/ingest/mod.rs @@ -1,18 +1,201 @@ -pub mod builder; -pub mod parser; -pub mod resolver; -pub mod scanner; +mod batch_tracker; +mod workers; -use std::path::Path; +use std::collections::{BTreeMap, HashMap}; +use std::path::PathBuf; +use std::sync::{Arc, RwLock}; -pub fn is_relevant_path(path: &Path) -> bool { - if let Some(name) = path.file_name().and_then(|n| n.to_str()) { - if name.starts_with('.') { - return false; +use naviscope_ingest::{ + IngestError, IngestRuntime, IntakeHandle, RuntimeComponents, RuntimeConfig, +}; +use naviscope_plugin::{BuildCaps, LanguageCaps, ParsedFile, ProjectContext}; + +use crate::error::{NaviscopeError, Result}; +use crate::indexing::StubRequest; +use crate::model::{CodeGraph, GraphOp}; + +use batch_tracker::BatchTracker; +use workers::{ + CommitGraphSink, InMemoryDeferredQueue, IngestExecutor, NoopRuntimeMetrics, + PassThroughScheduler, +}; + +#[derive(Clone)] +pub enum IngestWorkItem { + SourceFile(ParsedFile), + StubRequest(StubRequest), +} + +pub struct IngestAdapter { + intake: IntakeHandle, + project_context: Arc>, + routes: Arc>>>, + batch_tracker: Arc, + runtime_task: tokio::task::JoinHandle<()>, +} + +impl IngestAdapter { + pub async fn start( + current: Arc>>, + naming_conventions: Arc>>, + build_caps: Arc>, + lang_caps: Arc>, + stub_cache: Arc, + ) -> Result { + let project_context = Arc::new(RwLock::new(ProjectContext::new())); + let routes = Arc::new(RwLock::new(HashMap::new())); + let batch_tracker = Arc::new(BatchTracker::default()); + + let scheduler: naviscope_ingest::DynScheduler = + Arc::new(PassThroughScheduler); + let executor: naviscope_ingest::DynExecutor = + Arc::new(IngestExecutor { + build_caps, + lang_caps, + project_context: Arc::clone(&project_context), + routes: Arc::clone(&routes), + current: Arc::clone(¤t), + stub_cache, + }); + let deferred_store: naviscope_ingest::DynDeferredStore = + Arc::new(InMemoryDeferredQueue::default()); + let commit_sink: naviscope_ingest::DynCommitSink = Arc::new(CommitGraphSink { + current, + naming_conventions, + batch_tracker: Arc::clone(&batch_tracker), + }); + let metrics: naviscope_ingest::DynRuntimeMetrics = Arc::new(NoopRuntimeMetrics); + + let runtime = Arc::new(IngestRuntime::new( + RuntimeConfig { + kernel_channel_capacity: 500, + max_in_flight: 256, + deferred_poll_limit: 256, + idle_sleep_ms: 10, + }, + RuntimeComponents::with_tokio_bus( + scheduler, + executor, + deferred_store, + commit_sink, + metrics, + ), + )); + + let intake = runtime.intake_handle(); + let runtime_clone = Arc::clone(&runtime); + let runtime_task = tokio::spawn(async move { + if let Err(err) = runtime_clone.run_forever().await { + tracing::warn!("ingest runtime stopped: {}", err); + } + }); + + Ok(Self { + intake, + project_context, + routes, + batch_tracker, + runtime_task, + }) + } + + pub async fn submit_source_batch( + &self, + source_files: Vec, + project_context: ProjectContext, + routes: HashMap>, + ) -> Result<()> { + { + let mut guard = self + .project_context + .write() + .map_err(|_| NaviscopeError::Internal("project context poisoned".to_string()))?; + *guard = project_context; + } + { + let mut guard = self + .routes + .write() + .map_err(|_| NaviscopeError::Internal("routes map poisoned".to_string()))?; + *guard = routes; } - if name == "target" || name == "build" || name == "node_modules" { - return false; + + if source_files.is_empty() { + return Ok(()); } + + let msg_ids: Vec = source_files + .iter() + .enumerate() + .map(|(index, file)| format!("src:{}:{}", index, file.path().display())) + .collect(); + + let (batch_id, done_rx) = self.batch_tracker.register_batch(&msg_ids); + + for (index, file) in source_files.into_iter().enumerate() { + let msg = naviscope_ingest::Message { + msg_id: msg_ids[index].clone(), + topic: "source-index".to_string(), + message_group: file.path().to_string_lossy().to_string(), + version: 1, + depends_on: Vec::new(), + epoch: batch_id, + payload: IngestWorkItem::SourceFile(file), + metadata: BTreeMap::new(), + }; + self.intake + .submit(msg) + .await + .map_err(ingest_to_naviscope_error)?; + } + + done_rx + .await + .map_err(|_| NaviscopeError::Internal("ingest batch completion dropped".to_string())) + } + + pub async fn submit_stub_request(&self, req: StubRequest) -> Result<()> { + let msg_id = format!("stub:{}", req.fqn); + let msg = naviscope_ingest::Message { + msg_id, + topic: "stub-index".to_string(), + message_group: req.fqn.clone(), + version: 1, + depends_on: Vec::new(), + epoch: 0, + payload: IngestWorkItem::StubRequest(req), + metadata: BTreeMap::new(), + }; + self.intake + .submit(msg) + .await + .map_err(ingest_to_naviscope_error) } - true + + pub fn try_submit_stub_request(&self, req: StubRequest) -> Result<()> { + let msg_id = format!("stub:{}", req.fqn); + let msg = naviscope_ingest::Message { + msg_id, + topic: "stub-index".to_string(), + message_group: req.fqn.clone(), + version: 1, + depends_on: Vec::new(), + epoch: 0, + payload: IngestWorkItem::StubRequest(req), + metadata: BTreeMap::new(), + }; + self.intake + .try_submit(msg) + .map_err(ingest_to_naviscope_error) + } +} + +impl Drop for IngestAdapter { + fn drop(&mut self) { + self.runtime_task.abort(); + } +} + +fn ingest_to_naviscope_error(err: IngestError) -> NaviscopeError { + NaviscopeError::Internal(err.to_string()) } diff --git a/crates/core/src/ingest/parser/mod.rs b/crates/core/src/ingest/parser/mod.rs deleted file mode 100644 index b53a7e9..0000000 --- a/crates/core/src/ingest/parser/mod.rs +++ /dev/null @@ -1,16 +0,0 @@ -use crate::error::Result; -use std::path::Path; - -pub mod output; -pub mod utils; - -pub use naviscope_api::SymbolResolution; -pub use naviscope_api::models::symbol::NodeId; -pub use naviscope_plugin::{ - GlobalParseResult, IndexNode, IndexRelation, LspSyntaxService, ParseOutput, -}; - -/// Trait for parsers that provide data for the global code knowledge graph. -pub trait IndexParser: Send + Sync { - fn parse_file(&self, source_code: &str, file_path: Option<&Path>) -> Result; -} diff --git a/crates/core/src/ingest/parser/output.rs b/crates/core/src/ingest/parser/output.rs deleted file mode 100644 index 0542b5d..0000000 --- a/crates/core/src/ingest/parser/output.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub use naviscope_api::models::symbol::NodeId; -pub use naviscope_plugin::{IndexNode, IndexRelation, ParseOutput}; diff --git a/crates/core/src/ingest/parser/utils.rs b/crates/core/src/ingest/parser/utils.rs deleted file mode 100644 index d486009..0000000 --- a/crates/core/src/ingest/parser/utils.rs +++ /dev/null @@ -1,139 +0,0 @@ -use crate::error::{NaviscopeError, Result}; -use crate::model::{NodeKind, Range}; -use tree_sitter::{Language, Query}; - -/// Converts a tree-sitter range to our internal Range model. -pub fn range_from_ts(range: tree_sitter::Range) -> Range { - Range { - start_line: range.start_point.row, - start_col: range.start_point.column, - end_line: range.end_point.row, - end_col: range.end_point.column, - } -} - -/// Loads a Tree-sitter query from an SCM string. -pub fn load_query(language: &Language, scm: &str) -> Result { - Query::new(language, scm) - .map_err(|e| NaviscopeError::Parsing(format!("Invalid query: {:?}", e))) -} - -/// Gets the index of a capture name in a query. -pub fn get_capture_index(query: &Query, name: &str) -> Result { - query - .capture_index_for_name(name) - .ok_or_else(|| NaviscopeError::Parsing(format!("Capture name '{}' not found in SCM", name))) -} - -/// A raw symbol representation used during tree construction. -pub struct RawSymbol<'a> { - pub name: String, - pub kind: NodeKind, - pub range: crate::model::Range, - pub selection_range: crate::model::Range, - pub node: tree_sitter::Node<'a>, -} - -/// Builds a hierarchical DisplayGraphNode tree from flat raw symbols using AST parent-child relationships. -pub fn build_symbol_hierarchy(raw_symbols: Vec) -> Vec { - use std::collections::HashMap; - let mut symbols_map: HashMap = HashMap::new(); // node_id -> flat_index - let mut flat_symbols: Vec = Vec::new(); - let mut parent_child_rels: Vec<(usize, usize)> = Vec::new(); - - // 1. Create flat list and map nodes to indices - for (i, raw) in raw_symbols.iter().enumerate() { - flat_symbols.push(crate::model::DisplayGraphNode { - id: raw.name.clone(), // For document symbols, FQN might not be available, use name as fallback id - name: raw.name.clone(), - kind: raw.kind.clone(), - lang: String::new(), // To be filled by caller if needed - source: naviscope_api::models::graph::NodeSource::Project, - status: naviscope_api::models::graph::ResolutionStatus::Resolved, - location: Some(crate::model::DisplaySymbolLocation { - path: String::new(), // To be filled by caller - range: raw.range, - selection_range: Some(raw.selection_range), - }), - detail: None, - signature: None, - modifiers: vec![], - children: Some(Vec::new()), - }); - symbols_map.insert(raw.node.id(), i); - } - - // 2. Determine parent-child relationships using AST - for (i, raw) in raw_symbols.iter().enumerate() { - let mut curr = raw.node; - while let Some(parent) = curr.parent() { - if let Some(&parent_idx) = symbols_map.get(&parent.id()) { - if parent_idx != i { - parent_child_rels.push((parent_idx, i)); - break; - } - } - curr = parent; - } - } - - // 3. Build the tree - let mut has_parent = vec![false; flat_symbols.len()]; - for (_p, c) in &parent_child_rels { - has_parent[*c] = true; - } - - let mut roots = Vec::new(); - for i in 0..flat_symbols.len() { - if !has_parent[i] { - roots.push(i); - } - } - - fn build_node( - idx: usize, - flat: &mut Vec, - rels: &[(usize, usize)], - ) -> crate::model::DisplayGraphNode { - let mut sym = flat[idx].clone(); - let children: Vec = rels - .iter() - .filter(|(p, _)| *p == idx) - .map(|(_, c)| *c) - .collect(); - let mut child_nodes = Vec::new(); - for c_idx in children { - child_nodes.push(build_node(c_idx, flat, rels)); - } - sym.children = if child_nodes.is_empty() { - None - } else { - Some(child_nodes) - }; - sym - } - - roots - .into_iter() - .map(|root_idx| build_node(root_idx, &mut flat_symbols, &parent_child_rels)) - .collect() -} - -/// Macro to define a struct for capture indices and a `new` method to initialize it from a query. -#[macro_export] -macro_rules! decl_indices { - ($name:ident, { $($field:ident => $capture:expr),+ $(,)? }) => { - #[derive(Clone)] - pub struct $name { - $(pub $field: u32,)+ - } - - impl $name { - pub fn new(query: &tree_sitter::Query) -> $crate::error::Result { - Ok(Self { - $($field: $crate::ingest::parser::utils::get_capture_index(query, $capture)?,)+ - }) - } - } - }; -} diff --git a/crates/core/src/ingest/resolver/engine.rs b/crates/core/src/ingest/resolver/engine.rs deleted file mode 100644 index d3fbcc9..0000000 --- a/crates/core/src/ingest/resolver/engine.rs +++ /dev/null @@ -1,271 +0,0 @@ -use crate::error::Result; -use crate::ingest::resolver::StubbingManager; -use crate::ingest::scanner::ParsedFile; -use crate::model::source::Language; -use crate::model::{GraphOp, ResolvedUnit}; -use naviscope_plugin::{ - BuildCaps, LanguageCaps, NodeMetadataCodec, NodePresenter, ProjectContext, SemanticCap, -}; -use rayon::prelude::*; -use std::path::Path; -use std::sync::Arc; - -/// Main resolver that dispatches to specific capabilities based on file path. -pub struct IndexResolver { - build_caps: Vec, - lang_caps: Vec, - stubbing: Option, -} - -impl IndexResolver { - pub fn new() -> Self { - Self { - build_caps: Vec::new(), - lang_caps: Vec::new(), - stubbing: None, - } - } - - pub fn with_caps(build_caps: Vec, lang_caps: Vec) -> Self { - Self { - build_caps, - lang_caps, - stubbing: None, - } - } - - pub fn with_stubbing(mut self, stubbing: StubbingManager) -> Self { - self.stubbing = Some(stubbing); - self - } - - pub fn register_language(&mut self, caps: LanguageCaps) { - self.lang_caps.push(caps); - } - - pub fn register_build_tool(&mut self, caps: BuildCaps) { - self.build_caps.push(caps); - } - - pub fn get_semantic_cap(&self, language: Language) -> Option> { - self.lang_caps - .iter() - .find(|c| c.language == language) - .map(|c| c.semantic.clone()) - } - - pub fn get_node_presenter(&self, language: Language) -> Option> { - self.lang_caps - .iter() - .find(|c| c.language == language) - .and_then(|c| c.presentation.node_presenter()) - .or_else(|| { - self.build_caps - .iter() - .find(|c| c.build_tool.as_str() == language.as_str()) - .and_then(|c| c.presentation.node_presenter()) - }) - } - - pub fn get_metadata_codec(&self, language: Language) -> Option> { - self.lang_caps - .iter() - .find(|c| c.language == language) - .and_then(|c| c.metadata_codec.metadata_codec()) - .or_else(|| { - self.build_caps - .iter() - .find(|c| c.build_tool.as_str() == language.as_str()) - .and_then(|c| c.metadata_codec.metadata_codec()) - }) - } - - pub fn get_language_for_path(&self, path: &Path) -> Option { - self.lang_caps - .iter() - .find(|c| c.matcher.supports_path(path)) - .map(|c| c.language.clone()) - } - - pub fn get_naming_convention( - &self, - language: Language, - ) -> Option> { - self.lang_caps - .iter() - .find(|c| c.language == language) - .and_then(|c| c.presentation.naming_convention()) - } - - /// Resolve all parsed files into graph operations using a two-phase process. - /// Returns both the operations and the filled ProjectContext. - pub fn resolve(&self, files: Vec) -> Result<(Vec, ProjectContext)> { - let (mut all_ops, build_files, source_files) = self.prepare_and_partition(files); - - // Phase 1: Build Tools - let mut project_context = ProjectContext::new(); - let build_ops = self.resolve_build_batch(&build_files, &mut project_context)?; - all_ops.extend(build_ops); - - // Phase 2: Source Files - let source_ops = self.resolve_source_batch(&source_files, &project_context)?; - all_ops.extend(source_ops); - - Ok((all_ops, project_context)) - } - - fn prepare_and_partition( - &self, - files: Vec, - ) -> (Vec, Vec, Vec) { - let mut all_ops = Vec::new(); - for file in &files { - all_ops.push(GraphOp::RemovePath { - path: Arc::from(file.file.path.as_path()), - }); - all_ops.push(GraphOp::UpdateFile { - metadata: file.file.clone(), - }); - } - - let (build_files, source_files): (Vec<_>, Vec<_>) = - files.into_iter().partition(|f| f.is_build()); - - (all_ops, build_files, source_files) - } - - pub fn resolve_stubs( - &self, - ops: &[GraphOp], - routes: &std::collections::HashMap>, - ) -> Vec { - use crate::ingest::resolver::StubRequest; - use naviscope_api::models::graph::NodeSource; - use std::collections::HashSet; - - let mut requests = Vec::new(); - let mut seen_fqns = HashSet::new(); - - // 1. Identify all unique external FQNs referenced in the operations - for op in ops { - match op { - GraphOp::AddEdge { to_id, .. } => { - let fqn = to_id.to_string(); - seen_fqns.insert(fqn); - } - GraphOp::AddNode { - data: Some(node_data), - } => { - if node_data.source == NodeSource::External { - seen_fqns.insert(node_data.id.to_string()); - } - } - _ => {} - } - } - - if seen_fqns.is_empty() || routes.is_empty() { - return requests; - } - - // 2. Schedule each FQN for background resolution - for fqn in seen_fqns { - // We only schedule if we have a route for it - if let Some(paths) = self.find_asset_for_fqn(&fqn, routes) { - requests.push(StubRequest { - fqn, - candidate_paths: paths.clone(), - }); - } - } - requests - } - - /// Schedule stubs using internal manager (for tests/backward compat) - pub fn schedule_stubs( - &self, - ops: &[GraphOp], - routes: &std::collections::HashMap>, - ) { - if let Some(stubbing) = &self.stubbing { - for req in self.resolve_stubs(ops, routes) { - stubbing.send(req); - } - } - } - - fn find_asset_for_fqn<'a>( - &self, - fqn: &str, - routes: &'a std::collections::HashMap>, - ) -> Option<&'a Vec> { - // Longest prefix match - let mut current = fqn.to_string(); - while !current.is_empty() { - if let Some(paths) = routes.get(¤t) { - return Some(paths); - } - if let Some(idx) = current.rfind('.') { - current.truncate(idx); - } else { - break; - } - } - None - } - - pub fn resolve_build_batch( - &self, - build_files: &[ParsedFile], - context: &mut ProjectContext, - ) -> Result> { - let mut all_ops = Vec::new(); - for caps in &self.build_caps { - let tool_files: Vec<&ParsedFile> = build_files - .iter() - .filter(|f| caps.matcher.supports_path(f.path())) - .collect(); - - if !tool_files.is_empty() { - let (unit, ctx) = caps - .indexing - .compile_build(&tool_files) - .map_err(crate::error::NaviscopeError::from)?; - all_ops.extend(unit.ops); - context.path_to_module.extend(ctx.path_to_module); - } - } - Ok(all_ops) - } - - pub fn resolve_source_batch( - &self, - source_files: &[ParsedFile], - context: &ProjectContext, - ) -> Result> { - let source_results: Vec> = source_files - .par_iter() - .map(|file| { - let caps = self - .lang_caps - .iter() - .find(|c| c.matcher.supports_path(file.path())); - - if let Some(c) = caps { - c.indexing - .compile_source(file, context) - .map_err(crate::error::NaviscopeError::from) - } else { - Ok(ResolvedUnit::new()) - } - }) - .collect(); - - let mut all_ops = Vec::new(); - for result in source_results { - let unit = result?; - all_ops.extend(unit.ops); - } - Ok(all_ops) - } -} diff --git a/crates/core/src/ingest/resolver/mod.rs b/crates/core/src/ingest/resolver/mod.rs deleted file mode 100644 index 6669591..0000000 --- a/crates/core/src/ingest/resolver/mod.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod engine; -pub mod scope; -pub mod stub; - -pub use engine::IndexResolver; -pub use naviscope_plugin::{ProjectContext, SemanticCap}; -pub use stub::{StubRequest, StubbingManager}; diff --git a/crates/core/src/ingest/resolver/scope.rs b/crates/core/src/ingest/resolver/scope.rs deleted file mode 100644 index c25eba3..0000000 --- a/crates/core/src/ingest/resolver/scope.rs +++ /dev/null @@ -1,15 +0,0 @@ -use naviscope_api::models::SymbolResolution; - -/// A generic trait for semantic scopes in any programming language. -/// `C` represents the language-specific resolution context. -pub trait SemanticScope: Send + Sync { - /// Resolve a name within this specific scope. - /// Returns: - /// - `Some(Ok(res))` if the symbol is found. - /// - `Some(Err(()))` if the symbol is NOT found and searching should stop (shadowing/short-circuit). - /// - `None` if the symbol is NOT found and searching should continue in the next scope. - fn resolve(&self, name: &str, context: &C) -> Option>; - - /// Returns the name of the scope for debugging purposes. - fn name(&self) -> &'static str; -} diff --git a/crates/core/src/ingest/resolver/stub.rs b/crates/core/src/ingest/resolver/stub.rs deleted file mode 100644 index 6fdc686..0000000 --- a/crates/core/src/ingest/resolver/stub.rs +++ /dev/null @@ -1,33 +0,0 @@ -use tokio::sync::mpsc::UnboundedSender; - -/// A request to asynchronously generate a stub for an external FQN -#[derive(Debug, Clone)] -pub struct StubRequest { - pub fqn: String, - pub candidate_paths: Vec, -} - -pub struct StubbingManager { - tx: UnboundedSender, -} - -impl StubbingManager { - pub fn new(tx: UnboundedSender) -> Self { - Self { tx } - } - - pub fn request(&self, fqn: String, candidate_paths: Vec) { - self.send(StubRequest { - fqn, - candidate_paths, - }); - } - - pub fn send(&self, req: StubRequest) { - let fqn = req.fqn.clone(); - match self.tx.send(req) { - Ok(_) => tracing::trace!("Sent stub request for {}", fqn), - Err(e) => tracing::warn!("Failed to send stub request for {}: {}", fqn, e), - } - } -} diff --git a/crates/core/src/ingest/scanner.rs b/crates/core/src/ingest/scanner.rs deleted file mode 100644 index 79a1efb..0000000 --- a/crates/core/src/ingest/scanner.rs +++ /dev/null @@ -1,113 +0,0 @@ -use super::is_relevant_path; - -use crate::model::source::SourceFile; -use ignore::WalkBuilder; -use rayon::prelude::*; -use std::collections::HashMap; -use std::fs; -use std::hash::Hasher; -use std::path::{Path, PathBuf}; -use std::time::SystemTime; -use xxhash_rust::xxh3::Xxh3; - -pub use naviscope_plugin::{ParsedContent, ParsedFile}; - -pub struct Scanner; - -impl Scanner { - pub fn scan_and_parse( - root: &Path, - existing_files: &HashMap, - ) -> Vec { - let paths = Self::collect_paths(root); - Self::scan_files(paths, existing_files) - } - - pub fn scan_files( - paths: Vec, - existing_files: &HashMap, - ) -> Vec { - paths - .par_iter() - .filter_map(|path| { - // 1. Check metadata (mtime) first - let metadata = fs::metadata(path).ok()?; - let modified = metadata - .modified() - .unwrap_or(SystemTime::UNIX_EPOCH) - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap_or(std::time::Duration::ZERO) - .as_secs(); - - if let Some(existing) = existing_files.get(path) { - if existing.last_modified == modified { - return None; - } - } - - // 2. Read and hash content - let (source_file, content) = Self::process_file_with_mtime(path, modified)?; - - // 3. Double check hash (mtime might change but content remains same) - if let Some(existing) = existing_files.get(path) { - if existing.content_hash == source_file.content_hash { - return None; - } - } - - let content_str = String::from_utf8(content).ok()?; - - // Determine build tool or language from file extension/name - let file_name = path.file_name()?.to_str()?; - - if file_name == "build.gradle" - || file_name == "build.gradle.kts" - || file_name == "settings.gradle" - || file_name == "settings.gradle.kts" - { - Some(ParsedFile { - file: source_file, - content: ParsedContent::Unparsed(content_str), - }) - } else if path.extension().is_some() { - Some(ParsedFile { - file: source_file, - content: ParsedContent::Lazy, - }) - } else { - None - } - }) - .collect() - } - - pub(crate) fn collect_paths(root: &Path) -> Vec { - WalkBuilder::new(root) - .build() - .filter_map(|entry| { - let entry = entry.ok()?; - let path = entry.path(); - if path.is_file() && is_relevant_path(path) { - return Some(path.to_path_buf()); - } - None - }) - .collect() - } - - fn process_file_with_mtime(path: &Path, mtime: u64) -> Option<(SourceFile, Vec)> { - let content = fs::read(path).ok()?; - let mut hasher = Xxh3::new(); - hasher.write(&content); - let hash = hasher.finish(); - - Some(( - SourceFile { - path: path.to_path_buf(), - content_hash: hash, - last_modified: mtime, - }, - content, - )) - } -} diff --git a/crates/core/src/ingest/workers.rs b/crates/core/src/ingest/workers.rs new file mode 100644 index 0000000..ef4b1dd --- /dev/null +++ b/crates/core/src/ingest/workers.rs @@ -0,0 +1,281 @@ +use std::collections::{HashMap, VecDeque}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex, RwLock}; + +use naviscope_ingest::{ + DeferredStore, ExecutionResult, ExecutionStatus, IngestError, PipelineEvent, RuntimeMetrics, + Scheduler, +}; +use naviscope_plugin::{AssetEntry, AssetSource, BuildCaps, LanguageCaps, ProjectContext}; + +use crate::indexing::{compiler::BatchCompiler, stub_planner::StubPlanner}; +use crate::indexing::StubRequest; +use crate::model::{CodeGraph, GraphOp}; + +use super::IngestWorkItem; +use super::batch_tracker::BatchTracker; + +pub struct PassThroughScheduler; + +impl Scheduler for PassThroughScheduler { + fn schedule( + &self, + messages: Vec>, + ) -> Result>, IngestError> { + Ok(messages.into_iter().map(PipelineEvent::Runnable).collect()) + } +} + +pub struct IngestExecutor { + pub build_caps: Arc>, + pub lang_caps: Arc>, + pub project_context: Arc>, + pub routes: Arc>>>, + pub current: Arc>>, + pub stub_cache: Arc, +} + +impl naviscope_ingest::Executor for IngestExecutor { + fn execute( + &self, + message: naviscope_ingest::Message, + ) -> Result>, IngestError> { + let operations = match message.payload { + IngestWorkItem::SourceFile(file) => self.execute_source(file)?, + IngestWorkItem::StubRequest(req) => self.execute_stub(req), + }; + + Ok(vec![PipelineEvent::Executed { + epoch: message.epoch, + result: ExecutionResult { + msg_id: message.msg_id, + status: ExecutionStatus::Done, + operations, + next_dependencies: Vec::new(), + error: None, + }, + }]) + } +} + +impl IngestExecutor { + fn execute_source( + &self, + file: naviscope_plugin::ParsedFile, + ) -> Result, IngestError> { + let path = file.path().to_path_buf(); + + let compiler = BatchCompiler::with_caps((*self.build_caps).clone(), (*self.lang_caps).clone()); + + let context = self + .project_context + .read() + .map_err(|_| IngestError::Execution("project context poisoned".to_string()))? + .clone(); + + let mut ops = Vec::with_capacity(8); + ops.push(GraphOp::RemovePath { + path: Arc::from(path.as_path()), + }); + ops.push(GraphOp::UpdateFile { + metadata: file.file.clone(), + }); + + let source_ops = compiler + .compile_source_batch(std::slice::from_ref(&file), &context) + .map_err(naviscope_to_ingest_error)?; + ops.extend(source_ops); + + let routes_snapshot = self + .routes + .read() + .map_err(|_| IngestError::Execution("routes map poisoned".to_string()))? + .clone(); + let stub_requests = StubPlanner::plan(&ops, &routes_snapshot); + for req in stub_requests { + ops.extend(self.execute_stub(req)); + } + + Ok(ops) + } + + fn execute_stub(&self, req: StubRequest) -> Vec { + generate_stub_ops( + &req, + Arc::clone(&self.current), + Arc::clone(&self.lang_caps), + Arc::clone(&self.stub_cache), + ) + } +} + +pub fn generate_stub_ops( + req: &StubRequest, + current: Arc>>, + lang_caps: Arc>, + stub_cache: Arc, +) -> Vec { + let mut ops = Vec::new(); + + // Skip if node already exists and resolved. + let already_resolved = tokio::runtime::Handle::current().block_on(async { + let lock = current.read().await; + let graph = &**lock; + if let Some(idx) = graph.find_node(&req.fqn) + && let Some(node) = graph.get_node(idx) + { + return node.status == naviscope_api::models::graph::ResolutionStatus::Resolved; + } + false + }); + if already_resolved { + return ops; + } + + for asset_path in &req.candidate_paths { + let asset_key = crate::cache::AssetKey::from_path(asset_path).ok(); + + if let Some(ref key) = asset_key + && let Some(cached_stub) = stub_cache.lookup(key, &req.fqn) + { + ops.push(GraphOp::AddNode { + data: Some(cached_stub), + }); + break; + } + + for caps in lang_caps.iter() { + let Some(generator) = caps.asset.stub_generator() else { + continue; + }; + if !generator.can_generate(asset_path) { + continue; + } + + let entry = AssetEntry::new(asset_path.clone(), AssetSource::Unknown); + match generator.generate(&req.fqn, &entry) { + Ok(stub) => { + if let Some(ref key) = asset_key { + stub_cache.store(key, &stub); + } + ops.push(GraphOp::AddNode { data: Some(stub) }); + break; + } + Err(err) => { + tracing::debug!("Failed to generate stub for {}: {}", req.fqn, err); + } + } + } + + if !ops.is_empty() { + break; + } + } + + ops +} + +#[derive(Default)] +pub struct InMemoryDeferredQueue { + items: Mutex>>, +} + +impl DeferredStore for InMemoryDeferredQueue { + fn push(&self, message: naviscope_ingest::Message) -> Result<(), IngestError> { + let mut guard = self + .items + .lock() + .map_err(|_| IngestError::Execution("deferred queue poisoned".to_string()))?; + guard.push_back(message); + Ok(()) + } + + fn pop_ready( + &self, + limit: usize, + ) -> Result>, IngestError> { + let mut guard = self + .items + .lock() + .map_err(|_| IngestError::Execution("deferred queue poisoned".to_string()))?; + let mut out = Vec::new(); + for _ in 0..limit.max(1) { + if let Some(item) = guard.pop_front() { + out.push(item); + } else { + break; + } + } + Ok(out) + } + + fn notify_ready( + &self, + _event: naviscope_ingest::DependencyReadyEvent, + ) -> Result<(), IngestError> { + Ok(()) + } +} + +pub struct CommitGraphSink { + pub current: Arc>>, + pub naming_conventions: Arc>>, + pub batch_tracker: Arc, +} + +impl naviscope_ingest::CommitSink for CommitGraphSink { + fn commit_epoch( + &self, + _epoch: u64, + results: Vec>, + ) -> Result { + if results.is_empty() { + return Ok(0); + } + + let mut merged_ops = Vec::new(); + let mut completed_ids = Vec::with_capacity(results.len()); + + for result in results { + completed_ids.push(result.msg_id); + merged_ops.extend(result.operations); + } + + let naming_conventions = Arc::clone(&self.naming_conventions); + let current = Arc::clone(&self.current); + tokio::runtime::Handle::current().block_on(async move { + let mut lock = current.write().await; + let mut builder = (**lock).to_builder(); + for (lang, nc) in naming_conventions.iter() { + builder + .naming_conventions + .insert(crate::model::Language::from(lang.clone()), Arc::clone(nc)); + } + builder + .apply_ops(merged_ops) + .map_err(naviscope_to_ingest_error)?; + *lock = Arc::new(builder.build()); + Ok::<(), IngestError>(()) + })?; + + for msg_id in completed_ids { + self.batch_tracker.mark_done(&msg_id); + } + + Ok(1) + } +} + +#[derive(Default)] +pub struct NoopRuntimeMetrics; + +impl RuntimeMetrics for NoopRuntimeMetrics { + fn observe_queue_depth(&self, _queue: &'static str, _depth: usize) {} + fn observe_throughput(&self, _stage: &'static str, _count: usize) {} + fn observe_latency_ms(&self, _stage: &'static str, _p95_ms: u64, _p99_ms: u64) {} + fn observe_replay_result(&self, _ok: bool) {} +} + +fn naviscope_to_ingest_error(err: crate::error::NaviscopeError) -> IngestError { + IngestError::Execution(err.to_string()) +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index dfc7f94..8f7a0b9 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,5 +1,4 @@ pub mod asset; -pub mod bridge; pub mod cache; pub mod error; pub mod logging; @@ -8,6 +7,7 @@ pub mod util; pub mod facade; pub mod features; pub mod ingest; +pub mod indexing; pub mod model; pub mod runtime; // FQN types are now exported from model module diff --git a/crates/core/src/ingest/builder.rs b/crates/core/src/model/builder.rs similarity index 98% rename from crates/core/src/ingest/builder.rs rename to crates/core/src/model/builder.rs index 391df01..58defb3 100644 --- a/crates/core/src/ingest/builder.rs +++ b/crates/core/src/model/builder.rs @@ -97,7 +97,7 @@ impl CodeGraphBuilder { } /// Add or update a node - pub fn add_node(&mut self, node_data: crate::ingest::parser::IndexNode) -> NodeIndex { + pub fn add_node(&mut self, node_data: crate::indexing::IndexNode) -> NodeIndex { // We have language info here! Use it to select convention. let lang = crate::model::Language::new(node_data.lang.clone()); let fqn_id = if let Some(nc) = self.naming_conventions.get(&lang) { @@ -271,7 +271,7 @@ impl CodeGraphBuilder { // Heuristic for external node: use class/unknown kind let name = to_id.to_string(); - let placeholder = crate::ingest::parser::IndexNode { + let placeholder = crate::indexing::IndexNode { id: to_id.clone(), name, kind: naviscope_api::models::graph::NodeKind::Class, // Default to class for external types @@ -365,7 +365,7 @@ mod tests { fn test_build_from_scratch() { let mut builder = CodeGraphBuilder::new(); - let node = crate::ingest::parser::IndexNode { + let node = crate::indexing::IndexNode { id: "test_project".into(), name: "test_project".to_string(), kind: NodeKind::Project, @@ -390,7 +390,7 @@ mod tests { let mut builder = CodeGraphBuilder::from_graph(&graph); - let node = crate::ingest::parser::IndexNode { + let node = crate::indexing::IndexNode { id: "new_project".into(), name: "new_project".to_string(), kind: NodeKind::Project, diff --git a/crates/core/src/model/graph.rs b/crates/core/src/model/graph.rs index 3f7fb78..aff141e 100644 --- a/crates/core/src/model/graph.rs +++ b/crates/core/src/model/graph.rs @@ -3,13 +3,13 @@ //! The `CodeGraph` provides a cheap-to-clone, immutable view of the indexed codebase. //! All data is wrapped in `Arc`, so cloning only increments a reference counter. use crate::error::{NaviscopeError, Result}; -use crate::ingest::builder::CodeGraphBuilder; +use crate::model::builder::CodeGraphBuilder; +use naviscope_plugin::NodeMetadataCodec; use crate::features::CodeGraphLike; use crate::model::FqnManager; use crate::model::source::SourceFile; use crate::model::{GraphEdge, GraphNode}; -use crate::bridge::NodeMetadataCodec; use lasso::ThreadedRodeo; use naviscope_api::models::symbol::{FqnId, FqnReader, Symbol}; use petgraph::stable_graph::{NodeIndex, StableDiGraph}; @@ -447,11 +447,11 @@ mod tests { #[test] fn test_graph_serialization_roundtrip() { - use crate::ingest::builder::CodeGraphBuilder; + use crate::model::builder::CodeGraphBuilder; use crate::model::NodeKind; let mut builder = CodeGraphBuilder::new(); - let node = crate::ingest::parser::IndexNode { + let node = crate::indexing::IndexNode { id: naviscope_api::models::symbol::NodeId::Flat("test_node".to_string()), name: "node".to_string(), kind: NodeKind::Class, diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index aa283f9..73795fb 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -1,3 +1,4 @@ +pub mod builder; pub mod fqn; pub mod graph; pub mod metadata; diff --git a/crates/core/src/model/storage/converter.rs b/crates/core/src/model/storage/converter.rs index 6a5d3df..76c978d 100644 --- a/crates/core/src/model/storage/converter.rs +++ b/crates/core/src/model/storage/converter.rs @@ -1,7 +1,7 @@ use super::model::*; +use naviscope_plugin::NodeMetadataCodec; use crate::model::graph::{CodeGraphInner, FileEntry}; use crate::model::{EmptyMetadata, GraphNode, InternedLocation, NodeMetadata}; -use crate::bridge::NodeMetadataCodec; use lasso::{Key, Spur, ThreadedRodeo}; use naviscope_api::models::symbol::{FqnId, Symbol}; use petgraph::stable_graph::NodeIndex; diff --git a/crates/core/src/runtime/ingest_adapter.rs b/crates/core/src/runtime/ingest_adapter.rs deleted file mode 100644 index 8697865..0000000 --- a/crates/core/src/runtime/ingest_adapter.rs +++ /dev/null @@ -1,280 +0,0 @@ -use std::collections::{BTreeMap, HashMap, VecDeque}; -use std::path::PathBuf; -use std::sync::{Arc, Mutex}; - -use naviscope_ingest::runtime::kernel; -use naviscope_ingest::{ - CommitSink, DeferredStore, DependencyReadyEvent, ExecutionResult, ExecutionStatus, - FlowControlConfig, IngestError, PipelineBus, PipelineEvent, RuntimeConfig, RuntimeMetrics, - Scheduler, -}; -use naviscope_plugin::{LanguageCaps, ParsedFile, ProjectContext}; - -use crate::error::{NaviscopeError, Result}; -use crate::ingest::resolver::{IndexResolver, StubRequest}; -use crate::model::{CodeGraph, GraphOp}; - -#[derive(Clone)] -pub struct IndexWorkItem { - pub file: ParsedFile, -} - -struct IndexWorkScheduler; - -impl Scheduler for IndexWorkScheduler { - fn schedule( - &self, - messages: Vec>, - ) -> std::result::Result>, IngestError> { - Ok(messages.into_iter().map(PipelineEvent::Runnable).collect()) - } -} - -struct IndexWorkExecutor { - resolver: Arc, - project_context: Arc, -} - -impl naviscope_ingest::Executor for IndexWorkExecutor { - fn execute( - &self, - message: naviscope_ingest::Message, - ) -> std::result::Result>, IngestError> { - let file = message.payload.file; - let path = file.path().to_path_buf(); - - let mut ops = Vec::with_capacity(4); - ops.push(GraphOp::RemovePath { - path: Arc::from(path.as_path()), - }); - ops.push(GraphOp::UpdateFile { - metadata: file.file.clone(), - }); - - let source_ops = self - .resolver - .resolve_source_batch(std::slice::from_ref(&file), &self.project_context) - .map_err(naviscope_to_ingest_error)?; - ops.extend(source_ops); - - Ok(vec![PipelineEvent::Executed { - epoch: message.epoch, - result: ExecutionResult { - msg_id: message.msg_id, - status: ExecutionStatus::Done, - operations: ops, - next_dependencies: Vec::new(), - error: None, - }, - }]) - } -} - -#[derive(Default)] -struct InMemoryDeferredQueue { - items: Mutex>>, -} - -impl DeferredStore for InMemoryDeferredQueue { - fn push( - &self, - message: naviscope_ingest::Message, - ) -> std::result::Result<(), IngestError> { - let mut guard = self - .items - .lock() - .map_err(|_| IngestError::Execution("deferred queue poisoned".to_string()))?; - guard.push_back(message); - Ok(()) - } - - fn pop_ready( - &self, - limit: usize, - ) -> std::result::Result>, IngestError> { - let mut guard = self - .items - .lock() - .map_err(|_| IngestError::Execution("deferred queue poisoned".to_string()))?; - let mut out = Vec::new(); - let max = limit.max(1); - for _ in 0..max { - if let Some(item) = guard.pop_front() { - out.push(item); - } else { - break; - } - } - Ok(out) - } - - fn notify_ready(&self, _event: DependencyReadyEvent) -> std::result::Result<(), IngestError> { - Ok(()) - } -} - -struct IndexWorkCommitter { - builder: Arc>, - pending_stubs: Arc>>, - resolver: Arc, - routes: Arc>>, -} - -impl CommitSink for IndexWorkCommitter { - fn commit_epoch( - &self, - _epoch: u64, - results: Vec>, - ) -> std::result::Result { - if results.is_empty() { - return Ok(0); - } - - for result in results { - let operations = result.operations; - { - let mut guard = self - .builder - .lock() - .map_err(|_| IngestError::Execution("graph builder poisoned".to_string()))?; - guard - .apply_ops(operations.clone()) - .map_err(naviscope_to_ingest_error)?; - } - - let requests = self - .resolver - .resolve_stubs(&operations, self.routes.as_ref()); - if !requests.is_empty() { - let mut pending = self - .pending_stubs - .lock() - .map_err(|_| IngestError::Execution("stub queue poisoned".to_string()))?; - pending.extend(requests); - } - } - - Ok(1) - } -} - -#[derive(Default)] -struct NoopRuntimeMetrics; - -impl RuntimeMetrics for NoopRuntimeMetrics { - fn observe_queue_depth(&self, _queue: &'static str, _depth: usize) {} - fn observe_throughput(&self, _stage: &'static str, _count: usize) {} - fn observe_latency_ms(&self, _stage: &'static str, _p95_ms: u64, _p99_ms: u64) {} - fn observe_replay_result(&self, _ok: bool) {} -} - -pub fn run_source_ingest( - base_graph: &CodeGraph, - initial_ops: Vec, - source_files: Vec, - resolver: Arc, - project_context: Arc, - routes: Arc>>, - lang_caps: Arc>, - runtime_config: RuntimeConfig, -) -> Result<(CodeGraph, Vec)> { - let mut builder = base_graph.to_builder(); - for caps in lang_caps.iter() { - if let Some(nc) = caps.presentation.naming_convention() { - builder.naming_conventions.insert(caps.language.clone(), nc); - } - } - builder.apply_ops(initial_ops)?; - - let shared_builder = Arc::new(Mutex::new(builder)); - let pending_stubs = Arc::new(Mutex::new(Vec::new())); - - let scheduler: naviscope_ingest::DynScheduler = - Arc::new(IndexWorkScheduler); - let executor: naviscope_ingest::DynExecutor = - Arc::new(IndexWorkExecutor { - resolver: Arc::clone(&resolver), - project_context, - }); - let deferred_store: naviscope_ingest::DynDeferredStore = - Arc::new(InMemoryDeferredQueue::default()); - let commit_sink_impl = Arc::new(IndexWorkCommitter { - builder: Arc::clone(&shared_builder), - pending_stubs: Arc::clone(&pending_stubs), - resolver, - routes, - }); - let commit_sink: naviscope_ingest::DynCommitSink = commit_sink_impl; - let metrics: naviscope_ingest::DynRuntimeMetrics = Arc::new(NoopRuntimeMetrics); - - let flow_config = FlowControlConfig::from(&runtime_config); - let bus = naviscope_ingest::TokioPipelineBus; - let channels = - >::open_channels( - &bus, - flow_config.channel_capacity, - ); - - let intake_tx = channels.intake_tx.clone(); - let rt = tokio::runtime::Handle::current(); - rt.block_on(async move { - let producer = async move { - for (index, file) in source_files.into_iter().enumerate() { - let msg = naviscope_ingest::Message { - msg_id: format!("src:{}", file.path().display()), - topic: "source-index".to_string(), - message_group: file.path().to_string_lossy().to_string(), - version: 1, - depends_on: Vec::new(), - epoch: index as u64, - payload: IndexWorkItem { file }, - metadata: BTreeMap::new(), - }; - intake_tx - .send(msg) - .await - .map_err(|_| IngestError::Execution("ingest intake closed".to_string()))?; - } - Ok::<(), IngestError>(()) - }; - - let consumer = kernel::run_pipeline( - channels, - scheduler, - executor, - deferred_store, - commit_sink, - metrics, - &flow_config, - ); - - let (_produced, _stats) = tokio::try_join!(producer, consumer)?; - Ok::<(), IngestError>(()) - }) - .map_err(ingest_to_naviscope_error)?; - - let graph = { - let mut guard = shared_builder - .lock() - .map_err(|_| NaviscopeError::Internal("graph builder poisoned".to_string()))?; - let builder = std::mem::take(&mut *guard); - builder.build() - }; - - let stubs = { - let mut guard = pending_stubs - .lock() - .map_err(|_| NaviscopeError::Internal("stub queue poisoned".to_string()))?; - std::mem::take(&mut *guard) - }; - - Ok((graph, stubs)) -} - -fn naviscope_to_ingest_error(err: NaviscopeError) -> IngestError { - IngestError::Execution(err.to_string()) -} - -fn ingest_to_naviscope_error(err: IngestError) -> NaviscopeError { - NaviscopeError::Internal(err.to_string()) -} diff --git a/crates/core/src/runtime/lifecycle.rs b/crates/core/src/runtime/lifecycle.rs new file mode 100644 index 0000000..9107526 --- /dev/null +++ b/crates/core/src/runtime/lifecycle.rs @@ -0,0 +1,234 @@ +use super::*; + +impl NaviscopeEngine { + /// Load index from disk + pub async fn load(&self) -> Result { + let path = self.index_path.clone(); + let lang_caps = self.lang_caps.clone(); + let build_caps = self.build_caps.clone(); + + // Load in blocking pool + let graph_opt = + tokio::task::spawn_blocking(move || Self::load_from_disk(&path, lang_caps, build_caps)) + .await + .map_err(|e| NaviscopeError::Internal(e.to_string()))??; + + if let Some(graph) = graph_opt { + let mut lock = self.current.write().await; + *lock = Arc::new(graph); + Ok(true) + } else { + Ok(false) + } + } + + /// Save current graph to disk + pub async fn save(&self) -> Result<()> { + let graph = self.snapshot().await; + let path = self.index_path.clone(); + let lang_caps = self.lang_caps.clone(); + let build_caps = self.build_caps.clone(); + + tokio::task::spawn_blocking(move || { + Self::save_to_disk(&graph, &path, lang_caps, build_caps) + }) + .await + .map_err(|e| NaviscopeError::Internal(e.to_string()))? + } + + /// Rebuild the index from scratch + pub async fn rebuild(&self) -> Result<()> { + { + let mut lock = self.current.write().await; + *lock = Arc::new(CodeGraph::empty()); + } + + let project_root = self.project_root.clone(); + let paths = tokio::task::spawn_blocking(move || Scanner::collect_paths(&project_root)) + .await + .map_err(|e| NaviscopeError::Internal(e.to_string()))?; + + self.update_files(paths).await + } + + /// Update specific files incrementally + pub async fn update_files(&self, files: Vec) -> Result<()> { + let _ = self.scan_global_assets().await; + let base_graph = self.snapshot().await; + let existing_metadata = Self::collect_existing_metadata(&base_graph); + let (graph_after_build, source_paths, project_context) = + self.run_build_phase(base_graph, files, existing_metadata).await?; + self.apply_graph_snapshot(graph_after_build).await; + self.submit_source_stream(source_paths, project_context).await?; + self.finalize_update().await?; + Ok(()) + } + + /// Refresh index (detect changes and update) + pub async fn refresh(&self) -> Result<()> { + let project_root = self.project_root.clone(); + + let paths = tokio::task::spawn_blocking(move || Scanner::collect_paths(&project_root)) + .await + .map_err(|e| NaviscopeError::Internal(e.to_string()))?; + + self.update_files(paths).await + } + + async fn ensure_ingest_adapter( + &self, + ) -> Result> { + let runtime = self + .ingest_adapter + .get_or_try_init(|| async { + crate::ingest::IngestAdapter::start( + self.current.clone(), + self.naming_conventions.clone(), + self.build_caps.clone(), + self.lang_caps.clone(), + self.stub_cache.clone(), + ) + .await + .map(Arc::new) + }) + .await + .map(Arc::clone)?; + + let drained = match self.pending_stub_requests.lock() { + Ok(mut pending) => pending.drain(..).collect::>(), + Err(_) => Vec::new(), + }; + for req in drained { + if let Err(err) = runtime.submit_stub_request(req).await { + tracing::warn!("Failed to submit deferred stub request: {}", err); + } + } + + Ok(runtime) + } + + fn collect_existing_metadata( + base_graph: &CodeGraph, + ) -> std::collections::HashMap { + let mut existing_metadata = std::collections::HashMap::new(); + for (path, entry) in base_graph.file_index() { + existing_metadata.insert( + PathBuf::from(base_graph.symbols().resolve(&path.0)), + entry.metadata.clone(), + ); + } + existing_metadata + } + + async fn run_build_phase( + &self, + base_graph: CodeGraph, + files: Vec, + existing_metadata: std::collections::HashMap, + ) -> Result<(CodeGraph, Vec, naviscope_plugin::ProjectContext)> { + let build_caps = self.build_caps.clone(); + let lang_caps = self.lang_caps.clone(); + tokio::task::spawn_blocking(move || -> Result<_> { + let mut manual_ops = Vec::new(); + let mut to_scan = Vec::new(); + + for path in files { + if path.exists() { + to_scan.push(path); + } else { + manual_ops.push(GraphOp::RemovePath { + path: Arc::from(path.as_path()), + }); + } + } + + let mut build_files = Vec::new(); + let mut source_paths = Vec::new(); + for file in Scanner::scan_files_iter(to_scan, &existing_metadata) { + if file.is_build() { + build_files.push(file); + } else { + source_paths.push(file.path().to_path_buf()); + } + } + + if build_files.is_empty() && source_paths.is_empty() && manual_ops.is_empty() { + return Ok((base_graph, Vec::new(), naviscope_plugin::ProjectContext::new())); + } + + let compiler = crate::indexing::compiler::BatchCompiler::with_caps( + (*build_caps).clone(), + (*lang_caps).clone(), + ); + + let mut project_context = naviscope_plugin::ProjectContext::new(); + let mut initial_ops = manual_ops; + + for bf in &build_files { + initial_ops.push(GraphOp::RemovePath { + path: Arc::from(bf.path()), + }); + initial_ops.push(GraphOp::UpdateFile { + metadata: bf.file.clone(), + }); + } + + let build_ops = compiler.compile_build_batch(&build_files, &mut project_context)?; + initial_ops.extend(build_ops); + + let mut builder = base_graph.to_builder(); + for caps in lang_caps.iter() { + if let Some(nc) = caps.presentation.naming_convention() { + builder.naming_conventions.insert(caps.language.clone(), nc); + } + } + builder.apply_ops(initial_ops)?; + + Ok((builder.build(), source_paths, project_context)) + }) + .await + .map_err(|e| NaviscopeError::Internal(e.to_string()))? + } + + async fn apply_graph_snapshot(&self, graph: CodeGraph) { + let mut lock = self.current.write().await; + *lock = Arc::new(graph); + } + + async fn submit_source_stream( + &self, + source_paths: Vec, + project_context: naviscope_plugin::ProjectContext, + ) -> Result<()> { + const SOURCE_SUBMIT_CHUNK_SIZE: usize = 256; + if source_paths.is_empty() { + return Ok(()); + } + + let ingest_adapter = self.ensure_ingest_adapter().await?; + let routes = self.global_asset_routes(); + + for chunk in source_paths.chunks(SOURCE_SUBMIT_CHUNK_SIZE) { + let chunk_paths = chunk.to_vec(); + let source_files = tokio::task::spawn_blocking(move || { + let existing = std::collections::HashMap::new(); + Scanner::scan_files_iter(chunk_paths, &existing).collect::>() + }) + .await + .map_err(|e| NaviscopeError::Internal(e.to_string()))?; + + if source_files.is_empty() { + continue; + } + + ingest_adapter + .submit_source_batch(source_files, project_context.clone(), routes.clone()) + .await?; + } + Ok(()) + } + + async fn finalize_update(&self) -> Result<()> { + self.save().await + } +} diff --git a/crates/core/src/runtime/mod.rs b/crates/core/src/runtime/mod.rs index d505a1e..17d242b 100644 --- a/crates/core/src/runtime/mod.rs +++ b/crates/core/src/runtime/mod.rs @@ -1,5 +1,378 @@ -pub mod ingest_adapter; -pub mod orchestrator; -pub mod watcher; +//! Core indexing engine with MVCC support + +use crate::asset::service::AssetStubService; +use crate::error::{NaviscopeError, Result}; +use crate::indexing::scanner::Scanner; +use crate::indexing::StubRequest; +use crate::model::{CodeGraph, GraphOp}; +use naviscope_plugin::{ + AssetDiscoverer, AssetIndexer, AssetSourceLocator, BuildCaps, LanguageCaps, NamingConvention, +}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use tokio::sync::RwLock; +use xxhash_rust::xxh3::xxh3_64; + +mod lifecycle; +mod storage; +mod watch; pub const DEFAULT_INDEX_DIR: &str = ".naviscope/indices"; + +/// Naviscope indexing engine +/// +/// Manages the current version of the code graph using MVCC: +/// - Readers get cheap snapshots (Arc clone) +/// - Writers create new versions and atomically swap +/// - No blocking during index updates +pub struct NaviscopeEngine { + /// Current version of the graph (double Arc for MVCC) + current: Arc>>, + + /// Project root path + project_root: PathBuf, + + /// Index storage path + index_path: PathBuf, + + /// Registered capabilities + build_caps: Arc>, + lang_caps: Arc>, + + /// Runtime registry: language name -> naming convention + naming_conventions: Arc>>, + + /// Cancellation token for background tasks (like watcher) + cancel_token: tokio_util::sync::CancellationToken, + + /// Global stub cache + stub_cache: Arc, + + /// Global asset service (new architecture) + asset_service: Option>, + + /// Resident ingest runtime for source/stub indexing. + ingest_adapter: tokio::sync::OnceCell>, + + /// Stub requests captured before ingest runtime is initialized. + pending_stub_requests: Mutex>, +} + +pub struct NaviscopeEngineBuilder { + project_root: PathBuf, + build_caps: Vec, + lang_caps: Vec, +} + +impl NaviscopeEngineBuilder { + pub fn new(project_root: PathBuf) -> Self { + Self { + project_root, + build_caps: Vec::new(), + lang_caps: Vec::new(), + } + } + + pub fn with_language_caps(mut self, caps: LanguageCaps) -> Self { + self.lang_caps.push(caps); + self + } + + pub fn with_build_caps(mut self, caps: BuildCaps) -> Self { + self.build_caps.push(caps); + self + } + + pub fn build(self) -> NaviscopeEngine { + let canonical_root = self + .project_root + .canonicalize() + .unwrap_or_else(|_| self.project_root.clone()); + let index_path = NaviscopeEngine::compute_index_path(&canonical_root); + let cancel_token = tokio_util::sync::CancellationToken::new(); + // Initialize global cache once + let stub_cache = Arc::new(crate::cache::GlobalStubCache::at_default_location()); + + // Process naming conventions + let mut conventions = HashMap::new(); + for caps in &self.lang_caps { + if let Some(nc) = caps.presentation.naming_convention() { + conventions.insert(caps.language.to_string(), nc); + } + } + + // Collect asset indexers from language plugins + let indexers: Vec> = self + .lang_caps + .iter() + .filter_map(|c| c.asset.asset_indexer()) + .collect(); + + // Collect asset discoverers from all plugins + let mut discoverers: Vec> = Vec::new(); + + // From language plugins (e.g., JdkDiscoverer from Java) + for caps in &self.lang_caps { + if let Some(d) = caps.asset.global_asset_discoverer() { + discoverers.push(d); + } + } + + // From build tool plugins (e.g., GradleCacheDiscoverer from Gradle) + for caps in &self.build_caps { + if let Some(d) = caps.asset.global_asset_discoverer() { + discoverers.push(d); + } + } + + // Collect asset source locators from all plugins + let mut source_locators: Vec> = Vec::new(); + for caps in &self.lang_caps { + if let Some(locator) = caps.asset.asset_source_locator() { + source_locators.push(locator); + } + } + for caps in &self.build_caps { + if let Some(locator) = caps.asset.asset_source_locator() { + source_locators.push(locator); + } + } + + // Project-local asset discoverers (optional hook) + for caps in &self.lang_caps { + if let Some(d) = caps.asset.project_asset_discoverer(&canonical_root) { + discoverers.push(d); + } + } + + for caps in &self.build_caps { + if let Some(d) = caps.asset.project_asset_discoverer(&canonical_root) { + discoverers.push(d); + } + } + + // Create asset service with discoverers from plugins + let asset_service = if !indexers.is_empty() && !discoverers.is_empty() { + Some(Arc::new(AssetStubService::new( + discoverers, + indexers, + vec![], // Generators will be added later + source_locators, + ))) + } else { + None + }; + + NaviscopeEngine { + current: Arc::new(RwLock::new(Arc::new(CodeGraph::empty()))), + project_root: canonical_root, + index_path, + build_caps: Arc::new(self.build_caps), + lang_caps: Arc::new(self.lang_caps), + naming_conventions: Arc::new(conventions), + cancel_token, + stub_cache, + asset_service, + ingest_adapter: tokio::sync::OnceCell::const_new(), + pending_stub_requests: Mutex::new(Vec::new()), + } + } +} + +impl Drop for NaviscopeEngine { + fn drop(&mut self) { + self.cancel_token.cancel(); + } +} + +impl NaviscopeEngine { + /// Create a builder for the engine + pub fn builder(project_root: PathBuf) -> NaviscopeEngineBuilder { + NaviscopeEngineBuilder::new(project_root) + } + + /// Get the project root path + pub fn root_path(&self) -> &Path { + &self.project_root + } + + /// Query semantic capabilities for a language. + pub fn semantic_cap( + &self, + language: crate::model::source::Language, + ) -> Option> { + self.lang_caps + .iter() + .find(|c| c.language == language) + .map(|c| c.semantic.clone()) + } + + /// Query node presenter for language or matching build-tool capability. + pub fn node_presenter( + &self, + language: crate::model::source::Language, + ) -> Option> { + self.lang_caps + .iter() + .find(|c| c.language == language) + .and_then(|c| c.presentation.node_presenter()) + .or_else(|| { + self.build_caps + .iter() + .find(|c| c.build_tool.as_str() == language.as_str()) + .and_then(|c| c.presentation.node_presenter()) + }) + } + + /// Query metadata codec for language or matching build-tool capability. + pub fn metadata_codec( + &self, + language: crate::model::source::Language, + ) -> Option> { + self.lang_caps + .iter() + .find(|c| c.language == language) + .and_then(|c| c.metadata_codec.metadata_codec()) + .or_else(|| { + self.build_caps + .iter() + .find(|c| c.build_tool.as_str() == language.as_str()) + .and_then(|c| c.metadata_codec.metadata_codec()) + }) + } + + /// Detect language capability by path. + pub fn language_for_path( + &self, + path: &std::path::Path, + ) -> Option { + self.lang_caps + .iter() + .find(|c| c.matcher.supports_path(path)) + .map(|c| c.language.clone()) + } + + /// Get naming conventions registry (cheap Arc clone) + pub(crate) fn naming_conventions( + &self, + ) -> Arc>> { + self.naming_conventions.clone() + } + + /// Get the asset service (if available) + pub fn asset_service(&self) -> Option<&Arc> { + self.asset_service.as_ref() + } + + /// Request on-demand stub generation for a single FQN. + /// Returns true if a request was accepted for execution. + pub fn request_stub_for_fqn(&self, fqn: &str) -> bool { + let Some(service) = &self.asset_service else { + return false; + }; + let Some(candidate_paths) = service.lookup_paths(fqn) else { + return false; + }; + if candidate_paths.is_empty() { + return false; + } + + let req = StubRequest { + fqn: fqn.to_string(), + candidate_paths, + }; + + if let Some(runtime) = self.ingest_adapter.get() { + return runtime.try_submit_stub_request(req).is_ok(); + } + + if let Ok(mut pending) = self.pending_stub_requests.lock() { + pending.push(req); + return true; + } + + false + } + + /// Run the global asset scan and populate routes + /// Returns the scan result with statistics + pub async fn scan_global_assets(&self) -> Option { + if let Some(service) = &self.asset_service { + let service = service.clone(); + let result = tokio::task::spawn_blocking(move || service.scan_sync()) + .await + .ok(); + result + } else { + None + } + } + + /// Get global asset routes snapshot (for passing to resolvers) + pub fn global_asset_routes(&self) -> HashMap> { + if let Some(service) = &self.asset_service { + service.routes_snapshot() + } else { + HashMap::new() + } + } + + /// Compute index storage path for a project + fn compute_index_path(project_root: &Path) -> PathBuf { + let base_dir = Self::get_base_index_dir(); + let abs_path = project_root + .canonicalize() + .unwrap_or_else(|_| project_root.to_path_buf()); + let hash = xxh3_64(abs_path.to_string_lossy().as_bytes()); + base_dir.join(format!("{:016x}.bin", hash)) + } + + /// Get a snapshot of the current graph (cheap operation) + pub async fn snapshot(&self) -> CodeGraph { + let lock = self.current.read().await; + (**lock).clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_snapshot_is_fast() { + let engine = NaviscopeEngine::builder(PathBuf::from(".")).build(); + + let start = std::time::Instant::now(); + for _ in 0..1000 { + let _graph = engine.snapshot().await; + } + let elapsed = start.elapsed(); + + // 1000 snapshots should be very fast + assert!(elapsed.as_millis() < 100, "Snapshots should be fast"); + } + + #[tokio::test] + async fn test_concurrent_snapshots() { + use tokio::task::JoinSet; + + let engine = Arc::new(NaviscopeEngine::builder(PathBuf::from(".")).build()); + + let mut set = JoinSet::new(); + + for _ in 0..10 { + let e = Arc::clone(&engine); + set.spawn(async move { + for _ in 0..10 { + let graph = e.snapshot().await; + assert_eq!(graph.node_count(), 0); + } + }); + } + + while let Some(result) = set.join_next().await { + result.unwrap(); + } + } +} diff --git a/crates/core/src/runtime/orchestrator/indexing.rs b/crates/core/src/runtime/orchestrator/indexing.rs deleted file mode 100644 index 4ba2a84..0000000 --- a/crates/core/src/runtime/orchestrator/indexing.rs +++ /dev/null @@ -1,202 +0,0 @@ -use super::*; - -impl NaviscopeEngine { - /// Load index from disk - pub async fn load(&self) -> Result { - let path = self.index_path.clone(); - let lang_caps = self.lang_caps.clone(); - let build_caps = self.build_caps.clone(); - - // Load in blocking pool - let graph_opt = - tokio::task::spawn_blocking(move || Self::load_from_disk(&path, lang_caps, build_caps)) - .await - .map_err(|e| NaviscopeError::Internal(e.to_string()))??; - - if let Some(graph) = graph_opt { - // Atomically update current - let mut lock = self.current.write().await; - *lock = Arc::new(graph); - Ok(true) - } else { - Ok(false) - } - } - - /// Save current graph to disk - pub async fn save(&self) -> Result<()> { - let graph = self.snapshot().await; - let path = self.index_path.clone(); - let lang_caps = self.lang_caps.clone(); - let build_caps = self.build_caps.clone(); - - tokio::task::spawn_blocking(move || { - Self::save_to_disk(&graph, &path, lang_caps, build_caps) - }) - .await - .map_err(|e| NaviscopeError::Internal(e.to_string()))? - } - - /// Rebuild the index from scratch - pub async fn rebuild(&self) -> Result<()> { - let _ = self.scan_global_assets().await; - let project_root = self.project_root.clone(); - let build_caps = self.build_caps.clone(); - let lang_caps = self.lang_caps.clone(); - let global_routes = self.global_asset_routes(); - - let stub_tx = self.stub_tx.clone(); - let (new_graph, stubs) = tokio::task::spawn_blocking(move || { - Self::build_index(&project_root, build_caps, lang_caps, stub_tx, global_routes) - }) - .await - .map_err(|e| NaviscopeError::Internal(e.to_string()))??; - - // Atomically update (write lock held for microseconds) - { - let mut lock = self.current.write().await; - *lock = Arc::new(new_graph); - } - - // Schedule stubs AFTER graph update using explicit requests - for req in stubs { - if let Err(e) = self.stub_tx.send(req.clone()) { - tracing::warn!("Failed to schedule stub: {}", e); - } - } - - // Save to disk - self.save().await?; - - Ok(()) - } - - /// Update specific files incrementally - pub async fn update_files(&self, files: Vec) -> Result<()> { - let _ = self.scan_global_assets().await; - let base_graph = self.snapshot().await; - let build_caps = self.build_caps.clone(); - let lang_caps = self.lang_caps.clone(); - let global_routes = Arc::new(self.global_asset_routes()); - - // Prepare existing file metadata for change detection - let mut existing_metadata = std::collections::HashMap::new(); - for (path, entry) in base_graph.file_index() { - existing_metadata.insert( - PathBuf::from(base_graph.symbols().resolve(&path.0)), - entry.metadata.clone(), - ); - } - - let current_lock = self.current.clone(); - let stub_tx = self.stub_tx.clone(); - - // Processing in blocking pool - tokio::task::spawn_blocking(move || -> Result<()> { - let mut manual_ops = Vec::new(); - let mut to_scan = Vec::new(); - - for path in files { - if path.exists() { - to_scan.push(path); - } else { - // File was deleted - manual_ops.push(GraphOp::RemovePath { - path: Arc::from(path.as_path()), - }); - } - } - - // 1. Initial scan to identify file types and changes - let scan_results = Scanner::scan_files(to_scan, &existing_metadata); - if scan_results.is_empty() && manual_ops.is_empty() { - return Ok(()); - } - - // Partition into build and source - let (build_files, source_files): (Vec<_>, Vec<_>) = - scan_results.into_iter().partition(|f| f.is_build()); - - let resolver = Arc::new( - IndexResolver::with_caps((*build_caps).clone(), (*lang_caps).clone()) - .with_stubbing(StubbingManager::new(stub_tx.clone())), - ); - - // 2. Phase 1: Heavy Build Resolution (Global Context) - let mut project_context_inner = crate::ingest::resolver::ProjectContext::new(); - let mut initial_ops = manual_ops; - - // IMPORTANT: RemovePath MUST come before AddNode for the same paths. - // Add RemovePath and UpdateFile for build files up front. - for bf in &build_files { - initial_ops.push(GraphOp::RemovePath { - path: Arc::from(bf.path()), - }); - initial_ops.push(GraphOp::UpdateFile { - metadata: bf.file.clone(), - }); - } - - // For build files, we still process them up front because they define the structure - let build_ops = - resolver.resolve_build_batch(&build_files, &mut project_context_inner)?; - initial_ops.extend(build_ops); - - let project_context = Arc::new(project_context_inner); - let routes = global_routes.clone(); - - // 3. Phase 2: Source processing via naviscope-ingest streaming runtime. - let (final_graph, pending_stubs) = crate::runtime::ingest_adapter::run_source_ingest( - &base_graph, - initial_ops, - source_files, - Arc::clone(&resolver), - project_context, - routes, - lang_caps, - naviscope_ingest::RuntimeConfig { - kernel_channel_capacity: 500, - max_in_flight: 256, - deferred_poll_limit: 256, - idle_sleep_ms: 10, - }, - )?; - - // 4. Final Swap - let final_graph = Arc::new(final_graph); - let rt = tokio::runtime::Handle::current(); - rt.block_on(async { - let mut lock = current_lock.write().await; - *lock = final_graph; - }); - - // 5. Schedule stubs - for req in pending_stubs { - if let Err(e) = stub_tx.send(req) { - tracing::warn!("Failed to schedule stub: {}", e); - } - } - - Ok(()) - }) - .await - .map_err(|e| crate::error::NaviscopeError::Internal(e.to_string()))??; - - // Save at the very end - self.save().await?; - - Ok(()) - } - - /// Refresh index (detect changes and update) - pub async fn refresh(&self) -> Result<()> { - let project_root = self.project_root.clone(); - - // Scan for all current files and update incrementally - let paths = tokio::task::spawn_blocking(move || Scanner::collect_paths(&project_root)) - .await - .map_err(|e| NaviscopeError::Internal(e.to_string()))?; - - self.update_files(paths).await - } -} diff --git a/crates/core/src/runtime/orchestrator/mod.rs b/crates/core/src/runtime/orchestrator/mod.rs deleted file mode 100644 index 020b29b..0000000 --- a/crates/core/src/runtime/orchestrator/mod.rs +++ /dev/null @@ -1,321 +0,0 @@ -//! Core indexing engine with MVCC support - -use crate::asset::service::AssetStubService; -use crate::error::{NaviscopeError, Result}; -use crate::ingest::builder::CodeGraphBuilder; -use crate::ingest::resolver::{IndexResolver, StubRequest, StubbingManager}; -use crate::ingest::scanner::Scanner; -use crate::model::{CodeGraph, GraphOp}; -use naviscope_plugin::{ - AssetDiscoverer, AssetEntry, AssetIndexer, AssetSource, AssetSourceLocator, BuildCaps, - LanguageCaps, NamingConvention, -}; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; -use std::sync::Arc; -use tokio::sync::RwLock; -use xxhash_rust::xxh3::xxh3_64; - -mod indexing; -mod storage; -mod stub_worker; -mod watch; - -/// Naviscope indexing engine -/// -/// Manages the current version of the code graph using MVCC: -/// - Readers get cheap snapshots (Arc clone) -/// - Writers create new versions and atomically swap -/// - No blocking during index updates -pub struct NaviscopeEngine { - /// Current version of the graph (double Arc for MVCC) - current: Arc>>, - - /// Project root path - project_root: PathBuf, - - /// Index storage path - index_path: PathBuf, - - /// Registered capabilities - build_caps: Arc>, - lang_caps: Arc>, - - /// Runtime registry: language name -> naming convention - naming_conventions: Arc>>, - - /// Cancellation token for background tasks (like watcher) - cancel_token: tokio_util::sync::CancellationToken, - - /// Background stubbing channel - stub_tx: tokio::sync::mpsc::UnboundedSender, - - /// Global stub cache - stub_cache: Arc, - - /// Global asset service (new architecture) - asset_service: Option>, -} - -pub struct NaviscopeEngineBuilder { - project_root: PathBuf, - build_caps: Vec, - lang_caps: Vec, -} - -impl NaviscopeEngineBuilder { - pub fn new(project_root: PathBuf) -> Self { - Self { - project_root, - build_caps: Vec::new(), - lang_caps: Vec::new(), - } - } - - pub fn with_language_caps(mut self, caps: LanguageCaps) -> Self { - self.lang_caps.push(caps); - self - } - - pub fn with_build_caps(mut self, caps: BuildCaps) -> Self { - self.build_caps.push(caps); - self - } - - pub fn build(self) -> NaviscopeEngine { - let canonical_root = self - .project_root - .canonicalize() - .unwrap_or_else(|_| self.project_root.clone()); - let index_path = NaviscopeEngine::compute_index_path(&canonical_root); - - let (stub_tx, stub_rx) = tokio::sync::mpsc::unbounded_channel(); - let cancel_token = tokio_util::sync::CancellationToken::new(); - // Initialize global cache once - let stub_cache = Arc::new(crate::cache::GlobalStubCache::at_default_location()); - - // Process naming conventions - let mut conventions = HashMap::new(); - for caps in &self.lang_caps { - if let Some(nc) = caps.presentation.naming_convention() { - conventions.insert(caps.language.to_string(), nc); - } - } - - // Collect asset indexers from language plugins - let indexers: Vec> = self - .lang_caps - .iter() - .filter_map(|c| c.asset.asset_indexer()) - .collect(); - - // Collect asset discoverers from all plugins - let mut discoverers: Vec> = Vec::new(); - - // From language plugins (e.g., JdkDiscoverer from Java) - for caps in &self.lang_caps { - if let Some(d) = caps.asset.global_asset_discoverer() { - discoverers.push(d); - } - } - - // From build tool plugins (e.g., GradleCacheDiscoverer from Gradle) - for caps in &self.build_caps { - if let Some(d) = caps.asset.global_asset_discoverer() { - discoverers.push(d); - } - } - - // Collect asset source locators from all plugins - let mut source_locators: Vec> = Vec::new(); - for caps in &self.lang_caps { - if let Some(locator) = caps.asset.asset_source_locator() { - source_locators.push(locator); - } - } - for caps in &self.build_caps { - if let Some(locator) = caps.asset.asset_source_locator() { - source_locators.push(locator); - } - } - - // Project-local asset discoverers (optional hook) - for caps in &self.lang_caps { - if let Some(d) = caps.asset.project_asset_discoverer(&canonical_root) { - discoverers.push(d); - } - } - - for caps in &self.build_caps { - if let Some(d) = caps.asset.project_asset_discoverer(&canonical_root) { - discoverers.push(d); - } - } - - // Create asset service with discoverers from plugins - let asset_service = if !indexers.is_empty() && !discoverers.is_empty() { - Some(Arc::new(AssetStubService::new( - discoverers, - indexers, - vec![], // Generators will be added later - source_locators, - ))) - } else { - None - }; - - let engine = NaviscopeEngine { - current: Arc::new(RwLock::new(Arc::new(CodeGraph::empty()))), - project_root: canonical_root, - index_path, - build_caps: Arc::new(self.build_caps), - lang_caps: Arc::new(self.lang_caps), - naming_conventions: Arc::new(conventions), - cancel_token: cancel_token.clone(), - stub_tx, - stub_cache: stub_cache.clone(), - asset_service, - }; - - engine.spawn_stub_worker(stub_rx, cancel_token, stub_cache); - - engine - } -} - -impl Drop for NaviscopeEngine { - fn drop(&mut self) { - self.cancel_token.cancel(); - } -} - -impl NaviscopeEngine { - /// Create a builder for the engine - pub fn builder(project_root: PathBuf) -> NaviscopeEngineBuilder { - NaviscopeEngineBuilder::new(project_root) - } - - /// Get the project root path - pub fn root_path(&self) -> &Path { - &self.project_root - } - - /// Get the index resolver configured with current plugins - pub fn get_resolver(&self) -> IndexResolver { - IndexResolver::with_caps((*self.build_caps).clone(), (*self.lang_caps).clone()) - .with_stubbing(StubbingManager::new(self.stub_tx.clone())) - } - - /// Get naming conventions registry (cheap Arc clone) - pub(crate) fn naming_conventions( - &self, - ) -> Arc>> { - self.naming_conventions.clone() - } - - /// Get the asset service (if available) - pub fn asset_service(&self) -> Option<&Arc> { - self.asset_service.as_ref() - } - - /// Request on-demand stub generation for a single FQN. - /// Returns true if a request was successfully enqueued. - pub fn request_stub_for_fqn(&self, fqn: &str) -> bool { - let Some(service) = &self.asset_service else { - return false; - }; - let Some(candidate_paths) = service.lookup_paths(fqn) else { - return false; - }; - if candidate_paths.is_empty() { - return false; - } - self.stub_tx - .send(StubRequest { - fqn: fqn.to_string(), - candidate_paths, - }) - .is_ok() - } - - /// Run the global asset scan and populate routes - /// Returns the scan result with statistics - pub async fn scan_global_assets(&self) -> Option { - if let Some(service) = &self.asset_service { - let service = service.clone(); - let result = tokio::task::spawn_blocking(move || service.scan_sync()) - .await - .ok(); - result - } else { - None - } - } - - /// Get global asset routes snapshot (for passing to resolvers) - pub fn global_asset_routes(&self) -> HashMap> { - if let Some(service) = &self.asset_service { - service.routes_snapshot() - } else { - HashMap::new() - } - } - - /// Compute index storage path for a project - fn compute_index_path(project_root: &Path) -> PathBuf { - let base_dir = Self::get_base_index_dir(); - let abs_path = project_root - .canonicalize() - .unwrap_or_else(|_| project_root.to_path_buf()); - let hash = xxh3_64(abs_path.to_string_lossy().as_bytes()); - base_dir.join(format!("{:016x}.bin", hash)) - } - - /// Get a snapshot of the current graph (cheap operation) - pub async fn snapshot(&self) -> CodeGraph { - let lock = self.current.read().await; - (**lock).clone() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn test_snapshot_is_fast() { - let engine = NaviscopeEngine::builder(PathBuf::from(".")).build(); - - let start = std::time::Instant::now(); - for _ in 0..1000 { - let _graph = engine.snapshot().await; - } - let elapsed = start.elapsed(); - - // 1000 snapshots should be very fast - assert!(elapsed.as_millis() < 100, "Snapshots should be fast"); - } - - #[tokio::test] - async fn test_concurrent_snapshots() { - use tokio::task::JoinSet; - - let engine = Arc::new(NaviscopeEngine::builder(PathBuf::from(".")).build()); - - let mut set = JoinSet::new(); - - for _ in 0..10 { - let e = Arc::clone(&engine); - set.spawn(async move { - for _ in 0..10 { - let graph = e.snapshot().await; - assert_eq!(graph.node_count(), 0); - } - }); - } - - while let Some(result) = set.join_next().await { - result.unwrap(); - } - } -} diff --git a/crates/core/src/runtime/orchestrator/stub_worker.rs b/crates/core/src/runtime/orchestrator/stub_worker.rs deleted file mode 100644 index 550c187..0000000 --- a/crates/core/src/runtime/orchestrator/stub_worker.rs +++ /dev/null @@ -1,115 +0,0 @@ -use super::*; - -impl NaviscopeEngine { - /// Start the background stubbing worker - pub(super) fn spawn_stub_worker( - &self, - mut rx: tokio::sync::mpsc::UnboundedReceiver, - cancel_token: tokio_util::sync::CancellationToken, - stub_cache: Arc, - ) { - let current = self.current.clone(); - let lang_caps = self.lang_caps.clone(); - let naming_conventions = self.naming_conventions.clone(); - - tokio::spawn(async move { - tracing::info!("Stubbing worker started"); - let mut seen_fqns = std::collections::HashSet::new(); - - loop { - tokio::select! { - _ = cancel_token.cancelled() => break, - Some(req) = rx.recv() => { - // Skip if already seen in this session to avoid redundant work - if !seen_fqns.insert(req.fqn.clone()) { - continue; - } - - // Check if node already exists and is resolved - { - let lock = current.read().await; - let graph = &**lock; - if let Some(idx) = graph.find_node(&req.fqn) { - if let Some(node) = graph.get_node(idx) { - if node.status == naviscope_api::models::graph::ResolutionStatus::Resolved { - continue; - } - } - } - } - - // Resolve - let mut ops = Vec::new(); - - for asset_path in req.candidate_paths { - // Try to create asset key for cache lookup - let asset_key = crate::cache::AssetKey::from_path(&asset_path).ok(); - - // Check cache first - if let Some(ref key) = asset_key { - if let Some(cached_stub) = stub_cache.lookup(key, &req.fqn) { - tracing::trace!("Cache hit for {}", req.fqn); - ops.push(GraphOp::AddNode { - data: Some(cached_stub), - }); - break; // Found it - } - } - - // If not in cache, generate stub - for caps in lang_caps.iter() { - let Some(generator) = caps.asset.stub_generator() else { - continue; - }; - if !generator.can_generate(&asset_path) { - continue; - } - - let entry = AssetEntry::new(asset_path.clone(), AssetSource::Unknown); - match generator.generate(&req.fqn, &entry) { - Ok(stub) => { - // Store in cache for future use - if let Some(ref key) = asset_key { - stub_cache.store(key, &stub); - tracing::trace!("Cached stub for {}", req.fqn); - } - ops.push(GraphOp::AddNode { data: Some(stub) }); - break; - } - Err(e) => { - tracing::debug!( - "Failed to generate stub for {}: {}", - req.fqn, - e - ); - } - } - } - - if !ops.is_empty() { - break; - } - } - - if !ops.is_empty() { - let mut lock = current.write().await; - let mut builder = (**lock).to_builder(); - - // Load naming conventions - let conventions = (*naming_conventions).clone(); - for (lang, nc) in conventions { - builder.naming_conventions.insert(naviscope_api::models::Language::from(lang), nc); - } - - if let Ok(()) = builder.apply_ops(ops) { - *lock = Arc::new(builder.build()); - tracing::trace!("Applied stub for {}", req.fqn); - } - } - } - } - } - tracing::info!("Stubbing worker stopped"); - }); - } -} diff --git a/crates/core/src/runtime/orchestrator/storage.rs b/crates/core/src/runtime/storage.rs similarity index 72% rename from crates/core/src/runtime/orchestrator/storage.rs rename to crates/core/src/runtime/storage.rs index 62b8fe1..70d7b56 100644 --- a/crates/core/src/runtime/orchestrator/storage.rs +++ b/crates/core/src/runtime/storage.rs @@ -31,7 +31,7 @@ impl NaviscopeEngine { } let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); - Path::new(&home).join(super::super::DEFAULT_INDEX_DIR) + Path::new(&home).join(super::DEFAULT_INDEX_DIR) } // ---- Helper methods ---- @@ -47,7 +47,7 @@ impl NaviscopeEngine { let bytes = std::fs::read(path)?; - let get_codec = |lang: &str| -> Option> { + let get_codec = |lang: &str| -> Option> { for caps in lang_caps.iter() { if caps.language.as_str() == lang { return caps.metadata_codec.metadata_codec(); @@ -99,7 +99,7 @@ impl NaviscopeEngine { std::fs::create_dir_all(parent)?; } - let get_codec = |lang: &str| -> Option> { + let get_codec = |lang: &str| -> Option> { for caps in lang_caps.iter() { if caps.language.as_str() == lang { return caps.metadata_codec.metadata_codec(); @@ -126,41 +126,6 @@ impl NaviscopeEngine { Ok(()) } - pub(super) fn build_index( - project_root: &Path, - build_caps: Arc>, - lang_caps: Arc>, - stub_tx: tokio::sync::mpsc::UnboundedSender, - global_routes: HashMap>, - ) -> Result<(CodeGraph, Vec)> { - // Scan and parse - let parse_results = - Scanner::scan_and_parse(project_root, &std::collections::HashMap::new()); - - // Resolve - let resolver = IndexResolver::with_caps((*build_caps).clone(), (*lang_caps).clone()) - .with_stubbing(StubbingManager::new(stub_tx)); - - // resolve() now returns both ops and the filled ProjectContext - let (ops, _project_context) = resolver.resolve(parse_results)?; - - // Build graph - let mut builder = CodeGraphBuilder::new(); - - // Register naming conventions - for caps in lang_caps.iter() { - if let Some(nc) = caps.presentation.naming_convention() { - builder.naming_conventions.insert(caps.language.clone(), nc); - } - } - - builder.apply_ops(ops.clone())?; - - let stubs = resolver.resolve_stubs(&ops, &global_routes); - - Ok((builder.build(), stubs)) - } - pub fn get_stub_cache(&self) -> Arc { self.stub_cache.clone() } diff --git a/crates/core/src/runtime/orchestrator/watch.rs b/crates/core/src/runtime/watch.rs similarity index 72% rename from crates/core/src/runtime/orchestrator/watch.rs rename to crates/core/src/runtime/watch.rs index 69c9326..14f47d4 100644 --- a/crates/core/src/runtime/orchestrator/watch.rs +++ b/crates/core/src/runtime/watch.rs @@ -1,4 +1,36 @@ use super::*; +use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher as NotifyWatcher}; +use std::path::Path; +use tokio::sync::mpsc; + +struct FsWatcher { + _watcher: RecommendedWatcher, + rx: mpsc::UnboundedReceiver>, +} + +impl FsWatcher { + fn new(root: &Path) -> notify::Result { + let (tx, rx) = mpsc::unbounded_channel(); + let mut watcher = RecommendedWatcher::new( + move |res| { + let _ = tx.send(res); + }, + Config::default(), + )?; + watcher.watch(root, RecursiveMode::Recursive)?; + Ok(Self { + _watcher: watcher, + rx, + }) + } + + async fn next_event_async(&mut self) -> Option { + match self.rx.recv().await { + Some(Ok(event)) => Some(event), + _ => None, + } + } +} impl NaviscopeEngine { /// Watch for filesystem changes and update incrementally. @@ -7,13 +39,11 @@ impl NaviscopeEngine { self: Arc, cancel_token: tokio_util::sync::CancellationToken, ) -> Result<()> { - use crate::runtime::watcher::Watcher; use std::collections::HashSet; use std::time::Duration; let root = self.project_root.clone(); - let mut watcher = - Watcher::new(&root).map_err(|e| NaviscopeError::Internal(e.to_string()))?; + let mut watcher = FsWatcher::new(&root).map_err(|e| NaviscopeError::Internal(e.to_string()))?; let engine_weak = Arc::downgrade(&self); @@ -37,7 +67,7 @@ impl NaviscopeEngine { let mut paths = HashSet::new(); for event in &pending_events { for path in &event.paths { - if crate::ingest::is_relevant_path(path) { + if crate::indexing::is_relevant_path(path) { paths.insert(path.clone()); } } diff --git a/crates/core/src/runtime/watcher.rs b/crates/core/src/runtime/watcher.rs deleted file mode 100644 index 0d2fbbe..0000000 --- a/crates/core/src/runtime/watcher.rs +++ /dev/null @@ -1,54 +0,0 @@ -use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher as NotifyWatcher}; -use std::path::Path; -use tokio::sync::mpsc; - -pub struct Watcher { - // Keep watcher alive - _watcher: RecommendedWatcher, - pub(crate) rx: mpsc::UnboundedReceiver>, -} - -impl Watcher { - pub fn new(root: &Path) -> notify::Result { - let (tx, rx) = mpsc::unbounded_channel(); - - let mut watcher = RecommendedWatcher::new( - move |res| { - let _ = tx.send(res); - }, - Config::default(), - )?; - - // Watch recursively - watcher.watch(root, RecursiveMode::Recursive)?; - - Ok(Self { - _watcher: watcher, - rx, - }) - } - - /// Blocks until an event is received. - pub fn next_event(&mut self) -> Option { - match self.rx.blocking_recv() { - Some(Ok(event)) => Some(event), - _ => None, - } - } - - /// Returns the next event (async). - pub async fn next_event_async(&mut self) -> Option { - match self.rx.recv().await { - Some(Ok(event)) => Some(event), - _ => None, - } - } - - /// Tries to receive an event without blocking. - pub fn try_next_event(&mut self) -> Option { - match self.rx.try_recv() { - Ok(Ok(event)) => Some(event), - _ => None, - } - } -} diff --git a/crates/core/tests/async_stubbing.rs b/crates/core/tests/async_stubbing.rs index 8e8f290..69ebc52 100644 --- a/crates/core/tests/async_stubbing.rs +++ b/crates/core/tests/async_stubbing.rs @@ -1,30 +1,12 @@ //! Tests for async stubbing workflow use naviscope_api::models::graph::ResolutionStatus; -use naviscope_core::ingest::resolver::StubbingManager; +use naviscope_core::indexing::stub_planner::StubPlanner; use naviscope_core::model::GraphOp; -use naviscope_core::runtime::orchestrator::NaviscopeEngine; +use naviscope_core::runtime::NaviscopeEngine; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; -use tokio::sync::mpsc; - -/// Test that the stubbing manager correctly sends requests -#[tokio::test] -async fn test_stubbing_manager_sends_requests() { - let (tx, mut rx) = mpsc::unbounded_channel(); - let manager = StubbingManager::new(tx); - - manager.request("com.example.Foo".to_string(), Vec::new()); - manager.request("com.example.Bar".to_string(), Vec::new()); - - // Verify requests are received - let req1 = rx.recv().await.expect("Should receive first request"); - assert_eq!(req1.fqn, "com.example.Foo"); - - let req2 = rx.recv().await.expect("Should receive second request"); - assert_eq!(req2.fqn, "com.example.Bar"); -} /// Test that JavaPlugin correctly reports external asset handling #[test] @@ -63,6 +45,7 @@ async fn test_async_stubbing_with_jar() { let engine = NaviscopeEngine::builder(temp_dir.clone()) .with_language_caps(java_caps) .build(); + let _ = engine.scan_global_assets().await; // Find a real JAR file (use JDK's rt.jar or similar) let java_home = std::env::var("JAVA_HOME").ok(); @@ -74,8 +57,6 @@ async fn test_async_stubbing_with_jar() { let mut routes = std::collections::HashMap::new(); routes.insert("java.lang.String".to_string(), vec![jmod.clone()]); - let resolver = engine.get_resolver(); - let ops = vec![GraphOp::AddNode { data: Some(naviscope_plugin::IndexNode { id: naviscope_api::models::symbol::NodeId::Flat("java.lang.String".to_string()), @@ -89,7 +70,10 @@ async fn test_async_stubbing_with_jar() { }), }]; - resolver.schedule_stubs(&ops, &routes); + let reqs = StubPlanner::plan(&ops, &routes); + for req in reqs { + assert!(engine.request_stub_for_fqn(&req.fqn)); + } tokio::time::sleep(Duration::from_millis(500)).await; @@ -105,29 +89,3 @@ async fn test_async_stubbing_with_jar() { let _ = std::fs::remove_dir_all(&temp_dir); } - -/// Test that duplicate FQNs are deduplicated in the worker -#[tokio::test] -async fn test_stubbing_deduplication() { - let (tx, mut rx) = mpsc::unbounded_channel(); - - let mut seen = std::collections::HashSet::new(); - - let manager = StubbingManager::new(tx); - - manager.request("com.example.Foo".to_string(), Vec::new()); - manager.request("com.example.Foo".to_string(), Vec::new()); - manager.request("com.example.Bar".to_string(), Vec::new()); - - let mut processed = Vec::new(); - while let Ok(req) = rx.try_recv() { - if seen.insert(req.fqn.clone()) { - processed.push(req.fqn); - } - } - - // Only unique FQNs should be processed - assert_eq!(processed.len(), 2); - assert!(processed.contains(&"com.example.Foo".to_string())); - assert!(processed.contains(&"com.example.Bar".to_string())); -} diff --git a/crates/core/tests/engine_api.rs b/crates/core/tests/engine_api.rs index 7ef5088..811ac30 100644 --- a/crates/core/tests/engine_api.rs +++ b/crates/core/tests/engine_api.rs @@ -1,6 +1,6 @@ use naviscope_api::GraphService; use naviscope_core::facade::EngineHandle; -use naviscope_core::runtime::orchestrator::NaviscopeEngine as CoreEngine; +use naviscope_core::runtime::NaviscopeEngine as CoreEngine; use std::sync::Arc; #[tokio::test] diff --git a/crates/core/tests/global_assets.rs b/crates/core/tests/global_assets.rs index 52d822a..6891b5d 100644 --- a/crates/core/tests/global_assets.rs +++ b/crates/core/tests/global_assets.rs @@ -1,4 +1,4 @@ -use naviscope_core::runtime::orchestrator::NaviscopeEngine; +use naviscope_core::runtime::NaviscopeEngine; use tempfile::tempdir; #[tokio::test] diff --git a/crates/core/tests/indexing.rs b/crates/core/tests/indexing.rs index 59ec78c..4c9423a 100644 --- a/crates/core/tests/indexing.rs +++ b/crates/core/tests/indexing.rs @@ -1,6 +1,6 @@ use naviscope_api::models::graph::{NodeKind, NodeSource, ResolutionStatus}; use naviscope_api::models::{BuildTool, EmptyMetadata, Range}; -use naviscope_core::runtime::orchestrator::NaviscopeEngine; +use naviscope_core::runtime::NaviscopeEngine; use naviscope_plugin::{ AssetCap, BuildCaps, BuildContent, BuildIndexCap, BuildParseCap, FileMatcherCap, MetadataCodecCap, ParsedFile, PresentationCap, ProjectContext, ResolvedUnit, diff --git a/crates/core/tests/semantic_traits.rs b/crates/core/tests/semantic_traits.rs index a93d811..7795acd 100644 --- a/crates/core/tests/semantic_traits.rs +++ b/crates/core/tests/semantic_traits.rs @@ -4,7 +4,7 @@ use naviscope_api::models::{ }; use naviscope_api::semantic::{SymbolInfoProvider, SymbolNavigator}; use naviscope_core::facade::EngineHandle; -use naviscope_core::runtime::orchestrator::NaviscopeEngine as CoreEngine; +use naviscope_core::runtime::NaviscopeEngine as CoreEngine; use naviscope_plugin::{ AssetCap, CodecContext, FileMatcherCap, GlobalParseResult, LanguageCaps, LanguageParseCap, LspSyntaxService, MetadataCodecCap, NamingConvention, NodeMetadataCodec, NodePresenter, diff --git a/crates/ingest/Cargo.toml b/crates/ingest/Cargo.toml index 8b3348d..a4017fc 100644 --- a/crates/ingest/Cargo.toml +++ b/crates/ingest/Cargo.toml @@ -1,13 +1,11 @@ [package] name = "naviscope-ingest" -version = "0.1.0" +version = "0.5.5" edition = "2024" [dependencies] thiserror = { workspace = true } tokio = { workspace = true } -rayon = { workspace = true } tracing = { workspace = true } -dashmap = { workspace = true } naviscope-api = { workspace = true } async-trait = { workspace = true } diff --git a/crates/ingest/src/runtime/mod.rs b/crates/ingest/src/runtime/mod.rs index a77bad4..0296792 100644 --- a/crates/ingest/src/runtime/mod.rs +++ b/crates/ingest/src/runtime/mod.rs @@ -64,6 +64,17 @@ impl

IntakeHandle

where P: Clone + Send + Sync + 'static, { + pub fn try_submit(&self, message: Message

) -> Result<(), IngestError> { + self.tx.try_send(message).map_err(|err| match err { + tokio::sync::mpsc::error::TrySendError::Full(_) => { + IngestError::Execution("ingest intake queue full".to_string()) + } + tokio::sync::mpsc::error::TrySendError::Closed(_) => { + IngestError::Execution("ingest intake handle closed".to_string()) + } + }) + } + pub async fn submit(&self, message: Message

) -> Result<(), IngestError> { self.tx .send(message) diff --git a/crates/lang-java/tests/call_hierarchy_behavior.rs b/crates/lang-java/tests/call_hierarchy_behavior.rs index 5fc1fbc..a5dde51 100644 --- a/crates/lang-java/tests/call_hierarchy_behavior.rs +++ b/crates/lang-java/tests/call_hierarchy_behavior.rs @@ -3,7 +3,7 @@ mod common; use common::{offset_to_point, setup_java_test_graph}; use naviscope_core::features::CodeGraphLike; use naviscope_core::features::discovery::DiscoveryEngine; -use naviscope_core::ingest::parser::SymbolResolution; +use naviscope_api::models::SymbolResolution; use naviscope_java::JavaPlugin; use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; diff --git a/crates/lang-java/tests/common/mod.rs b/crates/lang-java/tests/common/mod.rs index fa63919..bb5bc33 100644 --- a/crates/lang-java/tests/common/mod.rs +++ b/crates/lang-java/tests/common/mod.rs @@ -1,5 +1,5 @@ use naviscope_api::models::Language; -use naviscope_core::ingest::builder::CodeGraphBuilder; +use naviscope_core::model::builder::CodeGraphBuilder; use naviscope_java::JavaPlugin; use naviscope_java::parser::JavaParser; use naviscope_plugin::{ @@ -92,7 +92,7 @@ pub async fn setup_java_engine( files: Vec<(&str, &str)>, ) -> naviscope_core::facade::EngineHandle { ensure_test_index_dir(); - use naviscope_core::runtime::orchestrator::NaviscopeEngine as CoreEngine; + use naviscope_core::runtime::NaviscopeEngine as CoreEngine; let java_caps = naviscope_java::java_caps().expect("Failed to create Java caps"); let engine = CoreEngine::builder(temp_dir.to_path_buf()) .with_language_caps(java_caps) diff --git a/crates/lang-java/tests/goto_definition_behavior.rs b/crates/lang-java/tests/goto_definition_behavior.rs index 0baea92..0b7f37f 100644 --- a/crates/lang-java/tests/goto_definition_behavior.rs +++ b/crates/lang-java/tests/goto_definition_behavior.rs @@ -2,7 +2,7 @@ mod common; use common::{offset_to_point, setup_java_test_graph}; use naviscope_core::features::CodeGraphLike; -use naviscope_core::ingest::parser::SymbolResolution; +use naviscope_api::models::SymbolResolution; use naviscope_java::JavaPlugin; use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; diff --git a/crates/lang-java/tests/java_integration.rs b/crates/lang-java/tests/java_integration.rs index d77afd7..7378a4e 100644 --- a/crates/lang-java/tests/java_integration.rs +++ b/crates/lang-java/tests/java_integration.rs @@ -32,7 +32,7 @@ fn test_cross_file_resolution() { let res = resolver.resolve_at(b_tree, b_content, 0, a_pos, &index); assert!(res.is_some(), "Failed to resolve 'A' at {}", a_pos); - if let Some(naviscope_core::ingest::parser::SymbolResolution::Precise(fqn, _)) = res { + if let Some(naviscope_api::models::SymbolResolution::Precise(fqn, _)) = res { assert_eq!(fqn, "com.example.A"); } else { panic!( @@ -49,7 +49,7 @@ fn test_cross_file_resolution() { let res = resolver.resolve_at(b_tree, b_content, 0, hello_pos, &index); assert!(res.is_some(), "Failed to resolve 'hello' at {}", hello_pos); - if let Some(naviscope_core::ingest::parser::SymbolResolution::Precise(fqn, _)) = res { + if let Some(naviscope_api::models::SymbolResolution::Precise(fqn, _)) = res { assert_eq!(fqn, "com.example.A#hello"); } else { panic!( @@ -124,7 +124,7 @@ fn test_inner_class_resolution() { let res = resolver.resolve_at(client_tree, client_content, 0, inner_pos, &index); assert!(res.is_some(), "Failed to resolve 'Inner' at {}", inner_pos); - if let Some(naviscope_core::ingest::parser::SymbolResolution::Precise(fqn, _)) = res { + if let Some(naviscope_api::models::SymbolResolution::Precise(fqn, _)) = res { assert_eq!(fqn, "com.example.Outer.Inner"); } else { panic!( @@ -167,7 +167,7 @@ fn test_chained_calls_resolution() { .expect("Could not find 'getC()'"); let res = resolver.resolve_at(main_tree, main_content, 0, get_c_pos, &index); assert!(res.is_some(), "Failed to resolve 'getC' at {}", get_c_pos); - if let Some(naviscope_core::ingest::parser::SymbolResolution::Precise(fqn, _)) = res { + if let Some(naviscope_api::models::SymbolResolution::Precise(fqn, _)) = res { assert_eq!(fqn, "com.chain.B#getC"); } else { panic!( @@ -186,7 +186,7 @@ fn test_chained_calls_resolution() { "Failed to resolve 'execute' at {}", execute_pos ); - if let Some(naviscope_core::ingest::parser::SymbolResolution::Precise(fqn, _)) = res { + if let Some(naviscope_api::models::SymbolResolution::Precise(fqn, _)) = res { assert_eq!(fqn, "com.chain.C#execute"); } else { panic!( @@ -236,7 +236,7 @@ public class Main { "Failed to resolve chain middle node 'getContext'" ); - if let Some(naviscope_core::ingest::parser::SymbolResolution::Precise(fqn, _)) = res { + if let Some(naviscope_api::models::SymbolResolution::Precise(fqn, _)) = res { assert_eq!(fqn, "com.web.HttpResponse#getContext"); } else { panic!( @@ -268,7 +268,7 @@ fn test_lambda_parameter_resolution() { "Failed to resolve lambda parameter 'it' at {}", it_usage_pos ); - if let Some(naviscope_core::ingest::parser::SymbolResolution::Local(range, _)) = res { + if let Some(naviscope_api::models::SymbolResolution::Local(range, _)) = res { // The definition of 'it' should be at 'it ->' let it_def_pos = content.find("it ->").expect("Could not find 'it ->'"); assert_eq!(range.start_col, it_def_pos); @@ -308,7 +308,7 @@ fn test_lambda_explicit_type_resolution() { "Failed to resolve 'hello' on lambda parameter at {}", hello_pos ); - if let Some(naviscope_core::ingest::parser::SymbolResolution::Precise(fqn, _)) = res { + if let Some(naviscope_api::models::SymbolResolution::Precise(fqn, _)) = res { assert_eq!(fqn, "com.A#hello"); } else { panic!("Expected precise resolution for it.hello(), got {:?}", res); @@ -343,7 +343,7 @@ fn test_lambda_heuristic_type_inference() { "Failed to resolve 'hello' on lambda parameter via heuristic at {}", hello_pos ); - if let Some(naviscope_core::ingest::parser::SymbolResolution::Precise(fqn, _)) = res { + if let Some(naviscope_api::models::SymbolResolution::Precise(fqn, _)) = res { assert_eq!(fqn, "com.A#hello"); } else { panic!( @@ -392,7 +392,7 @@ public class DefaultApplicationArguments { line, col ); - if let Some(naviscope_core::ingest::parser::SymbolResolution::Precise(fqn, _)) = res { + if let Some(naviscope_api::models::SymbolResolution::Precise(fqn, _)) = res { assert_eq!(fqn, "DefaultApplicationArguments"); } else { panic!("Expected precise resolution for 'this', got {:?}", res); @@ -465,7 +465,7 @@ public class DefaultApplicationArguments { let res = resolver.resolve_at(tree, source_content, line, col, &index); - if let Some(naviscope_core::ingest::parser::SymbolResolution::Precise(fqn, _)) = res { + if let Some(naviscope_api::models::SymbolResolution::Precise(fqn, _)) = res { assert_eq!( fqn, "org.springframework.boot.DefaultApplicationArguments.Source#getNonOptionArgs" @@ -514,7 +514,7 @@ fn test_field_method_call_resolution() { line, col ); - if let Some(naviscope_core::ingest::parser::SymbolResolution::Precise(fqn, _)) = res { + if let Some(naviscope_api::models::SymbolResolution::Precise(fqn, _)) = res { assert_eq!(fqn, "B#doB"); } else { panic!("Expected precise resolution to B.doB, got {:?}", res); diff --git a/crates/plugin/Cargo.toml b/crates/plugin/Cargo.toml index 00723e3..518b442 100644 --- a/crates/plugin/Cargo.toml +++ b/crates/plugin/Cargo.toml @@ -11,4 +11,4 @@ async-trait = { workspace = true } thiserror = { workspace = true } tree-sitter = { workspace = true } lsp-types = { workspace = true } -serde_bytes = "0.11.19" +serde_bytes = { workspace = true } diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs index 7f0702c..1ada255 100644 --- a/crates/runtime/src/lib.rs +++ b/crates/runtime/src/lib.rs @@ -8,7 +8,7 @@ use std::sync::Arc; /// This function acts as the central factory for the Naviscope runtime, /// assembling the core engine with language-specific plugins like Java and Gradle. pub fn build_default_engine(path: PathBuf) -> Arc { - let mut builder = naviscope_core::runtime::orchestrator::NaviscopeEngine::builder(path); + let mut builder = naviscope_core::runtime::NaviscopeEngine::builder(path); // Register Build Tool Caps builder = builder.with_build_caps(naviscope_gradle::gradle_caps()); @@ -38,7 +38,7 @@ pub fn init_logging(component: &str, to_stderr: bool) -> Option { /// Utility to clear all indices stored on the local system. pub fn clear_all_indices() -> ApiResult<()> { - naviscope_core::runtime::orchestrator::NaviscopeEngine::clear_all_indices() + naviscope_core::runtime::NaviscopeEngine::clear_all_indices() .map_err(|e: naviscope_core::error::NaviscopeError| ApiError::Internal(e.to_string())) } diff --git a/type_system_integration_plan.md b/type_system_integration_plan.md new file mode 100644 index 0000000..a9643b8 --- /dev/null +++ b/type_system_integration_plan.md @@ -0,0 +1,95 @@ +# Type System 架构深度剥离与接入方案 (v2) + +## 1. 核心哲学:语义解耦 + +我们不应将 Type System 视为一个额外的“功能模块”,而应将其视为**插件的语义大脑**。 +* `LspService`: 负责**语法与局部上下文**(提取符号、查找词法引用)。 +* `SemanticResolver`: 负责**图查询与全局映射**(查找定义、实现、解析 FQN)。 +* `TypeSystem` (新): 负责**逻辑判断与关系推理**(子类型、重写、匹配验证)。 + +## 2. 插件层 (naviscope_plugin) 调整 + +在 `naviscope_plugin` 中确立 `TypeSystem` 的地位,并让其他 Service 能够引用它。 + +### 2.1 定义独立 Trait +```rust +// crates/plugin/src/type_system.rs + +pub trait TypeSystem: Send + Sync { + /// 核心判断逻辑:在给定的图上下文中,candidate 是否是 target 的一个有效语义引用? + /// 这封装了继承、匹配、重写等所有复杂逻辑。 + fn is_reference_to( + &self, + graph: &dyn CodeGraph, + candidate: &SymbolResolution, + target: &SymbolResolution, + ) -> bool; + + /// 获取类型层级关系 + fn is_subtype(&self, graph: &dyn CodeGraph, sub: &str, sup: &str) -> bool; +} +``` + +### 2.2 统一生命周期 +语言插件(如 `JavaPlugin`)不再零散地创建服务,而是统一初始化: +1. 初始化 `LanguageTypeSystem`。 +2. 将该 `TypeSystem` 分别注入给 `LspService` 和 `SemanticResolver`。 + +```rust +// 插件接口更新 +pub trait LanguagePlugin: Send + Sync { + fn type_system(&self) -> Arc; + fn lsp_service(&self) -> Arc; // 内部持有 type_system + fn semantic_resolver(&self) -> Arc; // 内部持有 type_system +} +``` + +## 3. Java 插件的实现微调 (lang-java) + +通过“依赖注入”消除重复逻辑: +* **JavaTypeSystem**: 包装 `crates/lang-java/src/inference` 中的核心推理引擎。 +* **JavaLspService**: 在执行 `find_occurrences` 时,利用注入的 `TypeSystem` 进行局部验证。 +* **JavaResolver**: 在执行 `resolve_at` 时,利用注入的 `TypeSystem` 关联图节点。 + +## 4. 核心引擎 (crates/core) 的极简调用 + +`DiscoveryEngine` 的 `scan_file` 逻辑将变得极其纯粹,它只负责流程编排,不持有任何判断逻辑: + +```rust +// crates/core/src/features/discovery.rs + +pub fn scan_file( + &self, + lsp_service: &dyn LspService, + type_system: &dyn TypeSystem, // 新增参数 + resolver: &dyn SemanticResolver, + source: &str, + target_resolution: &SymbolResolution, +) -> Vec { + // 1. 委托 LspService 进行快速语法扫描 + let candidates = lsp_service.find_occurrences(source, &tree, target_resolution); + + // 2. 委托 Resolver 进行实时点位解析 + for range in candidates { + if let Some(resolved) = resolver.resolve_at(...) { + // 3. 委托 TypeSystem 进行最终的语义身份验证 + if type_system.is_reference_to(self.index, &resolved, target_resolution) { + valid_locations.push(...); + } + } + } +} +``` + +## 5. 方案优势 + +1. **实现不重复**:复杂的 Java 类型逻辑(如 Bridge Method, Generics)只在 `JavaTypeSystem` 中写一次。 +2. **职责明确**:`LspService` 不再需要理解什么是“子类方法”,它只管找“在这个作用域内叫这个名字的符号”。 +3. **可测试性**:可以给 `scan_file` 注入一个 `MockTypeSystem` 来测试核心流程。 + +## 6. 改造清单 + +- [ ] **naviscope-plugin**: 新增 `TypeSystem` trait,并将其加入 `LanguagePlugin` 初始化流。 +- [ ] **lang-java**: 将现有的 `inference` 代码包装为 `TypeSystem` 实现。 +- [ ] **lang-java**: 重构 `JavaLspService` 和 `JavaResolver`,使其通过构造函数接收 `TypeSystem`。 +- [ ] **core**: 在获取服务时,同步获取 `TypeSystem` 并传递给 `DiscoveryEngine`。 From 64e461c0f93a40d1ebc375c03a2c393fc2ad66b1 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Sat, 14 Feb 2026 16:32:54 +0800 Subject: [PATCH 40/49] refactor(indexing): enhance file processing and parsing in BatchCompiler and Scanner --- .gitignore | 4 ++- crates/core/src/indexing/compiler.rs | 42 ++++++++++++++++++++++++++-- crates/core/src/indexing/scanner.rs | 31 ++++++-------------- crates/core/src/runtime/lifecycle.rs | 5 +++- 4 files changed, 55 insertions(+), 27 deletions(-) diff --git a/.gitignore b/.gitignore index 41eee24..a40d227 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ node_modules/ out/ -*.vsix \ No newline at end of file +*.vsix + +plan \ No newline at end of file diff --git a/crates/core/src/indexing/compiler.rs b/crates/core/src/indexing/compiler.rs index fd85d52..4f98cd4 100644 --- a/crates/core/src/indexing/compiler.rs +++ b/crates/core/src/indexing/compiler.rs @@ -1,7 +1,8 @@ use crate::error::Result; use crate::indexing::scanner::ParsedFile; use crate::model::{GraphOp, ResolvedUnit}; -use naviscope_plugin::{BuildCaps, LanguageCaps, ProjectContext}; +use naviscope_plugin::{BuildCaps, BuildContent, LanguageCaps, ParsedContent, ProjectContext}; +use std::fs; pub struct BatchCompiler { build_caps: Vec, @@ -29,9 +30,14 @@ impl BatchCompiler { .collect(); if !tool_files.is_empty() { + let parsed_tool_files: Vec = tool_files + .iter() + .map(|f| Self::prepare_build_file(caps, f)) + .collect::>>()?; + let parsed_tool_file_refs: Vec<&ParsedFile> = parsed_tool_files.iter().collect(); let (unit, ctx) = caps .indexing - .compile_build(&tool_files) + .compile_build(&parsed_tool_file_refs) .map_err(crate::error::NaviscopeError::from)?; all_ops.extend(unit.ops); context.path_to_module.extend(ctx.path_to_module); @@ -70,4 +76,36 @@ impl BatchCompiler { } Ok(all_ops) } + + fn prepare_build_file(caps: &BuildCaps, file: &ParsedFile) -> Result { + let source = match &file.content { + ParsedContent::Unparsed(s) => s.clone(), + ParsedContent::Lazy => fs::read_to_string(file.path()).map_err(|e| { + crate::error::NaviscopeError::Internal(format!( + "Failed to read build file {}: {}", + file.path().display(), + e + )) + })?, + ParsedContent::Metadata(_) => return Ok(file.clone()), + ParsedContent::Language(_) => return Ok(file.clone()), + }; + + let parse_result = caps + .parser + .parse_build_file(&source) + .map_err(crate::error::NaviscopeError::from)?; + + let content = match parse_result.content { + BuildContent::Metadata(value) => ParsedContent::Metadata(value), + BuildContent::Unparsed(text) => ParsedContent::Unparsed(text), + // Build indexing currently consumes Metadata/Unparsed; preserve source for this case. + BuildContent::Parsed(_) => ParsedContent::Unparsed(source), + }; + + Ok(ParsedFile { + file: file.file.clone(), + content, + }) + } } diff --git a/crates/core/src/indexing/scanner.rs b/crates/core/src/indexing/scanner.rs index 0f568f1..2456447 100644 --- a/crates/core/src/indexing/scanner.rs +++ b/crates/core/src/indexing/scanner.rs @@ -37,20 +37,17 @@ impl Scanner { .collect() } - fn process_file_with_mtime(path: &Path, mtime: u64) -> Option<(SourceFile, Vec)> { + fn process_file_with_mtime(path: &Path, mtime: u64) -> Option { let content = fs::read(path).ok()?; let mut hasher = Xxh3::new(); hasher.write(&content); let hash = hasher.finish(); - Some(( - SourceFile { - path: path.to_path_buf(), - content_hash: hash, - last_modified: mtime, - }, - content, - )) + Some(SourceFile { + path: path.to_path_buf(), + content_hash: hash, + last_modified: mtime, + }) } fn parse_path(path: &Path, existing_files: &HashMap) -> Option { @@ -70,7 +67,7 @@ impl Scanner { } // 2. Read and hash content - let (source_file, content) = Self::process_file_with_mtime(path, modified)?; + let source_file = Self::process_file_with_mtime(path, modified)?; // 3. Double check hash (mtime might change but content remains same) if let Some(existing) = existing_files.get(path) { @@ -79,19 +76,7 @@ impl Scanner { } } - let content_str = String::from_utf8(content).ok()?; - let file_name = path.file_name()?.to_str()?; - - if file_name == "build.gradle" - || file_name == "build.gradle.kts" - || file_name == "settings.gradle" - || file_name == "settings.gradle.kts" - { - Some(ParsedFile { - file: source_file, - content: ParsedContent::Unparsed(content_str), - }) - } else if path.extension().is_some() { + if path.extension().is_some() { Some(ParsedFile { file: source_file, content: ParsedContent::Lazy, diff --git a/crates/core/src/runtime/lifecycle.rs b/crates/core/src/runtime/lifecycle.rs index 9107526..cdefe14 100644 --- a/crates/core/src/runtime/lifecycle.rs +++ b/crates/core/src/runtime/lifecycle.rs @@ -145,7 +145,10 @@ impl NaviscopeEngine { let mut build_files = Vec::new(); let mut source_paths = Vec::new(); for file in Scanner::scan_files_iter(to_scan, &existing_metadata) { - if file.is_build() { + if build_caps + .iter() + .any(|caps| caps.matcher.supports_path(file.path())) + { build_files.push(file); } else { source_paths.push(file.path().to_path_buf()); From 6b490eeae2c43c303a20f0a0e5203bf1b747c961 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Sat, 14 Feb 2026 22:41:56 +0800 Subject: [PATCH 41/49] feat: enhance method signature handling and resolution in Java parser - Introduced new methods to extract parameter types from method declarations. - Updated method resolution to include parameter types in fully qualified names (FQNs). - Improved handling of method signatures in naming utilities, allowing for better formatting and parsing of method names with parameters. - Adjusted tests to validate changes in method resolution and signature handling, ensuring accurate representation of method calls with parameters. - Refactored existing code to streamline method parameter extraction and signature parsing. --- .../lang-java/src/inference/adapters/graph.rs | 54 +++- .../lang-java/src/inference/core/subtyping.rs | 17 +- .../src/inference/core/type_system.rs | 10 +- crates/lang-java/src/inference/mod.rs | 6 +- .../lang-java/src/inference/scope/manager.rs | 14 + .../src/inference/strategy/literal.rs | 39 +++ .../src/inference/strategy/method.rs | 9 +- .../lang-java/src/inference/strategy/mod.rs | 3 + crates/lang-java/src/lsp/references.rs | 111 ++++--- crates/lang-java/src/lsp/type_system.rs | 11 +- crates/lang-java/src/naming.rs | 277 +++++++++++++++++- crates/lang-java/src/parser/ast/entities.rs | 13 +- crates/lang-java/src/parser/naming.rs | 24 +- crates/lang-java/src/parser/types.rs | 114 +++++++ crates/lang-java/src/resolve/mod.rs | 12 +- .../tests/call_hierarchy_behavior.rs | 24 +- crates/lang-java/tests/capability_boundary.rs | 6 +- crates/lang-java/tests/edge_verification.rs | 4 +- .../tests/goto_definition_behavior.rs | 18 +- .../tests/implementation_behavior.rs | 2 +- crates/lang-java/tests/inference_mock_test.rs | 70 ++++- crates/lang-java/tests/java_integration.rs | 16 +- crates/lang-java/tests/references_behavior.rs | 114 +++++-- crates/lang-java/tests/test_engine_facade.rs | 44 +-- crates/plugin/src/lib.rs | 2 +- crates/plugin/src/naming.rs | 249 +++++++++++++++- 26 files changed, 1096 insertions(+), 167 deletions(-) create mode 100644 crates/lang-java/src/inference/strategy/literal.rs diff --git a/crates/lang-java/src/inference/adapters/graph.rs b/crates/lang-java/src/inference/adapters/graph.rs index 037dd4d..3aa163d 100644 --- a/crates/lang-java/src/inference/adapters/graph.rs +++ b/crates/lang-java/src/inference/adapters/graph.rs @@ -2,19 +2,17 @@ //! //! Adapts the CodeGraph to the JavaTypeSystem trait. +use lasso::Key; use naviscope_api::models::TypeRef; use naviscope_api::models::graph::{EdgeType, NodeKind, NodeMetadata}; use naviscope_api::models::symbol::{FqnId, Symbol}; use naviscope_plugin::{CodeGraph, Direction}; -use lasso::Key; use std::collections::HashSet; use std::sync::Arc; -use crate::inference::{InheritanceProvider, MemberProvider, TypeProvider}; -use crate::inference::{ - MemberInfo, MemberKind, TypeInfo, TypeKind, TypeResolutionContext, -}; use crate::inference::core::types::TypeParameter; +use crate::inference::{InheritanceProvider, MemberProvider, TypeProvider}; +use crate::inference::{MemberInfo, MemberKind, TypeInfo, TypeKind, TypeResolutionContext}; use crate::model::{JavaIndexMetadata, JavaNodeMetadata}; /// Adapter that implements JavaTypeSystem using CodeGraph. @@ -214,6 +212,15 @@ impl<'a> TypeProvider for CodeGraphTypeSystem<'a> { } fn resolve_type_name(&self, simple_name: &str, ctx: &TypeResolutionContext) -> Option { + // 0. Check known FQNs (Current file types) + for fqn in &ctx.known_fqns { + if fqn.ends_with(&format!(".{}", simple_name)) + || fqn.ends_with(&format!("#{}", simple_name)) + { + return Some(fqn.clone()); + } + } + // 1. Check explicit imports for imp in &ctx.imports { if imp.ends_with(&format!(".{}", simple_name)) { @@ -340,13 +347,26 @@ impl<'a> InheritanceProvider for CodeGraphTypeSystem<'a> { impl<'a> MemberProvider for CodeGraphTypeSystem<'a> { fn get_members(&self, type_fqn: &str, member_name: &str) -> Vec { - // Use unified member FQN format - let member_fqn = crate::naming::build_member_fqn(type_fqn, member_name); - let node_ids = self.graph.resolve_fqn(&member_fqn); + // With signature-based FQNs, methods are stored as e.g. `A#target(int)`. + // We can't construct the full member FQN from just the simple name, so + // we traverse the type's children and match by simple name. + // Normalize: callers may pass either `leaf` or `leaf()` as the member name. + let needle = crate::naming::extract_simple_name(member_name); + let node_ids = self.graph.resolve_fqn(type_fqn); let mut members = Vec::new(); - for &node_id in &node_ids { - if let Some(node) = self.graph.get_node(node_id) { + for &type_node_id in &node_ids { + let children = self.graph.get_neighbors( + type_node_id, + Direction::Outgoing, + Some(EdgeType::Contains), + ); + + for child_id in children { + let Some(node) = self.graph.get_node(child_id) else { + continue; + }; + let kind = match &node.kind { NodeKind::Method => MemberKind::Method, NodeKind::Field => MemberKind::Field, @@ -354,11 +374,21 @@ impl<'a> MemberProvider for CodeGraphTypeSystem<'a> { _ => continue, }; + let child_fqn = self.render_fqn_id(child_id); + // Extract the member part (after `#`) and strip signature to compare + let raw_member = crate::naming::extract_member_name(&child_fqn) + .unwrap_or_else(|| self.graph.fqns().resolve_atom(node.name)); + let simple = crate::naming::extract_simple_name(raw_member); + + if simple != needle { + continue; + } + let type_ref = self.extract_type_from_metadata(&node.metadata); members.push(MemberInfo { - name: member_name.to_string(), - fqn: member_fqn.clone(), + name: raw_member.to_string(), + fqn: child_fqn, kind, declaring_type: type_fqn.to_string(), type_ref, diff --git a/crates/lang-java/src/inference/core/subtyping.rs b/crates/lang-java/src/inference/core/subtyping.rs index b887dd1..c355365 100644 --- a/crates/lang-java/src/inference/core/subtyping.rs +++ b/crates/lang-java/src/inference/core/subtyping.rs @@ -33,6 +33,10 @@ pub fn is_subtype(sub: &TypeRef, super_type: &TypeRe // Primitive boxing + reference widening, e.g. int -> Integer / Number / Object (TypeRef::Raw(prim), TypeRef::Id(super_id)) => { + // Allow relaxed matching for bad parser output (Id("int")) + if prim == super_id { + return true; + } if let Some(wrapper) = primitive_to_wrapper(prim) { if wrapper == super_id { return true; @@ -44,6 +48,9 @@ pub fn is_subtype(sub: &TypeRef, super_type: &TypeRe // Wrapper unboxing (+ primitive widening), e.g. Integer -> int / long (TypeRef::Id(sub_id), TypeRef::Raw(super_prim)) => { + if sub_id == super_prim { + return true; + } if let Some(unboxed) = wrapper_to_primitive(sub_id) { return unboxed == super_prim || is_primitive_subtype(unboxed, super_prim); } @@ -51,7 +58,15 @@ pub fn is_subtype(sub: &TypeRef, super_type: &TypeRe } // Class/Interface hierarchy - (TypeRef::Id(sub_id), TypeRef::Id(super_id)) => is_class_subtype(sub_id, super_id, ts), + (TypeRef::Id(sub_id), TypeRef::Id(super_id)) => { + if sub_id == super_id { + return true; + } + if sub_id == &format!("java.lang.{}", super_id) { + return true; + } + is_class_subtype(sub_id, super_id, ts) + } // Arrays (Covariant for references) (TypeRef::Array { element: e1, .. }, TypeRef::Array { element: e2, .. }) => { diff --git a/crates/lang-java/src/inference/core/type_system.rs b/crates/lang-java/src/inference/core/type_system.rs index 79d20c3..4e6357b 100644 --- a/crates/lang-java/src/inference/core/type_system.rs +++ b/crates/lang-java/src/inference/core/type_system.rs @@ -111,7 +111,9 @@ pub trait JavaTypeSystem: TypeProvider + InheritanceProvider + MemberProvider { // 2. Subtype match (widening) let subtype_fixed = collect_matching_candidates(candidates, |params| { - matches_fixed_arity(params, arg_types, |arg, expected| self.is_subtype(arg, expected)) + matches_fixed_arity(params, arg_types, |arg, expected| { + self.is_subtype(arg, expected) + }) }); if !subtype_fixed.is_empty() { return select_most_specific(self, subtype_fixed, arg_types); @@ -135,9 +137,9 @@ pub trait JavaTypeSystem: TypeProvider + InheritanceProvider + MemberProvider { return select_most_specific(self, subtype_varargs, arg_types); } - // Fallback to first if no parameters expected or provided - // (Helpful for field access or methods we couldn't match precisely) - candidates.first().cloned() + // Strict mode: if no overload matches exactly or via subtype, + // we return None. No fallback to arbitrary candidate. + None } /// Check if sub is a subtype of super_type. diff --git a/crates/lang-java/src/inference/mod.rs b/crates/lang-java/src/inference/mod.rs index 4636644..2dcd4b1 100644 --- a/crates/lang-java/src/inference/mod.rs +++ b/crates/lang-java/src/inference/mod.rs @@ -75,7 +75,11 @@ pub fn create_inference_context<'a>( builder.build(root); } // builder dropped, releasing mutable borrow - // 2. Attach populated scope manager to context + // 2. Extract known FQNs + let known_fqns = scope_manager.get_all_class_fqns(); + ctx.known_fqns = known_fqns; + + // 3. Attach populated scope manager to context ctx.scope_manager = Some(scope_manager); // Downgrade to shared reference ctx diff --git a/crates/lang-java/src/inference/scope/manager.rs b/crates/lang-java/src/inference/scope/manager.rs index 47735d9..92ac2d8 100644 --- a/crates/lang-java/src/inference/scope/manager.rs +++ b/crates/lang-java/src/inference/scope/manager.rs @@ -127,4 +127,18 @@ impl ScopeManager { pub fn get_scope_id(&self, node_id: usize) -> Option { self.node_to_scope.get(&node_id).copied() } + + /// Get all class FQNs found in this file + pub fn get_all_class_fqns(&self) -> Vec { + self.scopes + .values() + .filter_map(|s| { + if let ScopeKind::Class(fqn) = &s.kind { + Some(fqn.clone()) + } else { + None + } + }) + .collect() + } } diff --git a/crates/lang-java/src/inference/strategy/literal.rs b/crates/lang-java/src/inference/strategy/literal.rs new file mode 100644 index 0000000..1db09e4 --- /dev/null +++ b/crates/lang-java/src/inference/strategy/literal.rs @@ -0,0 +1,39 @@ +use super::InferStrategy; +use crate::inference::InferContext; +use naviscope_api::models::TypeRef; +use tree_sitter::Node; + +pub struct LiteralInfer; + +impl InferStrategy for LiteralInfer { + fn infer(&self, node: &Node, ctx: &InferContext) -> Option { + let kind = node.kind(); + + match kind { + "decimal_integer_literal" + | "hex_integer_literal" + | "octal_integer_literal" + | "binary_integer_literal" => { + if let Ok(text) = node.utf8_text(ctx.source.as_bytes()) { + if text.ends_with('L') || text.ends_with('l') { + return Some(TypeRef::Raw("long".to_string())); + } + } + Some(TypeRef::Raw("int".to_string())) + } + "decimal_floating_point_literal" | "hex_floating_point_literal" => { + if let Ok(text) = node.utf8_text(ctx.source.as_bytes()) { + if text.ends_with('f') || text.ends_with('F') { + return Some(TypeRef::Raw("float".to_string())); + } + } + Some(TypeRef::Raw("double".to_string())) + } + "true" | "false" => Some(TypeRef::Raw("boolean".to_string())), + "character_literal" => Some(TypeRef::Raw("char".to_string())), + "string_literal" => Some(TypeRef::Id("java.lang.String".to_string())), + "null_literal" => Some(TypeRef::Unknown), // Null is compatible with any ref type, handled by assignment check usually + _ => None, + } + } +} diff --git a/crates/lang-java/src/inference/strategy/method.rs b/crates/lang-java/src/inference/strategy/method.rs index 4c5d61c..dbacdf3 100644 --- a/crates/lang-java/src/inference/strategy/method.rs +++ b/crates/lang-java/src/inference/strategy/method.rs @@ -1,9 +1,9 @@ //! Method invocation inference. use super::{InferStrategy, infer_expression}; -use crate::inference::core::unification::Substitution; use crate::inference::InferContext; use crate::inference::TypeRefExt; +use crate::inference::core::unification::Substitution; use naviscope_api::models::TypeRef; use tree_sitter::Node; @@ -61,7 +61,12 @@ impl MethodCallInfer { }; // Get the FQN from the receiver type - let type_fqn = receiver_type.as_fqn()?; + let raw_fqn = receiver_type.as_fqn()?; + let resolution_ctx = ctx.to_resolution_context(); + let type_fqn = ctx + .ts + .resolve_type_name(&raw_fqn, &resolution_ctx) + .unwrap_or(raw_fqn); // Get argument types let mut arg_types = Vec::new(); diff --git a/crates/lang-java/src/inference/strategy/mod.rs b/crates/lang-java/src/inference/strategy/mod.rs index 7a7fe22..1f41699 100644 --- a/crates/lang-java/src/inference/strategy/mod.rs +++ b/crates/lang-java/src/inference/strategy/mod.rs @@ -7,6 +7,7 @@ mod combinator; mod field; mod lambda; +mod literal; mod method; mod new_expr; mod this; @@ -16,6 +17,7 @@ mod type_id; pub use combinator::{Cached, Map, OrElse}; pub use field::FieldAccessInfer; pub use lambda::LambdaInfer; +pub use literal::LiteralInfer; pub use type_id::TypeIdentifierInfer; pub mod local; pub use local::LocalVarInfer; @@ -82,6 +84,7 @@ pub trait InferStrategy: Sync + Send { /// This combines all strategies in priority order. pub fn build_expression_inferrer() -> impl InferStrategy { ThisInfer + .or_else(LiteralInfer) .or_else(LocalVarInfer) .or_else(FieldAccessInfer) .or_else(MethodCallInfer) diff --git a/crates/lang-java/src/lsp/references.rs b/crates/lang-java/src/lsp/references.rs index cf8b5ea..cdb0634 100644 --- a/crates/lang-java/src/lsp/references.rs +++ b/crates/lang-java/src/lsp/references.rs @@ -1,10 +1,10 @@ +use crate::inference::InferContext; use crate::inference::adapters::{CodeGraphTypeSystem, NoOpTypeSystem}; use crate::inference::create_inference_context; use crate::inference::scope::ScopeManager; -use crate::inference::InferContext; use crate::parser::JavaParser; -use naviscope_api::models::{SymbolIntent, SymbolResolution, TypeRef}; use naviscope_api::models::symbol::Range; +use naviscope_api::models::{SymbolIntent, SymbolResolution, TypeRef}; use naviscope_plugin::CodeGraph; use naviscope_plugin::utils::{line_col_at_to_offset, range_from_ts}; use tree_sitter::{Node, Tree}; @@ -33,7 +33,7 @@ pub fn find_occurrences( package, imports, ); - collect_occurrences_with_ctx(tree, source, target, &ctx, &mut ranges); + collect_occurrences_with_ctx(tree, source, target, &ctx, parser, &mut ranges); } else { let ts = NoOpTypeSystem; let mut scope_manager = ScopeManager::new(); @@ -45,7 +45,7 @@ pub fn find_occurrences( package, imports, ); - collect_occurrences_with_ctx(tree, source, target, &ctx, &mut ranges); + collect_occurrences_with_ctx(tree, source, target, &ctx, parser, &mut ranges); } ranges @@ -56,6 +56,7 @@ fn collect_occurrences_with_ctx( source: &str, target: &SymbolResolution, infer_ctx: &InferContext, + parser: &JavaParser, ranges: &mut Vec, ) { let Some(scope_manager) = infer_ctx.scope_manager else { @@ -82,18 +83,21 @@ fn collect_occurrences_with_ctx( } } SymbolResolution::Precise(fqn, _) | SymbolResolution::Global(fqn) => { - let name = fqn - .split(|c| c == '.' || c == '#' || c == '$') - .last() - .unwrap_or(fqn); + let member_target = fqn.contains('#'); + let type_target = matches!(target, SymbolResolution::Precise(_, SymbolIntent::Type)); + let name = if member_target { + // For signed member FQNs like `Owner#target(java.lang.String)`, + // never split on '.'; extract member part first. + let member = crate::naming::extract_member_name(fqn).unwrap_or(fqn); + crate::naming::extract_simple_name(member) + } else { + fqn.split(['.', '$']).last().unwrap_or(fqn) + }; if name.is_empty() { return; } - let member_target = fqn.contains('#'); - let type_target = matches!(target, SymbolResolution::Precise(_, SymbolIntent::Type)); - find_matching_identifiers( tree, source, @@ -106,7 +110,7 @@ fn collect_occurrences_with_ctx( } if member_target { - return resolve_member_reference(node, infer_ctx) + return resolve_member_reference(node, infer_ctx, parser) .map(|(resolved, static_site)| { member_fqn_matches_target(&resolved, fqn, static_site, infer_ctx) }) @@ -121,8 +125,8 @@ fn collect_occurrences_with_ctx( } } - // Name-only fallback for non-member symbols. - true + // Strict mode: No name-only fallback. + false }, ranges, ); @@ -149,13 +153,7 @@ fn find_matching_identifiers( ) where F: Fn(&Node) -> bool, { - visit_tree_recursive( - &tree.root_node(), - source, - target_name, - &predicate, - ranges, - ); + visit_tree_recursive(&tree.root_node(), source, target_name, &predicate, ranges); } fn visit_tree_recursive( @@ -179,13 +177,7 @@ fn visit_tree_recursive( let mut cursor = node.walk(); for child in node.children(&mut cursor) { - visit_tree_recursive( - &child, - source, - target_name, - predicate, - ranges, - ); + visit_tree_recursive(&child, source, target_name, predicate, ranges); } } @@ -200,42 +192,53 @@ fn find_start_scope_id(node: &Node, sm: &ScopeManager) -> Option { None } -fn resolve_member_reference(node: &Node, infer_ctx: &InferContext) -> Option<(String, bool)> { +fn resolve_member_reference( + node: &Node, + infer_ctx: &InferContext, + parser: &JavaParser, +) -> Option<(String, bool)> { if node.kind() != "identifier" { return None; } + // Determine the precise enclosing class scope for this node to handle implicit `this` calls + // and correctly resolve members in current class hierarchy. + let enclosing = find_enclosing_class_fqn(node, infer_ctx); + let mut local_ctx = infer_ctx.clone(); + if let Some(fqn) = enclosing { + local_ctx.enclosing_class = Some(fqn); + } + let ctx_ref = &local_ctx; + if let Some(parent) = node.parent() { if parent.kind() == "method_invocation" && parent.child_by_field_name("name") == Some(*node) { if let Some(resolved) = - crate::inference::strategy::MethodCallInfer.infer_member(&parent, infer_ctx) + crate::inference::strategy::MethodCallInfer.infer_member(&parent, ctx_ref) { - let static_site = is_static_member_access_site(&parent, infer_ctx); + let static_site = is_static_member_access_site(&parent, ctx_ref); return Some((resolved, static_site)); } - // Fallback for implicit `this` calls when enclosing_class is not pre-filled in ctx. - if parent.child_by_field_name("object").is_none() { - let member_name = node.utf8_text(infer_ctx.source.as_bytes()).ok()?; - let class_fqn = find_enclosing_class_fqn(node, infer_ctx)?; - return Some((crate::naming::build_member_fqn(&class_fqn, member_name), false)); - } - + // If implicit inference failed, we return None. return None; } if parent.kind() == "field_access" && parent.child_by_field_name("field") == Some(*node) { return crate::inference::strategy::FieldAccessInfer - .infer_member(&parent, infer_ctx) - .map(|fqn| (fqn, is_static_member_access_site(&parent, infer_ctx))); + .infer_member(&parent, ctx_ref) + .map(|fqn| (fqn, is_static_member_access_site(&parent, ctx_ref))); } } - resolve_member_declaration_fqn(node, infer_ctx).map(|fqn| (fqn, false)) + resolve_member_declaration_fqn(node, ctx_ref, parser).map(|fqn| (fqn, false)) } -fn resolve_member_declaration_fqn(node: &Node, infer_ctx: &InferContext) -> Option { +fn resolve_member_declaration_fqn( + node: &Node, + infer_ctx: &InferContext, + parser: &JavaParser, +) -> Option { let parent = node.parent()?; if parent.child_by_field_name("name") != Some(*node) { return None; @@ -246,7 +249,13 @@ fn resolve_member_declaration_fqn(node: &Node, infer_ctx: &InferContext) -> Opti match parent.kind() { "method_declaration" | "constructor_declaration" => { - Some(crate::naming::build_member_fqn(&class_fqn, member_name)) + let param_types: Vec = parser + .extract_method_parameters(parent, infer_ctx.source) + .into_iter() + .map(|p| p.type_ref) + .collect(); + let signed_name = crate::naming::build_java_method_name(member_name, ¶m_types); + Some(crate::naming::build_member_fqn(&class_fqn, &signed_name)) } "variable_declarator" => { if parent.parent().map(|p| p.kind()) == Some("field_declaration") { @@ -280,7 +289,21 @@ fn member_fqn_matches_target( return false; }; - if resolved_name != target_name { + // Compare member names: if both have signatures, compare exactly; + // otherwise fall back to simple-name comparison for graceful degradation. + let resolved_has_sig = resolved_name.contains('(') && resolved_name.ends_with(')'); + let target_has_sig = target_name.contains('(') && target_name.ends_with(')'); + + let names_match = if resolved_has_sig && target_has_sig { + // Both signed → require exact match (overload-safe) + resolved_name == target_name + } else { + // At least one unsigned → compare by simple name + crate::naming::extract_simple_name(resolved_name) + == crate::naming::extract_simple_name(target_name) + }; + + if !names_match { return false; } diff --git a/crates/lang-java/src/lsp/type_system.rs b/crates/lang-java/src/lsp/type_system.rs index 0ade5b4..39717e3 100644 --- a/crates/lang-java/src/lsp/type_system.rs +++ b/crates/lang-java/src/lsp/type_system.rs @@ -48,7 +48,16 @@ impl JavaTypeSystem { parse_member_fqn(candidate_fqn), parse_member_fqn(target_fqn), ) { - if c_member == t_member { + // Compare: if both are signed, require exact match; otherwise simple-name match + let c_has_sig = naviscope_plugin::naming::has_method_signature(c_member); + let t_has_sig = naviscope_plugin::naming::has_method_signature(t_member); + let names_match = if c_has_sig && t_has_sig { + c_member == t_member + } else { + naviscope_plugin::naming::extract_simple_name(c_member) + == naviscope_plugin::naming::extract_simple_name(t_member) + }; + if names_match { // Member names match, check if classes are related return self.is_subtype(graph, c_type, t_type) || self.is_subtype(graph, t_type, c_type); diff --git a/crates/lang-java/src/naming.rs b/crates/lang-java/src/naming.rs index e4dd895..fe13cbe 100644 --- a/crates/lang-java/src/naming.rs +++ b/crates/lang-java/src/naming.rs @@ -1,10 +1,281 @@ -// Re-export standard naming utilities from plugin +// Re-export cross-language naming utilities from plugin pub use naviscope_plugin::naming::{ - MEMBER_SEPARATOR, TYPE_SEPARATOR, build_member_fqn, extract_member_name, extract_type_fqn, - is_member_fqn, parse_member_fqn, + MEMBER_SEPARATOR, MethodSignature, TYPE_SEPARATOR, build_member_fqn, extract_member_name, + extract_simple_name, extract_type_fqn, format_method_name, has_method_signature, is_member_fqn, + parse_member_fqn, parse_method_signature, }; /// Java uses standard dot-separated paths for types and packages, /// and standard hash-separated paths for members in our graph. /// Thus we can alias directly to StandardNamingConvention. pub use naviscope_plugin::StandardNamingConvention as JavaNamingConvention; + +// --------------------------------------------------------------------------- +// Java-specific type normalization for method signatures +// +// Java uses type erasure for generics at the bytecode level, so the canonical +// method signature erases generic type arguments. These rules are Java/JVM +// specific and do NOT belong in the cross-language plugin crate. +// --------------------------------------------------------------------------- + +use naviscope_api::models::TypeRef; + +/// Normalize a `TypeRef` into its canonical signature string using Java rules. +/// +/// Java-specific rules: +/// - Primitives / raw unresolved types: as-is (`int`, `boolean`, `void`) +/// - Resolved reference types: full FQN (`java.lang.String`) +/// - Arrays: element type + `[]` per dimension (`java.lang.String[]`) +/// - Generics: **erased** to base type (`java.util.List` → `java.util.List`) +/// - Varargs: caller should convert to array **before** calling this function +/// (use [`varargs_to_array_type`]) +/// - Wildcards / Unknown: `?` +pub fn normalize_type_for_signature(type_ref: &TypeRef) -> String { + match type_ref { + TypeRef::Raw(s) => s.clone(), + TypeRef::Id(fqn) => fqn.clone(), + TypeRef::Array { + element, + dimensions, + } => { + let base = normalize_type_for_signature(element); + let brackets: String = "[]".repeat(*dimensions); + format!("{}{}", base, brackets) + } + TypeRef::Generic { base, .. } => { + // Java type erasure: discard generic type arguments + normalize_type_for_signature(base) + } + TypeRef::Wildcard { .. } => "?".to_string(), + TypeRef::Unknown => "?".to_string(), + } +} + +/// Convert a varargs parameter type to its array equivalent for signature purposes. +/// +/// Java varargs `String...` should be represented as `String[]` in the method signature. +/// Call this before passing the type to signature construction. +/// +/// If the type is already an array, an additional dimension is **not** added — +/// the parser is assumed to have already handled the varargs→array conversion. +pub fn varargs_to_array_type(type_ref: &TypeRef) -> TypeRef { + match type_ref { + TypeRef::Array { .. } => type_ref.clone(), + other => TypeRef::Array { + element: Box::new(other.clone()), + dimensions: 1, + }, + } +} + +/// Build a Java method member name with its parameter signature. +/// +/// This combines Java-specific type normalization with the cross-language +/// `format_method_name`. The result can be passed to `build_member_fqn`. +/// +/// # Examples +/// +/// ```ignore +/// let signed = build_java_method_name("target", &[TypeRef::raw("int")]); +/// // signed == "target(int)" +/// let fqn = build_member_fqn("com.example.A", &signed); +/// // fqn == "com.example.A#target(int)" +/// ``` +pub fn build_java_method_name(name: &str, param_types: &[TypeRef]) -> String { + let normalized: Vec = param_types + .iter() + .map(normalize_type_for_signature) + .collect(); + let refs: Vec<&str> = normalized.iter().map(|s| s.as_str()).collect(); + format_method_name(name, &refs) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // -- normalize_type_for_signature -- + + #[test] + fn normalize_primitive() { + assert_eq!(normalize_type_for_signature(&TypeRef::raw("int")), "int"); + assert_eq!( + normalize_type_for_signature(&TypeRef::raw("boolean")), + "boolean" + ); + assert_eq!(normalize_type_for_signature(&TypeRef::raw("void")), "void"); + } + + #[test] + fn normalize_resolved_reference() { + assert_eq!( + normalize_type_for_signature(&TypeRef::id("java.lang.String")), + "java.lang.String" + ); + } + + #[test] + fn normalize_array_single_dimension() { + let arr = TypeRef::Array { + element: Box::new(TypeRef::id("java.lang.String")), + dimensions: 1, + }; + assert_eq!(normalize_type_for_signature(&arr), "java.lang.String[]"); + } + + #[test] + fn normalize_array_multi_dimension() { + let arr = TypeRef::Array { + element: Box::new(TypeRef::raw("int")), + dimensions: 2, + }; + assert_eq!(normalize_type_for_signature(&arr), "int[][]"); + } + + #[test] + fn normalize_generic_erases_type_args() { + let generic = TypeRef::Generic { + base: Box::new(TypeRef::id("java.util.List")), + args: vec![TypeRef::id("java.lang.String")], + }; + assert_eq!(normalize_type_for_signature(&generic), "java.util.List"); + } + + #[test] + fn normalize_nested_generic_erases_all() { + let generic = TypeRef::Generic { + base: Box::new(TypeRef::id("java.util.Map")), + args: vec![ + TypeRef::id("java.lang.String"), + TypeRef::Generic { + base: Box::new(TypeRef::id("java.util.List")), + args: vec![TypeRef::id("java.lang.Integer")], + }, + ], + }; + assert_eq!(normalize_type_for_signature(&generic), "java.util.Map"); + } + + #[test] + fn normalize_generic_array() { + let arr = TypeRef::Array { + element: Box::new(TypeRef::Generic { + base: Box::new(TypeRef::id("java.util.List")), + args: vec![TypeRef::id("java.lang.String")], + }), + dimensions: 1, + }; + assert_eq!(normalize_type_for_signature(&arr), "java.util.List[]"); + } + + #[test] + fn normalize_wildcard_and_unknown() { + assert_eq!( + normalize_type_for_signature(&TypeRef::Wildcard { + bound: None, + is_upper_bound: true + }), + "?" + ); + assert_eq!(normalize_type_for_signature(&TypeRef::Unknown), "?"); + } + + // -- varargs_to_array_type -- + + #[test] + fn varargs_converts_non_array_to_array() { + let input = TypeRef::id("java.lang.String"); + let result = varargs_to_array_type(&input); + assert_eq!( + result, + TypeRef::Array { + element: Box::new(TypeRef::id("java.lang.String")), + dimensions: 1 + } + ); + } + + #[test] + fn varargs_preserves_existing_array() { + let input = TypeRef::Array { + element: Box::new(TypeRef::raw("int")), + dimensions: 1, + }; + let result = varargs_to_array_type(&input); + assert_eq!(result, input); + } + + // -- build_java_method_name -- + + #[test] + fn build_no_params() { + let signed = build_java_method_name("target", &[]); + assert_eq!(signed, "target()"); + } + + #[test] + fn build_single_primitive_param() { + let signed = build_java_method_name("target", &[TypeRef::raw("int")]); + assert_eq!(signed, "target(int)"); + } + + #[test] + fn build_multiple_params() { + let signed = build_java_method_name("target", &[TypeRef::raw("int"), TypeRef::raw("int")]); + assert_eq!(signed, "target(int,int)"); + } + + #[test] + fn build_generic_param_erased() { + let signed = build_java_method_name( + "add", + &[TypeRef::Generic { + base: Box::new(TypeRef::id("java.util.List")), + args: vec![TypeRef::id("java.lang.String")], + }], + ); + assert_eq!(signed, "add(java.util.List)"); + } + + #[test] + fn build_constructor() { + let signed = build_java_method_name("", &[TypeRef::raw("int")]); + assert_eq!(signed, "(int)"); + } + + // -- End-to-end: build_java_method_name → build_member_fqn → parse_method_signature -- + + #[test] + fn roundtrip_java_method_fqn() { + let signed = build_java_method_name( + "target", + &[TypeRef::raw("int"), TypeRef::id("java.lang.String")], + ); + let fqn = build_member_fqn("com.example.A", &signed); + assert_eq!(fqn, "com.example.A#target(int,java.lang.String)"); + + let sig = parse_method_signature(&fqn).unwrap(); + assert_eq!(sig.owner, "com.example.A"); + assert_eq!(sig.name, "target"); + assert_eq!(sig.params, "int,java.lang.String"); + } + + #[test] + fn two_java_overloads_produce_different_fqns() { + let fqn1 = build_member_fqn( + "com.example.A", + &build_java_method_name("target", &[TypeRef::raw("int")]), + ); + let fqn2 = build_member_fqn( + "com.example.A", + &build_java_method_name("target", &[TypeRef::raw("int"), TypeRef::raw("int")]), + ); + assert_ne!(fqn1, fqn2); + assert_eq!(fqn1, "com.example.A#target(int)"); + assert_eq!(fqn2, "com.example.A#target(int,int)"); + } +} diff --git a/crates/lang-java/src/parser/ast/entities.rs b/crates/lang-java/src/parser/ast/entities.rs index 7a41c31..4e4ebea 100644 --- a/crates/lang-java/src/parser/ast/entities.rs +++ b/crates/lang-java/src/parser/ast/entities.rs @@ -159,6 +159,17 @@ impl JavaParser { }, KIND_LABEL_ANNOTATION => JavaIndexMetadata::Annotation { modifiers: vec![] }, KIND_LABEL_METHOD | KIND_LABEL_CONSTRUCTOR => { + let def_idx = if kind == KIND_LABEL_METHOD { + self.indices.method_def + } else { + self.indices.constr_def + }; + let anchor_node = captures + .iter() + .find(|c| c.index == def_idx) + .map(|c| c.node) + .expect("Method definition node must exist"); + let mut return_type = TypeRef::raw("void"); if let Some(ret_node) = captures .iter() @@ -170,7 +181,7 @@ impl JavaParser { } JavaIndexMetadata::Method { return_type, - parameters: vec![], + parameters: self.extract_method_parameters(anchor_node, source), modifiers: vec![], is_constructor: kind == KIND_LABEL_CONSTRUCTOR, } diff --git a/crates/lang-java/src/parser/naming.rs b/crates/lang-java/src/parser/naming.rs index 68782e0..2489d76 100644 --- a/crates/lang-java/src/parser/naming.rs +++ b/crates/lang-java/src/parser/naming.rs @@ -101,7 +101,29 @@ impl JavaParser { } _ => kind, }; - parts.push((id_kind, self_name)); + + // For methods and constructors, produce a signature-based name like + // `target(int,java.lang.String)` so that overloaded methods get + // distinct FQN IDs in the graph. + let id_name = match id_kind { + naviscope_api::models::graph::NodeKind::Method + | naviscope_api::models::graph::NodeKind::Constructor => { + if let Some(decl_node) = name_node.parent() { + let param_types = self + .extract_method_parameters(decl_node, source) + .into_iter() + .map(|p| p.type_ref) + .collect::>(); + crate::naming::build_java_method_name(&self_name, ¶m_types) + } else { + // Fallback: no parent available, use bare name with empty params + crate::naming::format_method_name(&self_name, &[]) + } + } + _ => self_name, + }; + + parts.push((id_kind, id_name)); naviscope_api::models::symbol::NodeId::Structured(parts) } diff --git a/crates/lang-java/src/parser/types.rs b/crates/lang-java/src/parser/types.rs index ca3c29b..574de3c 100644 --- a/crates/lang-java/src/parser/types.rs +++ b/crates/lang-java/src/parser/types.rs @@ -83,17 +83,131 @@ impl JavaParser { is_upper_bound: is_upper, } } + "integral_type" + | "floating_point_type" + | "boolean_type" + | "void_type" + | "_unannotated_type" => { + let text = node + .utf8_text(source.as_bytes()) + .unwrap_or_default() + .to_string(); + if text.is_empty() { + TypeRef::Unknown + } else { + TypeRef::Raw(text) + } + } + "type_identifier" | "scoped_type_identifier" => { + let text = node + .utf8_text(source.as_bytes()) + .unwrap_or_default() + .to_string(); + if text.is_empty() { + TypeRef::Unknown + } else { + // At parser stage these identifiers are syntactic type names and may not + // be fully resolved; keep them as Raw and normalize later in semantic phase. + TypeRef::Raw(text) + } + } _ => { let text = node .utf8_text(source.as_bytes()) .unwrap_or_default() .to_string(); + // Default fallback if text.is_empty() { TypeRef::Unknown } else { + // Start uppercase -> Id, lowercase -> Raw? Or just Raw (safer for primitives missed) + // If it ends with 'type', it's likely primitive node we missed? + // Safe logic: If primitive keyword -> Raw. Else Id. + // But we can just use Raw for now as fallback. TypeRef::Raw(text) } } } } + + /// Extract parameter types from a method/constructor declaration node. + /// + /// This reads the `formal_parameters` child directly from the AST so that + /// we can build signature-based FQNs at ID-generation time, **before** the + /// enrichment stage populates `JavaIndexMetadata.parameters`. + /// + /// For varargs (`spread_parameter`), the type is wrapped in `TypeRef::Array` + /// to match Java's bytecode representation. + pub fn extract_param_types_from_declaration( + &self, + declaration_node: Node, + source: &str, + ) -> Vec { + self.extract_method_parameters(declaration_node, source) + .into_iter() + .map(|p| p.type_ref) + .collect() + } + + /// Extracts full parameter metadata for parsing. + pub fn extract_method_parameters( + &self, + declaration_node: tree_sitter::Node, + source: &str, + ) -> Vec { + let Some(params_node) = declaration_node.child_by_field_name("parameters") else { + return vec![]; + }; + + let mut result = Vec::new(); + let mut cursor = params_node.walk(); + for child in params_node.children(&mut cursor) { + match child.kind() { + "formal_parameter" => { + if let Some(type_node) = child.child_by_field_name("type") { + let type_ref = self.parse_type_node(type_node, source); + let name_node = child.child_by_field_name("name"); + let name = name_node + .and_then(|n| n.utf8_text(source.as_bytes()).ok()) + .unwrap_or("arg") + .to_string(); + + result.push(crate::model::JavaParameter { + name, + type_ref, + is_varargs: false, + }); + } + } + "spread_parameter" => { + let mut type_ref = naviscope_api::models::TypeRef::Unknown; + let mut name = "arg".to_string(); + + // Find type (first named node that is not variable_declarator) + // Find name (in variable_declarator) + let mut inner_cursor = child.walk(); + for gc in child.children(&mut inner_cursor) { + if gc.kind() == "variable_declarator" { + if let Some(n) = gc.child_by_field_name("name") { + if let Ok(text) = n.utf8_text(source.as_bytes()) { + name = text.to_string(); + } + } + } else if gc.kind() != "..." && gc.is_named() { + let base = self.parse_type_node(gc, source); + type_ref = crate::naming::varargs_to_array_type(&base); + } + } + + result.push(crate::model::JavaParameter { + name, + type_ref, + is_varargs: true, + }); + } + _ => {} + } + } + result + } } diff --git a/crates/lang-java/src/resolve/mod.rs b/crates/lang-java/src/resolve/mod.rs index e89f5f7..b3bbe6b 100644 --- a/crates/lang-java/src/resolve/mod.rs +++ b/crates/lang-java/src/resolve/mod.rs @@ -71,10 +71,18 @@ impl JavaPlugin { if parent.child_by_field_name("name") == Some(context.node) { match parent.kind() { "method_declaration" | "constructor_declaration" => { - // Build method FQN using canonical member separator + // Build signature-based method FQN if let Some(ref enclosing) = infer_ctx.enclosing_class { + let param_types: Vec = self + .parser + .extract_method_parameters(parent, context.source) + .into_iter() + .map(|p| p.type_ref) + .collect(); + let signed_name = + crate::naming::build_java_method_name(&context.name, ¶m_types); let method_fqn = - crate::naming::build_member_fqn(enclosing, &context.name); + crate::naming::build_member_fqn(enclosing, &signed_name); return Some(SymbolResolution::Precise( method_fqn, naviscope_api::models::SymbolIntent::Method, diff --git a/crates/lang-java/tests/call_hierarchy_behavior.rs b/crates/lang-java/tests/call_hierarchy_behavior.rs index a5dde51..dcb5e93 100644 --- a/crates/lang-java/tests/call_hierarchy_behavior.rs +++ b/crates/lang-java/tests/call_hierarchy_behavior.rs @@ -1,9 +1,9 @@ mod common; use common::{offset_to_point, setup_java_test_graph}; +use naviscope_api::models::SymbolResolution; use naviscope_core::features::CodeGraphLike; use naviscope_core::features::discovery::DiscoveryEngine; -use naviscope_api::models::SymbolResolution; use naviscope_java::JavaPlugin; use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; @@ -65,7 +65,7 @@ fn given_leaf_method_when_find_incoming_callers_then_returns_direct_callers_only } callers.sort(); - assert_eq!(callers, vec!["Test#c1", "Test#c2"]); + assert_eq!(callers, vec!["Test#c1()", "Test#c2()"]); } #[test] @@ -87,8 +87,13 @@ fn given_root_method_when_find_outgoing_calls_then_returns_direct_callees() { .expect("resolve root"); let target_fqn = resolver.find_matches(&index, &resolution)[0]; - let target_idx = *index.fqn_map().get(&target_fqn).expect("target node exists"); - let container_range = index.topology()[target_idx].range().expect("container range"); + let target_idx = *index + .fqn_map() + .get(&target_fqn) + .expect("target node exists"); + let container_range = index.topology()[target_idx] + .range() + .expect("container range"); let mut callees = Vec::new(); let mut stack = vec![tree.root_node()]; @@ -117,7 +122,7 @@ fn given_root_method_when_find_outgoing_calls_then_returns_direct_callees() { if let Some(fqn) = maybe_fqn && fqn.contains('#') - && fqn != "Test#root" + && fqn != "Test#root()" && !callees.contains(&fqn) { callees.push(fqn); @@ -131,7 +136,7 @@ fn given_root_method_when_find_outgoing_calls_then_returns_direct_callees() { } callees.sort(); - assert_eq!(callees, vec!["Test#step1", "Test#step2"]); + assert_eq!(callees, vec!["Test#step1()", "Test#step2()"]); } #[test] @@ -150,7 +155,10 @@ fn given_recursive_method_when_find_incoming_callers_then_includes_self_call() { .expect("resolve rec"); let target_fqn = resolver.find_matches(&index, &resolution)[0]; - let target_idx = *index.fqn_map().get(&target_fqn).expect("target node exists"); + let target_idx = *index + .fqn_map() + .get(&target_fqn) + .expect("target node exists"); let discovery = DiscoveryEngine::new(&index, std::collections::HashMap::new()); let abs_path = std::env::current_dir().expect("cwd").join("Test.java"); @@ -188,5 +196,5 @@ fn given_recursive_method_when_find_incoming_callers_then_includes_self_call() { } } - assert!(callers.contains(&"Test#rec".to_string())); + assert!(callers.contains(&"Test#rec()".to_string())); } diff --git a/crates/lang-java/tests/capability_boundary.rs b/crates/lang-java/tests/capability_boundary.rs index 913607a..85781cf 100644 --- a/crates/lang-java/tests/capability_boundary.rs +++ b/crates/lang-java/tests/capability_boundary.rs @@ -29,7 +29,7 @@ fn cap_structural_nesting() { assert!(index.find_node("com.example").is_some()); assert!(index.find_node("com.example.MyClass").is_some()); assert!(index.find_node("com.example.MyClass#field").is_some()); - assert!(index.find_node("com.example.MyClass#method").is_some()); + assert!(index.find_node("com.example.MyClass#method()").is_some()); // Assert nesting via 'Contains' edges let class_idx = index.find_node("com.example.MyClass").unwrap(); @@ -38,7 +38,7 @@ fn cap_structural_nesting() { assert!(index.topology().contains_edge(pkg_idx, class_idx)); let field_idx = index.find_node("com.example.MyClass#field").unwrap(); - let method_idx = index.find_node("com.example.MyClass#method").unwrap(); + let method_idx = index.find_node("com.example.MyClass#method()").unwrap(); assert!(index.topology().contains_edge(class_idx, field_idx)); assert!(index.topology().contains_edge(class_idx, method_idx)); } @@ -148,7 +148,7 @@ fn cap_method_call_tracking() { ]; let (index, _) = setup_java_test_graph(files); - let a_target_idx = index.find_node("A#target").unwrap(); + let a_target_idx = index.find_node("A#target()").unwrap(); // Check DiscoveryEngine "Scouting" (uses Reference Index) let discovery = DiscoveryEngine::new(&index, std::collections::HashMap::new()); diff --git a/crates/lang-java/tests/edge_verification.rs b/crates/lang-java/tests/edge_verification.rs index c0ad11b..139a03f 100644 --- a/crates/lang-java/tests/edge_verification.rs +++ b/crates/lang-java/tests/edge_verification.rs @@ -110,7 +110,7 @@ fn test_edge_contains() { assert_edge( &index, "com.test.Container", - "com.test.Container#method", + "com.test.Container#method()", EdgeType::Contains, ); } @@ -155,7 +155,7 @@ fn test_edge_calls() { let (index, _) = setup_java_test_graph(files); // Using FQN in call to ensure resolution works in batch mode - assert_reference_scouted(&index, "com.test.Service#helper", "src/Service.java"); + assert_reference_scouted(&index, "com.test.Service#helper()", "src/Service.java"); } #[test] diff --git a/crates/lang-java/tests/goto_definition_behavior.rs b/crates/lang-java/tests/goto_definition_behavior.rs index 0b7f37f..8c23fad 100644 --- a/crates/lang-java/tests/goto_definition_behavior.rs +++ b/crates/lang-java/tests/goto_definition_behavior.rs @@ -1,8 +1,8 @@ mod common; use common::{offset_to_point, setup_java_test_graph}; -use naviscope_core::features::CodeGraphLike; use naviscope_api::models::SymbolResolution; +use naviscope_core::features::CodeGraphLike; use naviscope_java::JavaPlugin; use naviscope_plugin::{SymbolQueryService, SymbolResolveService}; @@ -66,7 +66,7 @@ fn given_cross_file_call_when_goto_definition_then_resolves_precise_method_owner &index.topology()[idx], Some(&naviscope_java::naming::JavaNamingConvention::default()) ), - "com.A#hello" + "com.A#hello()" ); } @@ -163,7 +163,10 @@ fn given_static_field_access_when_goto_definition_then_resolves_declaring_field( #[test] fn given_overloaded_method_call_chain_when_goto_definition_then_uses_most_specific_overload() { let files = vec![ - ("BaseResult.java", "public class BaseResult { void base() {} }"), + ( + "BaseResult.java", + "public class BaseResult { void base() {} }", + ), ( "SpecialResult.java", "public class SpecialResult extends BaseResult { void special() {} }", @@ -201,7 +204,7 @@ fn given_overloaded_method_call_chain_when_goto_definition_then_uses_most_specif &index.topology()[idx], Some(&naviscope_java::naming::JavaNamingConvention::default()) ), - "SpecialResult#special" + "SpecialResult#special()" ); } @@ -209,10 +212,7 @@ fn given_overloaded_method_call_chain_when_goto_definition_then_uses_most_specif fn given_overloaded_constructor_call_when_goto_definition_then_resolves_constructor_type_symbol() { let files = vec![ ("SpecialArg.java", "public class SpecialArg {}"), - ( - "A.java", - "public class A { A() {} A(SpecialArg arg) {} }", - ), + ("A.java", "public class A { A() {} A(SpecialArg arg) {} }"), ( "Use.java", "public class Use { A build() { return new A(new SpecialArg()); } }", @@ -275,6 +275,6 @@ fn given_same_class_different_arity_overloads_when_goto_definition_then_resolves &index.topology()[idx], Some(&naviscope_java::naming::JavaNamingConvention::default()) ), - "A#target" + "A#target(int,int)" ); } diff --git a/crates/lang-java/tests/implementation_behavior.rs b/crates/lang-java/tests/implementation_behavior.rs index 9fd54b3..2ed81c0 100644 --- a/crates/lang-java/tests/implementation_behavior.rs +++ b/crates/lang-java/tests/implementation_behavior.rs @@ -80,6 +80,6 @@ fn given_interface_method_when_goto_implementation_then_returns_method_override( &index.topology()[idx], Some(&naviscope_java::naming::JavaNamingConvention) ), - "Impl#act" + "Impl#act()" ); } diff --git a/crates/lang-java/tests/inference_mock_test.rs b/crates/lang-java/tests/inference_mock_test.rs index f20cf41..c031af4 100644 --- a/crates/lang-java/tests/inference_mock_test.rs +++ b/crates/lang-java/tests/inference_mock_test.rs @@ -4,14 +4,14 @@ use naviscope_api::models::TypeRef; use std::collections::HashMap; -use naviscope_java::inference::{create_inference_context, infer_expression}; use naviscope_java::inference::JavaTypeSystem; +use naviscope_java::inference::core::types::TypeParameter; +use naviscope_java::inference::scope::ScopeManager; use naviscope_java::inference::{InheritanceProvider, MemberProvider, TypeProvider}; use naviscope_java::inference::{ MemberInfo, MemberKind, TypeInfo, TypeKind, TypeResolutionContext, }; -use naviscope_java::inference::core::types::TypeParameter; -use naviscope_java::inference::scope::ScopeManager; +use naviscope_java::inference::{create_inference_context, infer_expression}; use naviscope_java::parser::JavaParser; /// A mock type system for testing. @@ -99,11 +99,7 @@ impl MockTypeSystem { } /// Add an interface with generic type parameters. - pub fn add_interface_with_type_params( - mut self, - fqn: &str, - type_parameters: Vec<&str>, - ) -> Self { + pub fn add_interface_with_type_params(mut self, fqn: &str, type_parameters: Vec<&str>) -> Self { self.types.insert( fqn.to_string(), TypeInfo { @@ -163,6 +159,43 @@ impl MockTypeSystem { self } + /// Add a method with parameters to a class. + pub fn add_method_with_params( + mut self, + class_fqn: &str, + name: &str, + return_type: TypeRef, + param_types: Vec, + ) -> Self { + let parameters: Vec<_> = param_types + .into_iter() + .enumerate() + .map(|(i, t)| naviscope_java::inference::ParameterInfo { + name: format!("arg{}", i), + type_ref: t, + is_varargs: false, + }) + .collect(); + + let member = MemberInfo { + name: name.to_string(), + fqn: format!("{}#{}", class_fqn, name), + kind: MemberKind::Method, + declaring_type: class_fqn.to_string(), + type_ref: return_type, + parameters: Some(parameters), + modifiers: vec![], + generic_signature: None, + }; + + self.members + .entry(class_fqn.to_string()) + .or_default() + .push(member); + + self + } + /// Add a field to a class. pub fn add_field(mut self, class_fqn: &str, name: &str, field_type: TypeRef) -> Self { let member = MemberInfo { @@ -665,7 +698,12 @@ class Demo { .add_class("java.lang.String", None) .add_class("java.lang.Object", None) .add_interface_with_type_params("java.util.List", vec!["E"]) - .add_method("java.util.List", "get", TypeRef::Id("E".into())); + .add_method_with_params( + "java.util.List", + "get", + TypeRef::Id("E".into()), + vec![TypeRef::Raw("int".into())], + ); let mut scope_manager = ScopeManager::new(); let ctx = create_inference_context( @@ -703,7 +741,12 @@ class Demo { .add_class("java.lang.String", None) .add_class("java.lang.Integer", None) .add_interface_with_type_params("java.util.Map", vec!["K", "V"]) - .add_method("java.util.Map", "get", TypeRef::Id("V".into())); + .add_method_with_params( + "java.util.Map", + "get", + TypeRef::Id("V".into()), + vec![TypeRef::Id("java.lang.Object".into())], + ); let mut scope_manager = ScopeManager::new(); let ctx = create_inference_context( @@ -781,7 +824,12 @@ class Demo { .add_class("java.lang.Integer", None) .add_interface_with_type_params("java.util.List", vec!["E"]) .add_interface_with_type_params("java.util.Map", vec!["K", "V"]) - .add_method("java.util.Map", "get", TypeRef::Id("V".into())); + .add_method_with_params( + "java.util.Map", + "get", + TypeRef::Id("V".into()), + vec![TypeRef::Id("java.lang.Object".into())], + ); let mut scope_manager = ScopeManager::new(); let ctx = create_inference_context( diff --git a/crates/lang-java/tests/java_integration.rs b/crates/lang-java/tests/java_integration.rs index 7378a4e..3f8281c 100644 --- a/crates/lang-java/tests/java_integration.rs +++ b/crates/lang-java/tests/java_integration.rs @@ -50,7 +50,7 @@ fn test_cross_file_resolution() { let res = resolver.resolve_at(b_tree, b_content, 0, hello_pos, &index); assert!(res.is_some(), "Failed to resolve 'hello' at {}", hello_pos); if let Some(naviscope_api::models::SymbolResolution::Precise(fqn, _)) = res { - assert_eq!(fqn, "com.example.A#hello"); + assert_eq!(fqn, "com.example.A#hello()"); } else { panic!( "Expected precise resolution to com.example.A.hello, got {:?}", @@ -168,7 +168,7 @@ fn test_chained_calls_resolution() { let res = resolver.resolve_at(main_tree, main_content, 0, get_c_pos, &index); assert!(res.is_some(), "Failed to resolve 'getC' at {}", get_c_pos); if let Some(naviscope_api::models::SymbolResolution::Precise(fqn, _)) = res { - assert_eq!(fqn, "com.chain.B#getC"); + assert_eq!(fqn, "com.chain.B#getC()"); } else { panic!( "Expected precise resolution to com.chain.B.getC, got {:?}", @@ -187,7 +187,7 @@ fn test_chained_calls_resolution() { execute_pos ); if let Some(naviscope_api::models::SymbolResolution::Precise(fqn, _)) = res { - assert_eq!(fqn, "com.chain.C#execute"); + assert_eq!(fqn, "com.chain.C#execute()"); } else { panic!( "Expected precise resolution to com.chain.C.execute, got {:?}", @@ -237,7 +237,7 @@ public class Main { ); if let Some(naviscope_api::models::SymbolResolution::Precise(fqn, _)) = res { - assert_eq!(fqn, "com.web.HttpResponse#getContext"); + assert_eq!(fqn, "com.web.HttpResponse#getContext()"); } else { panic!( "Expected precise resolution to com.web.HttpResponse#getContext, got {:?}", @@ -309,7 +309,7 @@ fn test_lambda_explicit_type_resolution() { hello_pos ); if let Some(naviscope_api::models::SymbolResolution::Precise(fqn, _)) = res { - assert_eq!(fqn, "com.A#hello"); + assert_eq!(fqn, "com.A#hello()"); } else { panic!("Expected precise resolution for it.hello(), got {:?}", res); } @@ -344,7 +344,7 @@ fn test_lambda_heuristic_type_inference() { hello_pos ); if let Some(naviscope_api::models::SymbolResolution::Precise(fqn, _)) = res { - assert_eq!(fqn, "com.A#hello"); + assert_eq!(fqn, "com.A#hello()"); } else { panic!( "Expected precise resolution for it.hello() via heuristic, got {:?}", @@ -468,7 +468,7 @@ public class DefaultApplicationArguments { if let Some(naviscope_api::models::SymbolResolution::Precise(fqn, _)) = res { assert_eq!( fqn, - "org.springframework.boot.DefaultApplicationArguments.Source#getNonOptionArgs" + "org.springframework.boot.DefaultApplicationArguments.Source#getNonOptionArgs()" ); } else { println!("Graph nodes:"); @@ -515,7 +515,7 @@ fn test_field_method_call_resolution() { col ); if let Some(naviscope_api::models::SymbolResolution::Precise(fqn, _)) = res { - assert_eq!(fqn, "B#doB"); + assert_eq!(fqn, "B#doB()"); } else { panic!("Expected precise resolution to B.doB, got {:?}", res); } diff --git a/crates/lang-java/tests/references_behavior.rs b/crates/lang-java/tests/references_behavior.rs index fe5bba3..45604fa 100644 --- a/crates/lang-java/tests/references_behavior.rs +++ b/crates/lang-java/tests/references_behavior.rs @@ -60,7 +60,10 @@ fn given_same_method_name_different_owner_when_find_references_then_only_target_ let use_call_start = offset_to_point(use_content, use_call_pos); assert_eq!(starts_set(&a_ranges), BTreeSet::from([a_decl_start])); - assert!(b_ranges.is_empty(), "B.java should not be polluted by same-name method"); + assert!( + b_ranges.is_empty(), + "B.java should not be polluted by same-name method" + ); assert_eq!(starts_set(&use_ranges), BTreeSet::from([use_call_start])); } @@ -91,7 +94,10 @@ fn given_local_shadowing_when_find_references_then_only_same_binding_hits() { offset_to_point(content, decl_pos), offset_to_point(content, assign_pos), ]); - assert_eq!(starts, expected, "local x should only match declaration + assignment"); + assert_eq!( + starts, expected, + "local x should only match declaration + assignment" + ); let matches = resolver.find_matches(&index, &resolution); assert!( @@ -138,7 +144,7 @@ fn given_method_symbol_when_scouting_references_then_candidate_files_cover_call_ } #[test] -fn given_overloaded_methods_same_owner_when_find_references_then_matches_all_name_level_overloads() { +fn given_overloaded_methods_same_owner_when_find_references_then_matches_only_exact_overload() { let files = vec![ ( "A.java", @@ -155,7 +161,10 @@ fn given_overloaded_methods_same_owner_when_find_references_then_matches_all_nam let a_content = &trees[0].1; let a_tree = &trees[0].2; - let target_pos = a_content.find("target(int").expect("find overloaded declaration"); + // Find target(int) declaration + let target_pos = a_content + .find("target(int") + .expect("find overloaded declaration"); let (line, col) = offset_to_point(a_content, target_pos); let resolution = resolver @@ -166,31 +175,74 @@ fn given_overloaded_methods_same_owner_when_find_references_then_matches_all_nam let use_ranges = find_ranges_for_path(&resolver, &index, &trees, &resolution, "Use.java"); let a_decl_int = a_content.find("target(int n)").expect("find target(int)"); - let a_decl_str = a_content - .find("target(String s)") - .expect("find target(String)"); + // We expect strict matching, so target(String) should NOT be included. + let use_content = &trees[1].1; let use_call_int = use_content.find("a.target(1)").expect("find call int") + 2; - let use_call_str = use_content.find("a.target(\"x\")").expect("find call str") + 2; + // target("x") refers to target(String), so it should NOT be included. assert_eq!( starts_set(&a_ranges), - BTreeSet::from([ - offset_to_point(a_content, a_decl_int), - offset_to_point(a_content, a_decl_str), - ]) + BTreeSet::from([offset_to_point(a_content, a_decl_int),]), + "Should only match the exact overload declaration in A.java" ); assert_eq!( starts_set(&use_ranges), - BTreeSet::from([ - offset_to_point(use_content, use_call_int), - offset_to_point(use_content, use_call_str), - ]) + BTreeSet::from([offset_to_point(use_content, use_call_int),]), + "Should only match the exact overload usage in Use.java" ); } #[test] -fn given_same_class_different_arity_overloads_when_find_references_then_collects_decls_and_calls() { +fn given_signed_method_with_fqn_param_when_find_references_then_matches_exact_overload() { + let files = vec![ + ( + "A.java", + "public class A { void target(java.lang.String s) {} void target(int n) {} }", + ), + ( + "Use.java", + "public class Use { void run(A a) { a.target(\"x\"); a.target(1); } }", + ), + ]; + + let (index, trees) = setup_java_test_graph(files); + let resolver = JavaPlugin::new().expect("Failed to create JavaPlugin"); + + let a_content = &trees[0].1; + let a_tree = &trees[0].2; + let target_pos = a_content + .find("target(java.lang.String") + .expect("find target(String) declaration"); + let (line, col) = offset_to_point(a_content, target_pos); + + let resolution = resolver + .resolve_at(a_tree, a_content, line, col, &index) + .expect("resolve target(java.lang.String)"); + + let a_ranges = find_ranges_for_path(&resolver, &index, &trees, &resolution, "A.java"); + let use_ranges = find_ranges_for_path(&resolver, &index, &trees, &resolution, "Use.java"); + + let a_decl_string = a_content + .find("target(java.lang.String s)") + .expect("find target(java.lang.String)"); + let use_content = &trees[1].1; + let use_call_string = use_content.find("a.target(\"x\")").expect("find call string") + 2; + + assert_eq!( + starts_set(&a_ranges), + BTreeSet::from([offset_to_point(a_content, a_decl_string)]), + "Should match only target(java.lang.String) declaration" + ); + assert_eq!( + starts_set(&use_ranges), + BTreeSet::from([offset_to_point(use_content, use_call_string)]), + "Should match only target(\"x\") call site" + ); +} + +#[test] +fn given_same_class_different_arity_overloads_when_find_references_then_matches_exact_arity() { let files = vec![( "A.java", "public class A { void target() { target(1); target(1, 2); } void target(int a) {} void target(int a, int b) {} }", @@ -201,7 +253,10 @@ fn given_same_class_different_arity_overloads_when_find_references_then_collects let content = &trees[0].1; let tree = &trees[0].2; - let pos = content.find("target()").expect("find zero-arity declaration"); + // Find target() declaration + let pos = content + .find("target()") + .expect("find zero-arity declaration"); let (line, col) = offset_to_point(content, pos); let resolution = resolver @@ -212,19 +267,14 @@ fn given_same_class_different_arity_overloads_when_find_references_then_collects let starts = starts_set(&ranges); let decl_zero = content.find("target() {").expect("find target()"); - let call_one = content.find("target(1);").expect("find target(1)"); - let call_two = content.find("target(1, 2);").expect("find target(1,2)"); - let decl_one = content.find("target(int a)").expect("find target(int)"); - let decl_two = content - .find("target(int a, int b)") - .expect("find target(int,int)"); - let expected = BTreeSet::from([ - offset_to_point(content, decl_zero), - offset_to_point(content, call_one), - offset_to_point(content, call_two), - offset_to_point(content, decl_one), - offset_to_point(content, decl_two), - ]); - assert_eq!(starts, expected); + // target(1) calls target(int), target(1,2) calls target(int,int). + // querying target() should NOT include them. + // And target(int) / target(int,int) declarations are distinct. + + let expected = BTreeSet::from([offset_to_point(content, decl_zero)]); + assert_eq!( + starts, expected, + "Should strictly match only zero-arity target()" + ); } diff --git a/crates/lang-java/tests/test_engine_facade.rs b/crates/lang-java/tests/test_engine_facade.rs index 31a535d..d66d2b2 100644 --- a/crates/lang-java/tests/test_engine_facade.rs +++ b/crates/lang-java/tests/test_engine_facade.rs @@ -64,8 +64,8 @@ public class App { .expect("Should resolve b.run()"); match &resolution { - SymbolResolution::Precise(fqn, _) => assert_eq!(fqn, "com.example.Base#run"), - SymbolResolution::Global(fqn) => assert_eq!(fqn, "com.example.Base#run"), + SymbolResolution::Precise(fqn, _) => assert_eq!(fqn, "com.example.Base#run()"), + SymbolResolution::Global(fqn) => assert_eq!(fqn, "com.example.Base#run()"), _ => panic!( "Expected precise or global resolution, got {:?}", resolution @@ -81,7 +81,7 @@ public class App { assert!(impls[0].path.to_string_lossy().contains("Impl.java")); let calls = handle - .find_incoming_calls("com.example.Impl#run") + .find_incoming_calls("com.example.Impl#run()") .await .unwrap(); assert_eq!( @@ -90,7 +90,10 @@ public class App { "Lookup of Impl#run should find the call via Base type" ); - let incoming_base = handle.find_incoming_calls("com.example.Base#run").await.unwrap(); + let incoming_base = handle + .find_incoming_calls("com.example.Base#run()") + .await + .unwrap(); let query_refs = ReferenceQuery { language: naviscope_api::models::Language::JAVA, @@ -109,7 +112,7 @@ public class App { 1, "find_incoming_calls should have found 1 caller in App.java" ); - assert_eq!(incoming_base[0].from.id, "com.example.App#start"); + assert_eq!(incoming_base[0].from.id, "com.example.App#start()"); } #[tokio::test] @@ -310,7 +313,11 @@ public class Use { .await .unwrap(); - assert_eq!(refs.len(), 1, "Base#ping should only match Base.ping() usage"); + assert_eq!( + refs.len(), + 1, + "Base#ping should only match Base.ping() usage" + ); assert!(refs[0].path.to_string_lossy().contains("Use.java")); let use_content = std::fs::read_to_string(temp_dir.join("com/example/Use.java")).unwrap(); @@ -367,9 +374,14 @@ async fn test_find_references_same_class_overloads_different_arity_via_engine_fa .await .unwrap(); - assert_eq!(refs.len(), 5, "expected declarations + call sites for same-name overloads"); + assert_eq!( + refs.len(), + 1, + "expected strict resolution to match ONLY exact overload decl (target() is never called)" + ); assert!( - refs.iter().all(|r| r.path.to_string_lossy().contains("com/example/A.java")), + refs.iter() + .all(|r| r.path.to_string_lossy().contains("com/example/A.java")), "all references should stay in A.java for this scenario" ); @@ -377,16 +389,10 @@ async fn test_find_references_same_class_overloads_different_arity_via_engine_fa .iter() .map(|r| (r.range.start_line, r.range.start_col)) .collect(); - let expected: BTreeSet<(usize, usize)> = [ - content.find("target() {").unwrap(), - content.find("target(1);").unwrap(), - content.find("target(1, 2);").unwrap(), - content.find("target(int a) {}").unwrap(), - content.find("target(int a, int b) {}").unwrap(), - ] - .into_iter() - .map(|offset| offset_to_point(&content, offset)) - .collect(); + let expected: BTreeSet<(usize, usize)> = [content.find("target() {").unwrap()] + .into_iter() + .map(|offset| offset_to_point(&content, offset)) + .collect(); assert_eq!(starts, expected); } @@ -427,7 +433,7 @@ async fn test_resolve_method_declaration_name_and_symbol_info_for_overload_hover SymbolResolution::Precise(fqn, _) | SymbolResolution::Global(fqn) => fqn, SymbolResolution::Local(_, _) => panic!("method declaration should not resolve as local"), }; - assert_eq!(fqn, "com.example.A#target"); + assert_eq!(fqn, "com.example.A#target(int)"); let info = handle .get_symbol_info(&fqn) diff --git a/crates/plugin/src/lib.rs b/crates/plugin/src/lib.rs index ffe006d..f88a723 100644 --- a/crates/plugin/src/lib.rs +++ b/crates/plugin/src/lib.rs @@ -17,6 +17,6 @@ pub use core::*; pub use graph::*; pub use indexing::*; pub use model::*; -pub use naming::{NamingConvention, StandardNamingConvention}; +pub use naming::{MethodSignature, NamingConvention, StandardNamingConvention}; pub use registration::*; pub use typing::*; diff --git a/crates/plugin/src/naming.rs b/crates/plugin/src/naming.rs index 1e0d6bd..d4c9187 100644 --- a/crates/plugin/src/naming.rs +++ b/crates/plugin/src/naming.rs @@ -8,6 +8,10 @@ pub const MEMBER_SEPARATOR: char = '#'; /// Separator used between packages and between package/class. pub const TYPE_SEPARATOR: char = '.'; +// --------------------------------------------------------------------------- +// Name-level member FQN utilities (existing) +// --------------------------------------------------------------------------- + /// Build a fully qualified name for a member (method, field, or constructor). pub fn build_member_fqn(type_fqn: &str, member_name: &str) -> String { format!("{}{}{}", type_fqn, MEMBER_SEPARATOR, member_name) @@ -37,6 +41,115 @@ pub fn extract_member_name(fqn: &str) -> Option<&str> { parse_member_fqn(fqn).map(|(_, member)| member) } +// --------------------------------------------------------------------------- +// Signature-level method FQN utilities (cross-language) +// +// These operate on the project's FQN format convention (`Owner#name(params)`) +// and are language-agnostic. Language-specific type normalization (e.g. Java +// generic erasure) belongs in the respective `lang-*` crate. +// --------------------------------------------------------------------------- + +/// Format a method member name with its parameter signature. +/// +/// Takes a method name and **already-normalized** parameter type strings. +/// The normalization of types (e.g. generic erasure, varargs→array) is +/// language-specific and should be done by the caller. +/// +/// # Examples +/// +/// - `format_method_name("target", &[])` → `"target()"` +/// - `format_method_name("target", &["int"])` → `"target(int)"` +/// - `format_method_name("target", &["int", "java.lang.String"])` → `"target(int,java.lang.String)"` +/// +/// The result can be passed directly to [`build_member_fqn`]: +/// ``` +/// use naviscope_plugin::naming::{build_member_fqn, format_method_name}; +/// +/// let signed = format_method_name("target", &["int"]); +/// let fqn = build_member_fqn("com.example.A", &signed); +/// assert_eq!(fqn, "com.example.A#target(int)"); +/// ``` +pub fn format_method_name(name: &str, normalized_param_types: &[&str]) -> String { + format!("{}({})", name, normalized_param_types.join(",")) +} + +/// Parsed components of a signature-level method FQN. +/// +/// Produced by [`parse_method_signature`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MethodSignature<'a> { + /// Owner type FQN (e.g., `"com.example.A"`) + pub owner: &'a str, + /// Method simple name (e.g., `"target"`) + pub name: &'a str, + /// Raw parameter string inside parentheses (e.g., `"int,java.lang.String"`). + /// Empty string for no-arg methods. + pub params: &'a str, +} + +/// Parse a signature-level method FQN into its components. +/// +/// Returns `None` if the FQN is not a signed method FQN (no `#` or no parentheses). +/// +/// # Examples +/// +/// ``` +/// use naviscope_plugin::naming::parse_method_signature; +/// +/// let sig = parse_method_signature("com.example.A#target(int)").unwrap(); +/// assert_eq!(sig.owner, "com.example.A"); +/// assert_eq!(sig.name, "target"); +/// assert_eq!(sig.params, "int"); +/// +/// let sig = parse_method_signature("com.example.A#target()").unwrap(); +/// assert_eq!(sig.params, ""); +/// +/// // Not a signed method FQN: +/// assert!(parse_method_signature("com.example.A#field").is_none()); +/// assert!(parse_method_signature("com.example.A").is_none()); +/// ``` +pub fn parse_method_signature(fqn: &str) -> Option> { + let (owner, member) = parse_member_fqn(fqn)?; + let paren_start = member.find('(')?; + let paren_end = member.rfind(')')?; + if paren_end <= paren_start { + return None; + } + let name = &member[..paren_start]; + let params = &member[paren_start + 1..paren_end]; + Some(MethodSignature { + owner, + name, + params, + }) +} + +/// Check if a member FQN includes a method signature (has parentheses after `#`). +/// +/// - `"com.example.A#target(int)"` → `true` +/// - `"com.example.A#target()"` → `true` +/// - `"com.example.A#field"` → `false` +/// - `"com.example.A"` → `false` +pub fn has_method_signature(fqn: &str) -> bool { + parse_method_signature(fqn).is_some() +} + +/// Extract the simple name from a member part that may include a signature. +/// +/// This is useful for display purposes — stripping the parameter list while keeping +/// the method name. +/// +/// - `"target(int)"` → `"target"` +/// - `"target"` → `"target"` +/// - `"(int)"` → `""` +pub fn extract_simple_name(member: &str) -> &str { + member.find('(').map(|i| &member[..i]).unwrap_or(member) +} + +// --------------------------------------------------------------------------- +// NamingConvention trait +// --------------------------------------------------------------------------- + /// Defines language-specific naming rules for Fully Qualified Names (FQNs). /// This trait allows the core system to parse flat strings into structured paths /// based on language semantics (separators, nesting rules, etc.). @@ -168,7 +281,7 @@ impl NamingConvention for StandardNamingConvention { result.push((kind, part.to_string())); } - // 3. Add member part if present + // 3. Add member part if present (includes signature for signed method FQNs) if let Some(member) = member_part { // If parsed as member, use Method as default, or heuristic if provided // Note: Heuristic for member is usually Method, but could be Field. @@ -179,3 +292,137 @@ impl NamingConvention for StandardNamingConvention { result } } + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + // -- format_method_name -- + + #[test] + fn format_method_name_basic() { + assert_eq!(format_method_name("foo", &[]), "foo()"); + assert_eq!(format_method_name("foo", &["A"]), "foo(A)"); + assert_eq!(format_method_name("foo", &["A", "B"]), "foo(A,B)"); + } + + #[test] + fn format_method_name_composes_with_build_member_fqn() { + let signed = format_method_name("bar", &["X"]); + let fqn = build_member_fqn("pkg.Owner", &signed); + assert_eq!(fqn, "pkg.Owner#bar(X)"); + } + + // -- parse_method_signature -- + + #[test] + fn parse_no_params() { + let sig = parse_method_signature("pkg.Owner#foo()").unwrap(); + assert_eq!(sig.owner, "pkg.Owner"); + assert_eq!(sig.name, "foo"); + assert_eq!(sig.params, ""); + } + + #[test] + fn parse_single_param() { + let sig = parse_method_signature("pkg.Owner#foo(T1)").unwrap(); + assert_eq!(sig.owner, "pkg.Owner"); + assert_eq!(sig.name, "foo"); + assert_eq!(sig.params, "T1"); + } + + #[test] + fn parse_multiple_params() { + let sig = parse_method_signature("pkg.Owner#foo(T1,T2)").unwrap(); + assert_eq!(sig.owner, "pkg.Owner"); + assert_eq!(sig.name, "foo"); + assert_eq!(sig.params, "T1,T2"); + } + + #[test] + fn parse_not_signed_field() { + assert!(parse_method_signature("pkg.Owner#field").is_none()); + } + + #[test] + fn parse_not_a_member() { + assert!(parse_method_signature("pkg.Owner").is_none()); + } + + // -- has_method_signature -- + + #[test] + fn has_signature_true_cases() { + assert!(has_method_signature("pkg.Owner#foo()")); + assert!(has_method_signature("pkg.Owner#foo(T1)")); + assert!(has_method_signature("pkg.Owner#foo(T1,T2)")); + } + + #[test] + fn has_signature_false_cases() { + assert!(!has_method_signature("pkg.Owner#field")); + assert!(!has_method_signature("pkg.Owner")); + assert!(!has_method_signature("T1")); + } + + // -- extract_simple_name -- + + #[test] + fn extract_simple_name_with_signature() { + assert_eq!(extract_simple_name("foo(T1)"), "foo"); + assert_eq!(extract_simple_name("foo()"), "foo"); + assert_eq!(extract_simple_name("foo(T1,T2)"), "foo"); + } + + #[test] + fn extract_simple_name_without_signature() { + assert_eq!(extract_simple_name("foo"), "foo"); + assert_eq!(extract_simple_name("field"), "field"); + } + + // -- Interaction with existing utilities -- + + #[test] + fn parse_member_fqn_works_with_signed_method() { + let (owner, member) = parse_member_fqn("pkg.Owner#foo(T1)").unwrap(); + assert_eq!(owner, "pkg.Owner"); + assert_eq!(member, "foo(T1)"); + assert_eq!(extract_simple_name(member), "foo"); + } + + #[test] + fn roundtrip_format_then_parse() { + let signed = format_method_name("foo", &["T1", "T2"]); + let fqn = build_member_fqn("pkg.Owner", &signed); + assert_eq!(fqn, "pkg.Owner#foo(T1,T2)"); + + let sig = parse_method_signature(&fqn).unwrap(); + assert_eq!(sig.owner, "pkg.Owner"); + assert_eq!(sig.name, "foo"); + assert_eq!(sig.params, "T1,T2"); + } + + // -- StandardNamingConvention compatibility -- + + #[test] + fn standard_convention_parses_signed_method_fqn() { + let conv = StandardNamingConvention; + let parts = conv.parse_fqn("pkg.Owner#foo(T1)", None); + assert_eq!(parts.len(), 3); + assert_eq!(parts[0], (NodeKind::Package, "pkg".to_string())); + assert_eq!(parts[1], (NodeKind::Class, "Owner".to_string())); + assert_eq!(parts[2], (NodeKind::Method, "foo(T1)".to_string())); + } + + #[test] + fn two_overloads_produce_distinct_fqn_paths() { + let conv = StandardNamingConvention; + let p1 = conv.parse_fqn("pkg.Owner#foo(T1)", None); + let p2 = conv.parse_fqn("pkg.Owner#foo(T1,T2)", None); + assert_ne!(p1.last(), p2.last()); + } +} From 517464f8fdf82463a8f10eaa1d0ce92cef069d3e Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Sun, 15 Feb 2026 02:39:43 +0800 Subject: [PATCH 42/49] Implement epoch tracking in ingest runtime - Introduced `EpochTracker` to manage submission and commitment of messages in epochs. - Added methods for recording submissions, marking commitments, sealing epochs, and waiting for epoch completion. - Updated `IntakeHandle` to utilize `EpochTracker` for managing message submissions and rollbacks. - Removed the `Scheduler` trait from the ingest runtime, simplifying the execution flow. - Refactored tests to accommodate changes in the runtime and executor handling. - Updated Java plugin to support new artifact structures for source collection and analysis. - Enhanced project context to maintain a symbol table for type and method symbols. --- crates/core/src/indexing/compiler.rs | 43 +- crates/core/src/indexing/mod.rs | 1 - crates/core/src/indexing/stub_planner.rs | 60 --- crates/core/src/ingest/batch_tracker.rs | 67 --- crates/core/src/ingest/commit_sink.rs | 50 ++ crates/core/src/ingest/deferred_queue.rs | 119 +++++ crates/core/src/ingest/executor.rs | 359 +++++++++++++ crates/core/src/ingest/metrics.rs | 13 + crates/core/src/ingest/mod.rs | 81 +-- crates/core/src/ingest/stub_ops.rs | 128 +++++ crates/core/src/ingest/workers.rs | 281 ---------- crates/core/src/runtime/lifecycle.rs | 6 +- crates/core/tests/async_stubbing.rs | 4 +- crates/core/tests/semantic_traits.rs | 102 +++- crates/ingest/README.md | 239 +++++---- crates/ingest/src/lib.rs | 7 +- crates/ingest/src/runtime/kernel.rs | 135 +++-- crates/ingest/src/runtime/mod.rs | 199 +++++++- crates/ingest/src/traits.rs | 5 - crates/ingest/tests/runtime_kernel.rs | 73 +-- crates/lang-java/src/resolve/lang.rs | 624 +++++++++++++++-------- crates/lang-java/tests/common/mod.rs | 6 +- crates/plugin/src/cap/indexing.rs | 29 +- crates/plugin/src/graph.rs | 8 + crates/plugin/src/indexing/context.rs | 9 + 25 files changed, 1708 insertions(+), 940 deletions(-) delete mode 100644 crates/core/src/indexing/stub_planner.rs delete mode 100644 crates/core/src/ingest/batch_tracker.rs create mode 100644 crates/core/src/ingest/commit_sink.rs create mode 100644 crates/core/src/ingest/deferred_queue.rs create mode 100644 crates/core/src/ingest/executor.rs create mode 100644 crates/core/src/ingest/metrics.rs create mode 100644 crates/core/src/ingest/stub_ops.rs delete mode 100644 crates/core/src/ingest/workers.rs diff --git a/crates/core/src/indexing/compiler.rs b/crates/core/src/indexing/compiler.rs index 4f98cd4..2c0d620 100644 --- a/crates/core/src/indexing/compiler.rs +++ b/crates/core/src/indexing/compiler.rs @@ -1,20 +1,16 @@ use crate::error::Result; use crate::indexing::scanner::ParsedFile; -use crate::model::{GraphOp, ResolvedUnit}; -use naviscope_plugin::{BuildCaps, BuildContent, LanguageCaps, ParsedContent, ProjectContext}; +use crate::model::GraphOp; +use naviscope_plugin::{BuildCaps, BuildContent, ParsedContent, ProjectContext}; use std::fs; pub struct BatchCompiler { build_caps: Vec, - lang_caps: Vec, } impl BatchCompiler { - pub fn with_caps(build_caps: Vec, lang_caps: Vec) -> Self { - Self { - build_caps, - lang_caps, - } + pub fn with_caps(build_caps: Vec) -> Self { + Self { build_caps } } pub fn compile_build_batch( @@ -46,37 +42,6 @@ impl BatchCompiler { Ok(all_ops) } - pub fn compile_source_batch( - &self, - source_files: &[ParsedFile], - context: &ProjectContext, - ) -> Result> { - let source_results: Vec> = source_files - .iter() - .map(|file| { - let caps = self - .lang_caps - .iter() - .find(|c| c.matcher.supports_path(file.path())); - - if let Some(c) = caps { - c.indexing - .compile_source(file, context) - .map_err(crate::error::NaviscopeError::from) - } else { - Ok(ResolvedUnit::new()) - } - }) - .collect(); - - let mut all_ops = Vec::new(); - for result in source_results { - let unit = result?; - all_ops.extend(unit.ops); - } - Ok(all_ops) - } - fn prepare_build_file(caps: &BuildCaps, file: &ParsedFile) -> Result { let source = match &file.content { ParsedContent::Unparsed(s) => s.clone(), diff --git a/crates/core/src/indexing/mod.rs b/crates/core/src/indexing/mod.rs index b4f6521..d3f05dd 100644 --- a/crates/core/src/indexing/mod.rs +++ b/crates/core/src/indexing/mod.rs @@ -1,6 +1,5 @@ pub mod compiler; pub mod scanner; -pub mod stub_planner; pub use naviscope_plugin::IndexNode; diff --git a/crates/core/src/indexing/stub_planner.rs b/crates/core/src/indexing/stub_planner.rs deleted file mode 100644 index fb90e9b..0000000 --- a/crates/core/src/indexing/stub_planner.rs +++ /dev/null @@ -1,60 +0,0 @@ -use crate::indexing::StubRequest; -use crate::model::GraphOp; -use naviscope_api::models::graph::NodeSource; -use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; - -pub struct StubPlanner; - -impl StubPlanner { - pub fn plan(ops: &[GraphOp], routes: &HashMap>) -> Vec { - let mut requests = Vec::new(); - let mut seen_fqns = HashSet::new(); - - for op in ops { - match op { - GraphOp::AddEdge { to_id, .. } => { - seen_fqns.insert(to_id.to_string()); - } - GraphOp::AddNode { - data: Some(node_data), - } if node_data.source == NodeSource::External => { - seen_fqns.insert(node_data.id.to_string()); - } - _ => {} - } - } - - if seen_fqns.is_empty() || routes.is_empty() { - return requests; - } - - for fqn in seen_fqns { - if let Some(paths) = Self::find_asset_for_fqn(&fqn, routes) { - requests.push(StubRequest { - fqn, - candidate_paths: paths.clone(), - }); - } - } - requests - } - - fn find_asset_for_fqn<'a>( - fqn: &str, - routes: &'a HashMap>, - ) -> Option<&'a Vec> { - let mut current = fqn.to_string(); - while !current.is_empty() { - if let Some(paths) = routes.get(¤t) { - return Some(paths); - } - if let Some(idx) = current.rfind('.') { - current.truncate(idx); - } else { - break; - } - } - None - } -} diff --git a/crates/core/src/ingest/batch_tracker.rs b/crates/core/src/ingest/batch_tracker.rs deleted file mode 100644 index 9aac0e3..0000000 --- a/crates/core/src/ingest/batch_tracker.rs +++ /dev/null @@ -1,67 +0,0 @@ -use std::collections::HashMap; -use std::sync::Mutex; - -use tokio::sync::oneshot; - -#[derive(Default)] -pub struct BatchTracker { - inner: Mutex, -} - -#[derive(Default)] -struct BatchTrackerInner { - next_batch: u64, - msg_to_batch: HashMap, - batches: HashMap, -} - -struct BatchState { - remaining: usize, - done_tx: Option>, -} - -impl BatchTracker { - pub fn register_batch(&self, msg_ids: &[String]) -> (u64, oneshot::Receiver<()>) { - let mut guard = self.inner.lock().expect("batch tracker lock poisoned"); - let batch_id = guard.next_batch; - guard.next_batch += 1; - - let (tx, rx) = oneshot::channel(); - guard.batches.insert( - batch_id, - BatchState { - remaining: msg_ids.len(), - done_tx: Some(tx), - }, - ); - for msg_id in msg_ids { - guard.msg_to_batch.insert(msg_id.clone(), batch_id); - } - - (batch_id, rx) - } - - pub fn mark_done(&self, msg_id: &str) { - let mut guard = self.inner.lock().expect("batch tracker lock poisoned"); - let Some(batch_id) = guard.msg_to_batch.remove(msg_id) else { - return; - }; - - let mut should_remove = false; - if let Some(state) = guard.batches.get_mut(&batch_id) { - if state.remaining > 0 { - state.remaining -= 1; - } - if state.remaining == 0 { - if let Some(done_tx) = state.done_tx.take() { - let _ = done_tx.send(()); - } - should_remove = true; - } - } - - if should_remove { - guard.batches.remove(&batch_id); - } - } -} diff --git a/crates/core/src/ingest/commit_sink.rs b/crates/core/src/ingest/commit_sink.rs new file mode 100644 index 0000000..2248dde --- /dev/null +++ b/crates/core/src/ingest/commit_sink.rs @@ -0,0 +1,50 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use naviscope_ingest::{CommitSink, ExecutionResult, IngestError}; +use naviscope_plugin::NamingConvention; + +use crate::model::{CodeGraph, GraphOp}; + +pub struct CommitGraphSink { + pub current: Arc>>, + pub naming_conventions: Arc>>, +} + +impl CommitSink for CommitGraphSink { + fn commit_epoch( + &self, + _epoch: u64, + results: Vec>, + ) -> Result { + if results.is_empty() { + return Ok(0); + } + + let mut ops = Vec::new(); + for result in results { + ops.extend(result.operations); + } + + if !ops.is_empty() { + let current = Arc::clone(&self.current); + let naming_conventions = Arc::clone(&self.naming_conventions); + tokio::runtime::Handle::current().block_on(async move { + let mut lock = current.write().await; + let mut builder = lock.as_ref().to_builder(); + for (lang, naming) in naming_conventions.iter() { + builder + .naming_conventions + .insert(crate::model::Language::new(lang.clone()), Arc::clone(naming)); + } + builder + .apply_ops(ops) + .map_err(|e| IngestError::Execution(e.to_string()))?; + *lock = Arc::new(builder.build()); + Ok::<(), IngestError>(()) + })?; + } + + Ok(1) + } +} diff --git a/crates/core/src/ingest/deferred_queue.rs b/crates/core/src/ingest/deferred_queue.rs new file mode 100644 index 0000000..54af756 --- /dev/null +++ b/crates/core/src/ingest/deferred_queue.rs @@ -0,0 +1,119 @@ +use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::Mutex; + +use naviscope_ingest::{ + DeferredStore, DependencyKind, DependencyReadyEvent, DependencyRef, IngestError, Message, +}; + +pub struct InMemoryDeferredQueue

{ + deferred: Mutex>>, + ready_messages: Mutex>, + ready_resources: Mutex>, +} + +impl

Default for InMemoryDeferredQueue

{ + fn default() -> Self { + Self { + deferred: Mutex::new(VecDeque::new()), + ready_messages: Mutex::new(HashSet::new()), + ready_resources: Mutex::new(HashMap::new()), + } + } +} + +impl

DeferredStore

for InMemoryDeferredQueue

+where + P: Clone + Send + Sync + 'static, +{ + fn push(&self, message: Message

) -> Result<(), IngestError> { + let mut queue = self + .deferred + .lock() + .map_err(|_| IngestError::Execution("deferred queue poisoned".to_string()))?; + queue.push_back(message); + Ok(()) + } + + fn pop_ready(&self, limit: usize) -> Result>, IngestError> { + if limit == 0 { + return Ok(Vec::new()); + } + + let ready_messages = self + .ready_messages + .lock() + .map_err(|_| IngestError::Execution("ready message set poisoned".to_string()))? + .clone(); + let ready_resources = self + .ready_resources + .lock() + .map_err(|_| IngestError::Execution("ready resource map poisoned".to_string()))? + .clone(); + + let mut queue = self + .deferred + .lock() + .map_err(|_| IngestError::Execution("deferred queue poisoned".to_string()))?; + + let mut out = Vec::new(); + let original_len = queue.len(); + for _ in 0..original_len { + let Some(mut msg) = queue.pop_front() else { + break; + }; + + let satisfied = msg + .depends_on + .iter() + .all(|dep| dependency_satisfied(dep, &ready_messages, &ready_resources)); + + if satisfied && out.len() < limit { + msg.depends_on.clear(); + out.push(msg); + } else { + queue.push_back(msg); + } + } + + Ok(out) + } + + fn notify_ready(&self, event: DependencyReadyEvent) -> Result<(), IngestError> { + match event.dependency.kind { + DependencyKind::Message => { + let mut ready = self + .ready_messages + .lock() + .map_err(|_| IngestError::Execution("ready message set poisoned".to_string()))?; + ready.insert(event.dependency.target); + } + DependencyKind::Resource => { + let mut ready = self + .ready_resources + .lock() + .map_err(|_| IngestError::Execution("ready resource map poisoned".to_string()))?; + let version = event.dependency.min_version.unwrap_or(0); + ready.entry(event.dependency.target) + .and_modify(|v| *v = (*v).max(version)) + .or_insert(version); + } + } + Ok(()) + } +} + +fn dependency_satisfied( + dep: &DependencyRef, + ready_messages: &HashSet, + ready_resources: &HashMap, +) -> bool { + match dep.kind { + DependencyKind::Message => ready_messages.contains(&dep.target), + DependencyKind::Resource => match dep.min_version { + Some(min) => ready_resources + .get(&dep.target) + .is_some_and(|version| *version >= min), + None => ready_resources.contains_key(&dep.target), + }, + } +} diff --git a/crates/core/src/ingest/executor.rs b/crates/core/src/ingest/executor.rs new file mode 100644 index 0000000..52661c1 --- /dev/null +++ b/crates/core/src/ingest/executor.rs @@ -0,0 +1,359 @@ +use std::collections::{BTreeMap, HashMap}; +use std::path::PathBuf; +use std::sync::{Arc, Mutex, RwLock}; + +use naviscope_ingest::{ + DependencyRef, ExecutionResult, ExecutionStatus, Executor, IngestError, PipelineEvent, +}; +use naviscope_plugin::{ + LanguageCaps, ParsedFile, ProjectContext, SourceAnalyzeArtifact, SourceCollectArtifact, +}; + +use crate::indexing::StubRequest; +use crate::ingest::IngestWorkItem; +use crate::model::{CodeGraph, GraphOp}; + +use super::stub_ops::{find_asset_for_fqn, generate_stub_ops, plan_stub_requests}; + +pub struct IngestExecutor { + pub lang_caps: Arc>, + pub project_context: Arc>, + pub routes: Arc>>>, + pub current: Arc>>, + pub stub_cache: Arc, + pub collect_cache: Arc>>>, + pub analyze_cache: Arc>>>, +} + +impl Executor for IngestExecutor { + fn execute( + &self, + message: naviscope_ingest::Message, + ) -> Result>, IngestError> { + let parent_msg_id = message.msg_id.clone(); + let epoch = message.epoch; + match message.payload.clone() { + IngestWorkItem::SourceCollect(file) => { + let dep_state = self.execute_collect(file.clone())?; + let analyze_msg = naviscope_ingest::Message { + msg_id: next_stage_msg_id(&parent_msg_id, "collect", "analyze"), + topic: "source-analyze".to_string(), + message_group: file.path().to_string_lossy().to_string(), + version: 1, + depends_on: dep_state + .required_resources + .iter() + .cloned() + .map(|s| DependencyRef::resource(s, None)) + .collect(), + epoch, + payload: IngestWorkItem::SourceAnalyze(file), + metadata: BTreeMap::new(), + }; + Ok(vec![ + PipelineEvent::Executed { + epoch, + result: ExecutionResult { + msg_id: parent_msg_id, + status: ExecutionStatus::Done, + operations: Vec::new(), + next_dependencies: dep_state.provided_resources, + error: None, + }, + }, + PipelineEvent::Deferred(analyze_msg), + ]) + } + IngestWorkItem::SourceAnalyze(file) => { + self.execute_analyze(file.clone())?; + let lower_msg = naviscope_ingest::Message { + msg_id: next_stage_msg_id(&parent_msg_id, "analyze", "lower"), + topic: "source-lower".to_string(), + message_group: message.message_group.clone(), + version: 1, + depends_on: vec![DependencyRef::message(parent_msg_id.clone())], + epoch, + payload: IngestWorkItem::SourceLower(file), + metadata: BTreeMap::new(), + }; + Ok(vec![ + PipelineEvent::Executed { + epoch, + result: ExecutionResult { + msg_id: parent_msg_id, + status: ExecutionStatus::Done, + operations: Vec::new(), + next_dependencies: Vec::new(), + error: None, + }, + }, + PipelineEvent::Deferred(lower_msg), + ]) + } + IngestWorkItem::SourceLower(file) => { + let outcome = self.execute_lower(file)?; + Ok(vec![PipelineEvent::Executed { + epoch, + result: ExecutionResult { + msg_id: parent_msg_id, + status: ExecutionStatus::Done, + operations: outcome.operations, + next_dependencies: Vec::new(), + error: None, + }, + }]) + } + IngestWorkItem::StubRequest(req) => { + let operations = self.execute_stub(req); + Ok(vec![PipelineEvent::Executed { + epoch, + result: ExecutionResult { + msg_id: parent_msg_id, + status: ExecutionStatus::Done, + operations, + next_dependencies: Vec::new(), + error: None, + }, + }]) + } + } + } +} + +struct SourceExecutionOutcome { + operations: Vec, +} + +struct CollectDependencyState { + provided_resources: Vec, + required_resources: Vec, +} + +impl IngestExecutor { + fn execute_collect(&self, file: ParsedFile) -> Result { + let caps = self + .lang_caps + .iter() + .find(|c| c.matcher.supports_path(file.path())); + let Some(caps) = caps else { + return Ok(CollectDependencyState { + provided_resources: Vec::new(), + required_resources: Vec::new(), + }); + }; + + let mut cache = self + .collect_cache + .lock() + .map_err(|_| IngestError::Execution("collect cache poisoned".to_string()))?; + if let Some(collected) = cache.get(file.path()) { + let mut ctx = self + .project_context + .write() + .map_err(|_| IngestError::Execution("project context poisoned".to_string()))?; + for sym in collected.collected_type_symbols() { + ctx.symbol_table.type_symbols.insert(sym.clone()); + } + for sym in collected.collected_method_symbols() { + ctx.symbol_table.method_symbols.insert(sym.clone()); + } + return Ok(CollectDependencyState { + provided_resources: collected + .provided_dependency_symbols() + .iter() + .cloned() + .map(|s| DependencyRef::resource(s, None)) + .collect(), + required_resources: collected.required_dependency_symbols().to_vec(), + }); + } + + let context = self + .project_context + .read() + .map_err(|_| IngestError::Execution("project context poisoned".to_string()))? + .clone(); + let collected = caps + .indexing + .collect_source(&file, &context) + .map_err(|e| IngestError::Execution(e.to_string()))?; + { + let mut ctx = self + .project_context + .write() + .map_err(|_| IngestError::Execution("project context poisoned".to_string()))?; + for sym in collected.collected_type_symbols() { + ctx.symbol_table.type_symbols.insert(sym.clone()); + } + for sym in collected.collected_method_symbols() { + ctx.symbol_table.method_symbols.insert(sym.clone()); + } + } + let provided_resources = collected + .provided_dependency_symbols() + .iter() + .cloned() + .map(|s| DependencyRef::resource(s, None)) + .collect(); + let required_resources = collected.required_dependency_symbols().to_vec(); + cache.insert(file.path().to_path_buf(), collected); + Ok(CollectDependencyState { + provided_resources, + required_resources, + }) + } + + fn execute_analyze(&self, file: ParsedFile) -> Result<(), IngestError> { + let caps = self + .lang_caps + .iter() + .find(|c| c.matcher.supports_path(file.path())); + let Some(caps) = caps else { + return Ok(()); + }; + + let context = self + .project_context + .read() + .map_err(|_| IngestError::Execution("project context poisoned".to_string()))? + .clone(); + + let collected = { + let mut cache = self + .collect_cache + .lock() + .map_err(|_| IngestError::Execution("collect cache poisoned".to_string()))?; + if let Some(c) = cache.remove(file.path()) { + c + } else { + caps.indexing + .collect_source(&file, &context) + .map_err(|e| IngestError::Execution(e.to_string()))? + } + }; + + let analyzed = caps + .indexing + .analyze_source(collected, &context) + .map_err(|e| IngestError::Execution(e.to_string()))?; + + let mut cache = self + .analyze_cache + .lock() + .map_err(|_| IngestError::Execution("analyze cache poisoned".to_string()))?; + cache.insert(file.path().to_path_buf(), analyzed); + Ok(()) + } + + fn execute_lower(&self, file: ParsedFile) -> Result { + let caps = self + .lang_caps + .iter() + .find(|c| c.matcher.supports_path(file.path())); + let Some(caps) = caps else { + return Ok(SourceExecutionOutcome { + operations: Vec::new(), + }); + }; + let context = self + .project_context + .read() + .map_err(|_| IngestError::Execution("project context poisoned".to_string()))? + .clone(); + + let analyzed = { + let mut cache = self + .analyze_cache + .lock() + .map_err(|_| IngestError::Execution("analyze cache poisoned".to_string()))?; + if let Some(a) = cache.remove(file.path()) { + a + } else { + let collected = caps + .indexing + .collect_source(&file, &context) + .map_err(|e| IngestError::Execution(e.to_string()))?; + caps.indexing + .analyze_source(collected, &context) + .map_err(|e| IngestError::Execution(e.to_string()))? + } + }; + + let unit = caps + .indexing + .lower_source(analyzed, &context) + .map_err(|e| IngestError::Execution(e.to_string()))?; + + let path = file.path().to_path_buf(); + + let mut ops = Vec::with_capacity(8); + ops.push(GraphOp::RemovePath { + path: Arc::from(path.as_path()), + }); + ops.push(GraphOp::UpdateFile { + metadata: file.file.clone(), + }); + + let deferred_targets: Vec = + unit.deferred_symbols.into_iter().map(|d| d.target).collect(); + ops.extend(unit.ops); + + let routes_snapshot = self + .routes + .read() + .map_err(|_| IngestError::Execution("routes map poisoned".to_string()))? + .clone(); + + let stub_requests = plan_stub_requests(&ops, &routes_snapshot); + for req in stub_requests { + ops.extend(self.execute_stub(req)); + } + + let deferred_stub_requests = + deferred_targets_to_stub_requests(&deferred_targets, &routes_snapshot); + for req in deferred_stub_requests { + ops.extend(self.execute_stub(req)); + } + + Ok(SourceExecutionOutcome { operations: ops }) + } + + fn execute_stub(&self, req: StubRequest) -> Vec { + generate_stub_ops( + &req, + Arc::clone(&self.current), + Arc::clone(&self.lang_caps), + Arc::clone(&self.stub_cache), + ) + } +} + +fn next_stage_msg_id(current_msg_id: &str, from: &str, to: &str) -> String { + let suffix = format!(":{from}"); + if let Some(base) = current_msg_id.strip_suffix(&suffix) { + format!("{base}:{to}") + } else { + format!("{current_msg_id}:{to}") + } +} + +fn deferred_targets_to_stub_requests( + deferred_targets: &[String], + routes: &HashMap>, +) -> Vec { + let mut out = Vec::new(); + let mut seen = std::collections::HashSet::new(); + + for target in deferred_targets { + if let Some(paths) = find_asset_for_fqn(target, routes) { + if seen.insert(target.clone()) { + out.push(StubRequest { + fqn: target.clone(), + candidate_paths: paths.clone(), + }); + } + } + } + + out +} diff --git a/crates/core/src/ingest/metrics.rs b/crates/core/src/ingest/metrics.rs new file mode 100644 index 0000000..232f2eb --- /dev/null +++ b/crates/core/src/ingest/metrics.rs @@ -0,0 +1,13 @@ +use naviscope_ingest::RuntimeMetrics; + +pub struct NoopRuntimeMetrics; + +impl RuntimeMetrics for NoopRuntimeMetrics { + fn observe_queue_depth(&self, _queue: &'static str, _depth: usize) {} + + fn observe_throughput(&self, _stage: &'static str, _count: usize) {} + + fn observe_latency_ms(&self, _stage: &'static str, _p95_ms: u64, _p99_ms: u64) {} + + fn observe_replay_result(&self, _ok: bool) {} +} diff --git a/crates/core/src/ingest/mod.rs b/crates/core/src/ingest/mod.rs index 594cb3e..46d9bfd 100644 --- a/crates/core/src/ingest/mod.rs +++ b/crates/core/src/ingest/mod.rs @@ -1,5 +1,8 @@ -mod batch_tracker; -mod workers; +mod commit_sink; +mod deferred_queue; +mod executor; +mod metrics; +mod stub_ops; use std::collections::{BTreeMap, HashMap}; use std::path::PathBuf; @@ -14,23 +17,30 @@ use crate::error::{NaviscopeError, Result}; use crate::indexing::StubRequest; use crate::model::{CodeGraph, GraphOp}; -use batch_tracker::BatchTracker; -use workers::{ - CommitGraphSink, InMemoryDeferredQueue, IngestExecutor, NoopRuntimeMetrics, - PassThroughScheduler, -}; +use commit_sink::CommitGraphSink; +use deferred_queue::InMemoryDeferredQueue; +use executor::IngestExecutor; +use metrics::NoopRuntimeMetrics; +pub use stub_ops::plan_stub_requests; #[derive(Clone)] pub enum IngestWorkItem { - SourceFile(ParsedFile), + SourceCollect(ParsedFile), + SourceAnalyze(ParsedFile), + SourceLower(ParsedFile), StubRequest(StubRequest), } +#[derive(Clone)] +struct StagedSourceItem { + file: ParsedFile, + collect_id: String, +} + pub struct IngestAdapter { intake: IntakeHandle, project_context: Arc>, routes: Arc>>>, - batch_tracker: Arc, runtime_task: tokio::task::JoinHandle<()>, } @@ -38,31 +48,27 @@ impl IngestAdapter { pub async fn start( current: Arc>>, naming_conventions: Arc>>, - build_caps: Arc>, + _build_caps: Arc>, lang_caps: Arc>, stub_cache: Arc, ) -> Result { let project_context = Arc::new(RwLock::new(ProjectContext::new())); let routes = Arc::new(RwLock::new(HashMap::new())); - let batch_tracker = Arc::new(BatchTracker::default()); - - let scheduler: naviscope_ingest::DynScheduler = - Arc::new(PassThroughScheduler); let executor: naviscope_ingest::DynExecutor = Arc::new(IngestExecutor { - build_caps, lang_caps, project_context: Arc::clone(&project_context), routes: Arc::clone(&routes), current: Arc::clone(¤t), stub_cache, + collect_cache: Arc::new(std::sync::Mutex::new(HashMap::new())), + analyze_cache: Arc::new(std::sync::Mutex::new(HashMap::new())), }); let deferred_store: naviscope_ingest::DynDeferredStore = Arc::new(InMemoryDeferredQueue::default()); let commit_sink: naviscope_ingest::DynCommitSink = Arc::new(CommitGraphSink { current, naming_conventions, - batch_tracker: Arc::clone(&batch_tracker), }); let metrics: naviscope_ingest::DynRuntimeMetrics = Arc::new(NoopRuntimeMetrics); @@ -74,7 +80,6 @@ impl IngestAdapter { idle_sleep_ms: 10, }, RuntimeComponents::with_tokio_bus( - scheduler, executor, deferred_store, commit_sink, @@ -94,7 +99,6 @@ impl IngestAdapter { intake, project_context, routes, - batch_tracker, runtime_task, }) } @@ -124,34 +128,43 @@ impl IngestAdapter { return Ok(()); } - let msg_ids: Vec = source_files - .iter() - .enumerate() - .map(|(index, file)| format!("src:{}:{}", index, file.path().display())) - .collect(); - - let (batch_id, done_rx) = self.batch_tracker.register_batch(&msg_ids); - + let mut staged = Vec::new(); + let epoch = self.intake.new_epoch(); for (index, file) in source_files.into_iter().enumerate() { - let msg = naviscope_ingest::Message { - msg_id: msg_ids[index].clone(), - topic: "source-index".to_string(), + let base = format!("src:{}:{}", index, file.path().display()); + let collect_id = format!("{base}:collect"); + staged.push(StagedSourceItem { + file, + collect_id, + }); + } + + for item in staged { + let file = item.file; + let collect_id = item.collect_id; + let collect_msg = naviscope_ingest::Message { + msg_id: collect_id.clone(), + topic: "source-collect".to_string(), message_group: file.path().to_string_lossy().to_string(), version: 1, depends_on: Vec::new(), - epoch: batch_id, - payload: IngestWorkItem::SourceFile(file), + epoch, + payload: IngestWorkItem::SourceCollect(file.clone()), metadata: BTreeMap::new(), }; self.intake - .submit(msg) + .submit(collect_msg) .await .map_err(ingest_to_naviscope_error)?; } - done_rx + self.intake + .seal_epoch(epoch) + .map_err(ingest_to_naviscope_error)?; + self.intake + .wait_epoch(epoch) .await - .map_err(|_| NaviscopeError::Internal("ingest batch completion dropped".to_string())) + .map_err(ingest_to_naviscope_error) } pub async fn submit_stub_request(&self, req: StubRequest) -> Result<()> { diff --git a/crates/core/src/ingest/stub_ops.rs b/crates/core/src/ingest/stub_ops.rs new file mode 100644 index 0000000..4498a68 --- /dev/null +++ b/crates/core/src/ingest/stub_ops.rs @@ -0,0 +1,128 @@ +use std::sync::Arc; +use std::{collections::HashMap, path::PathBuf}; + +use naviscope_api::models::graph::NodeSource; +use naviscope_plugin::{AssetEntry, AssetSource, LanguageCaps}; + +use crate::indexing::StubRequest; +use crate::model::{CodeGraph, GraphOp}; + +pub fn plan_stub_requests( + ops: &[GraphOp], + routes: &HashMap>, +) -> Vec { + let mut requests = Vec::new(); + let mut seen_fqns = std::collections::HashSet::new(); + + for op in ops { + match op { + GraphOp::AddEdge { to_id, .. } => { + seen_fqns.insert(to_id.to_string()); + } + GraphOp::AddNode { + data: Some(node_data), + } if node_data.source == NodeSource::External => { + seen_fqns.insert(node_data.id.to_string()); + } + _ => {} + } + } + + if seen_fqns.is_empty() || routes.is_empty() { + return requests; + } + + for fqn in seen_fqns { + if let Some(paths) = find_asset_for_fqn(&fqn, routes) { + requests.push(StubRequest { + fqn, + candidate_paths: paths.clone(), + }); + } + } + requests +} + +pub fn find_asset_for_fqn<'a>( + fqn: &str, + routes: &'a HashMap>, +) -> Option<&'a Vec> { + let mut current = fqn.to_string(); + while !current.is_empty() { + if let Some(paths) = routes.get(¤t) { + return Some(paths); + } + if let Some(idx) = current.rfind('.') { + current.truncate(idx); + } else { + break; + } + } + None +} + +pub fn generate_stub_ops( + req: &StubRequest, + current: Arc>>, + lang_caps: Arc>, + stub_cache: Arc, +) -> Vec { + let mut ops = Vec::new(); + + // Skip if node already exists and resolved. + let already_resolved = tokio::runtime::Handle::current().block_on(async { + let lock = current.read().await; + let graph = &**lock; + if let Some(idx) = graph.find_node(&req.fqn) + && let Some(node) = graph.get_node(idx) + { + return node.status == naviscope_api::models::graph::ResolutionStatus::Resolved; + } + false + }); + if already_resolved { + return ops; + } + + for asset_path in &req.candidate_paths { + let asset_key = crate::cache::AssetKey::from_path(asset_path).ok(); + + if let Some(ref key) = asset_key + && let Some(cached_stub) = stub_cache.lookup(key, &req.fqn) + { + ops.push(GraphOp::AddNode { + data: Some(cached_stub), + }); + break; + } + + for caps in lang_caps.iter() { + let Some(generator) = caps.asset.stub_generator() else { + continue; + }; + if !generator.can_generate(asset_path) { + continue; + } + + let entry = AssetEntry::new(asset_path.clone(), AssetSource::Unknown); + match generator.generate(&req.fqn, &entry) { + Ok(stub) => { + if let Some(ref key) = asset_key { + stub_cache.store(key, &stub); + } + ops.push(GraphOp::AddNode { data: Some(stub) }); + break; + } + Err(err) => { + tracing::debug!("Failed to generate stub for {}: {}", req.fqn, err); + } + } + } + + if !ops.is_empty() { + break; + } + } + + ops +} diff --git a/crates/core/src/ingest/workers.rs b/crates/core/src/ingest/workers.rs deleted file mode 100644 index ef4b1dd..0000000 --- a/crates/core/src/ingest/workers.rs +++ /dev/null @@ -1,281 +0,0 @@ -use std::collections::{HashMap, VecDeque}; -use std::path::PathBuf; -use std::sync::{Arc, Mutex, RwLock}; - -use naviscope_ingest::{ - DeferredStore, ExecutionResult, ExecutionStatus, IngestError, PipelineEvent, RuntimeMetrics, - Scheduler, -}; -use naviscope_plugin::{AssetEntry, AssetSource, BuildCaps, LanguageCaps, ProjectContext}; - -use crate::indexing::{compiler::BatchCompiler, stub_planner::StubPlanner}; -use crate::indexing::StubRequest; -use crate::model::{CodeGraph, GraphOp}; - -use super::IngestWorkItem; -use super::batch_tracker::BatchTracker; - -pub struct PassThroughScheduler; - -impl Scheduler for PassThroughScheduler { - fn schedule( - &self, - messages: Vec>, - ) -> Result>, IngestError> { - Ok(messages.into_iter().map(PipelineEvent::Runnable).collect()) - } -} - -pub struct IngestExecutor { - pub build_caps: Arc>, - pub lang_caps: Arc>, - pub project_context: Arc>, - pub routes: Arc>>>, - pub current: Arc>>, - pub stub_cache: Arc, -} - -impl naviscope_ingest::Executor for IngestExecutor { - fn execute( - &self, - message: naviscope_ingest::Message, - ) -> Result>, IngestError> { - let operations = match message.payload { - IngestWorkItem::SourceFile(file) => self.execute_source(file)?, - IngestWorkItem::StubRequest(req) => self.execute_stub(req), - }; - - Ok(vec![PipelineEvent::Executed { - epoch: message.epoch, - result: ExecutionResult { - msg_id: message.msg_id, - status: ExecutionStatus::Done, - operations, - next_dependencies: Vec::new(), - error: None, - }, - }]) - } -} - -impl IngestExecutor { - fn execute_source( - &self, - file: naviscope_plugin::ParsedFile, - ) -> Result, IngestError> { - let path = file.path().to_path_buf(); - - let compiler = BatchCompiler::with_caps((*self.build_caps).clone(), (*self.lang_caps).clone()); - - let context = self - .project_context - .read() - .map_err(|_| IngestError::Execution("project context poisoned".to_string()))? - .clone(); - - let mut ops = Vec::with_capacity(8); - ops.push(GraphOp::RemovePath { - path: Arc::from(path.as_path()), - }); - ops.push(GraphOp::UpdateFile { - metadata: file.file.clone(), - }); - - let source_ops = compiler - .compile_source_batch(std::slice::from_ref(&file), &context) - .map_err(naviscope_to_ingest_error)?; - ops.extend(source_ops); - - let routes_snapshot = self - .routes - .read() - .map_err(|_| IngestError::Execution("routes map poisoned".to_string()))? - .clone(); - let stub_requests = StubPlanner::plan(&ops, &routes_snapshot); - for req in stub_requests { - ops.extend(self.execute_stub(req)); - } - - Ok(ops) - } - - fn execute_stub(&self, req: StubRequest) -> Vec { - generate_stub_ops( - &req, - Arc::clone(&self.current), - Arc::clone(&self.lang_caps), - Arc::clone(&self.stub_cache), - ) - } -} - -pub fn generate_stub_ops( - req: &StubRequest, - current: Arc>>, - lang_caps: Arc>, - stub_cache: Arc, -) -> Vec { - let mut ops = Vec::new(); - - // Skip if node already exists and resolved. - let already_resolved = tokio::runtime::Handle::current().block_on(async { - let lock = current.read().await; - let graph = &**lock; - if let Some(idx) = graph.find_node(&req.fqn) - && let Some(node) = graph.get_node(idx) - { - return node.status == naviscope_api::models::graph::ResolutionStatus::Resolved; - } - false - }); - if already_resolved { - return ops; - } - - for asset_path in &req.candidate_paths { - let asset_key = crate::cache::AssetKey::from_path(asset_path).ok(); - - if let Some(ref key) = asset_key - && let Some(cached_stub) = stub_cache.lookup(key, &req.fqn) - { - ops.push(GraphOp::AddNode { - data: Some(cached_stub), - }); - break; - } - - for caps in lang_caps.iter() { - let Some(generator) = caps.asset.stub_generator() else { - continue; - }; - if !generator.can_generate(asset_path) { - continue; - } - - let entry = AssetEntry::new(asset_path.clone(), AssetSource::Unknown); - match generator.generate(&req.fqn, &entry) { - Ok(stub) => { - if let Some(ref key) = asset_key { - stub_cache.store(key, &stub); - } - ops.push(GraphOp::AddNode { data: Some(stub) }); - break; - } - Err(err) => { - tracing::debug!("Failed to generate stub for {}: {}", req.fqn, err); - } - } - } - - if !ops.is_empty() { - break; - } - } - - ops -} - -#[derive(Default)] -pub struct InMemoryDeferredQueue { - items: Mutex>>, -} - -impl DeferredStore for InMemoryDeferredQueue { - fn push(&self, message: naviscope_ingest::Message) -> Result<(), IngestError> { - let mut guard = self - .items - .lock() - .map_err(|_| IngestError::Execution("deferred queue poisoned".to_string()))?; - guard.push_back(message); - Ok(()) - } - - fn pop_ready( - &self, - limit: usize, - ) -> Result>, IngestError> { - let mut guard = self - .items - .lock() - .map_err(|_| IngestError::Execution("deferred queue poisoned".to_string()))?; - let mut out = Vec::new(); - for _ in 0..limit.max(1) { - if let Some(item) = guard.pop_front() { - out.push(item); - } else { - break; - } - } - Ok(out) - } - - fn notify_ready( - &self, - _event: naviscope_ingest::DependencyReadyEvent, - ) -> Result<(), IngestError> { - Ok(()) - } -} - -pub struct CommitGraphSink { - pub current: Arc>>, - pub naming_conventions: Arc>>, - pub batch_tracker: Arc, -} - -impl naviscope_ingest::CommitSink for CommitGraphSink { - fn commit_epoch( - &self, - _epoch: u64, - results: Vec>, - ) -> Result { - if results.is_empty() { - return Ok(0); - } - - let mut merged_ops = Vec::new(); - let mut completed_ids = Vec::with_capacity(results.len()); - - for result in results { - completed_ids.push(result.msg_id); - merged_ops.extend(result.operations); - } - - let naming_conventions = Arc::clone(&self.naming_conventions); - let current = Arc::clone(&self.current); - tokio::runtime::Handle::current().block_on(async move { - let mut lock = current.write().await; - let mut builder = (**lock).to_builder(); - for (lang, nc) in naming_conventions.iter() { - builder - .naming_conventions - .insert(crate::model::Language::from(lang.clone()), Arc::clone(nc)); - } - builder - .apply_ops(merged_ops) - .map_err(naviscope_to_ingest_error)?; - *lock = Arc::new(builder.build()); - Ok::<(), IngestError>(()) - })?; - - for msg_id in completed_ids { - self.batch_tracker.mark_done(&msg_id); - } - - Ok(1) - } -} - -#[derive(Default)] -pub struct NoopRuntimeMetrics; - -impl RuntimeMetrics for NoopRuntimeMetrics { - fn observe_queue_depth(&self, _queue: &'static str, _depth: usize) {} - fn observe_throughput(&self, _stage: &'static str, _count: usize) {} - fn observe_latency_ms(&self, _stage: &'static str, _p95_ms: u64, _p99_ms: u64) {} - fn observe_replay_result(&self, _ok: bool) {} -} - -fn naviscope_to_ingest_error(err: crate::error::NaviscopeError) -> IngestError { - IngestError::Execution(err.to_string()) -} diff --git a/crates/core/src/runtime/lifecycle.rs b/crates/core/src/runtime/lifecycle.rs index cdefe14..acbcbd2 100644 --- a/crates/core/src/runtime/lifecycle.rs +++ b/crates/core/src/runtime/lifecycle.rs @@ -159,10 +159,8 @@ impl NaviscopeEngine { return Ok((base_graph, Vec::new(), naviscope_plugin::ProjectContext::new())); } - let compiler = crate::indexing::compiler::BatchCompiler::with_caps( - (*build_caps).clone(), - (*lang_caps).clone(), - ); + let compiler = + crate::indexing::compiler::BatchCompiler::with_caps((*build_caps).clone()); let mut project_context = naviscope_plugin::ProjectContext::new(); let mut initial_ops = manual_ops; diff --git a/crates/core/tests/async_stubbing.rs b/crates/core/tests/async_stubbing.rs index 69ebc52..bc2ba87 100644 --- a/crates/core/tests/async_stubbing.rs +++ b/crates/core/tests/async_stubbing.rs @@ -1,7 +1,7 @@ //! Tests for async stubbing workflow use naviscope_api::models::graph::ResolutionStatus; -use naviscope_core::indexing::stub_planner::StubPlanner; +use naviscope_core::ingest::plan_stub_requests; use naviscope_core::model::GraphOp; use naviscope_core::runtime::NaviscopeEngine; use std::path::Path; @@ -70,7 +70,7 @@ async fn test_async_stubbing_with_jar() { }), }]; - let reqs = StubPlanner::plan(&ops, &routes); + let reqs = plan_stub_requests(&ops, &routes); for req in reqs { assert!(engine.request_stub_for_fqn(&req.fqn)); } diff --git a/crates/core/tests/semantic_traits.rs b/crates/core/tests/semantic_traits.rs index 7795acd..0e51dbe 100644 --- a/crates/core/tests/semantic_traits.rs +++ b/crates/core/tests/semantic_traits.rs @@ -9,8 +9,8 @@ use naviscope_plugin::{ AssetCap, CodecContext, FileMatcherCap, GlobalParseResult, LanguageCaps, LanguageParseCap, LspSyntaxService, MetadataCodecCap, NamingConvention, NodeMetadataCodec, NodePresenter, ParsedContent, ParsedFile, PresentationCap, ProjectContext, ReferenceCheckService, - ResolvedUnit, SemanticCap, SourceIndexCap, StandardNamingConvention, SymbolQueryService, - SymbolResolveService, + ResolvedUnit, SemanticCap, SourceAnalyzeArtifact, SourceCollectArtifact, SourceIndexCap, + StandardNamingConvention, SymbolQueryService, SymbolResolveService, }; use std::path::Path; use std::sync::Arc; @@ -20,6 +20,19 @@ use tree_sitter::Tree; #[derive(Clone)] struct MockCap; +struct MockCollected { + file: ParsedFile, + type_symbols: Vec, + method_symbols: Vec, + provided_symbols: Vec, + required_symbols: Vec, +} + +struct MockAnalyzed { + file: ParsedFile, + identifiers: Vec, +} + impl FileMatcherCap for MockCap { fn supports_path(&self, path: &Path) -> bool { path.extension().and_then(|e| e.to_str()) == Some("mock") @@ -47,20 +60,53 @@ impl LanguageParseCap for MockCap { } impl SourceIndexCap for MockCap { - fn compile_source( + fn collect_source( &self, file: &ParsedFile, _context: &ProjectContext, - ) -> Result { - let mut unit = ResolvedUnit::new(); - let identifiers = match &file.content { + ) -> Result, naviscope_plugin::BoxError> { + Ok(Box::new(MockCollected { + file: file.clone(), + type_symbols: vec!["test::Symbol".to_string()], + method_symbols: vec!["test::Symbol#method()".to_string()], + provided_symbols: vec!["test::Symbol".to_string()], + required_symbols: vec![], + })) + } + + fn analyze_source( + &self, + collected: Box, + _context: &ProjectContext, + ) -> Result, naviscope_plugin::BoxError> { + let collected = collected + .into_any() + .downcast::() + .map_err(|_| "invalid mock collected artifact")?; + let identifiers = match &collected.file.content { ParsedContent::Language(res) => res.output.identifiers.clone(), _ => vec!["Symbol".to_string()], }; - unit.identifiers = identifiers.clone(); - unit.ops.push(naviscope_plugin::GraphOp::UpdateIdentifiers { - path: Arc::from(file.file.path.as_path()), + Ok(Box::new(MockAnalyzed { + file: collected.file, identifiers, + })) + } + + fn lower_source( + &self, + analyzed: Box, + _context: &ProjectContext, + ) -> Result { + let analyzed = analyzed + .into_any() + .downcast::() + .map_err(|_| "invalid mock analyzed artifact")?; + let mut unit = ResolvedUnit::new(); + unit.identifiers = analyzed.identifiers.clone(); + unit.ops.push(naviscope_plugin::GraphOp::UpdateIdentifiers { + path: Arc::from(analyzed.file.file.path.as_path()), + identifiers: analyzed.identifiers, }); unit.add_node(naviscope_plugin::IndexNode { id: naviscope_api::models::symbol::NodeId::Flat("test::Symbol".to_string()), @@ -70,7 +116,7 @@ impl SourceIndexCap for MockCap { source: NodeSource::Project, status: naviscope_api::models::graph::ResolutionStatus::Resolved, location: Some(DisplaySymbolLocation { - path: file.path().to_string_lossy().to_string(), + path: analyzed.file.path().to_string_lossy().to_string(), range: Range { start_line: 0, start_col: 0, @@ -85,6 +131,42 @@ impl SourceIndexCap for MockCap { } } +impl SourceCollectArtifact for MockCollected { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn into_any(self: Box) -> Box { + self + } + + fn collected_type_symbols(&self) -> &[String] { + &self.type_symbols + } + + fn collected_method_symbols(&self) -> &[String] { + &self.method_symbols + } + + fn provided_dependency_symbols(&self) -> &[String] { + &self.provided_symbols + } + + fn required_dependency_symbols(&self) -> &[String] { + &self.required_symbols + } +} + +impl SourceAnalyzeArtifact for MockAnalyzed { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn into_any(self: Box) -> Box { + self + } +} + impl SymbolResolveService for MockCap { fn resolve_at( &self, diff --git a/crates/ingest/README.md b/crates/ingest/README.md index 9345b6d..d95d64a 100644 --- a/crates/ingest/README.md +++ b/crates/ingest/README.md @@ -1,136 +1,163 @@ # naviscope-ingest -`naviscope-ingest` is a resident, event-driven runtime for dependency-aware message processing. +## 1. Definition -## Design Goals +`naviscope-ingest` is a DAG execution runtime built on a message queue. -- Keep domain semantics in `core`; keep orchestration in `ingest`. -- Support multi-producer push ingestion with bounded backpressure. -- Model dependency handling as data (`depends_on`) and deferred replay. -- Provide deterministic commit boundaries (`epoch`) with external commit policy. +It drives task transitions between `runnable` and `deferred` via dependencies (`depends_on`), and uses `epoch` for batch completion semantics. -## Scope +- Component type: runtime middleware +- Input: `Message

` +- Output: committed operation results through `CommitSink` +- Goal: stable dependency-aware execution and batch completion under high concurrency -In scope: +## 2. Responsibility Boundaries -- Message intake and bounded queueing. -- Event-driven schedule/execute/commit orchestration. -- Deferred persistence and replay trigger path. -- Runtime metrics hooks. +### 2.1 In Scope -Out of scope: +- Message intake and backpressure control +- Deferred persistence and replay for unresolved dependencies +- Dependency-ready event dispatch (`message` / `resource`) +- Batch (`epoch`) completion lifecycle +- Unified runtime observability hooks -- Domain/business rule evaluation. -- Source scanning / incremental detection / build-source partitioning. -- Storage engine internals. +### 2.2 Out of Scope -## Core Architecture +- Business semantics +- Source scanning and incremental discovery +- Concrete storage engine strategy -The runtime is centered around: - -- `IngestRuntime` -- `RuntimeComponents` -- `PipelineEvent` -- `PipelineBus` with **two channels**: -- `intake` channel for ingress + replayed messages -- `deferred` channel for unresolved messages +## 3. Architecture Model ```mermaid flowchart LR - EP[External Producers] --> IH[IntakeHandle::submit] - IH --> IQ[Intake Channel] - - IQ --> SCH[Scheduler] - SCH -->|Runnable| EXE[Executor] - SCH -->|Deferred| DQ[Deferred Channel] - - EXE -->|Executed| COM[CommitSink] - EXE -->|Deferred| DQ - EXE -->|Fatal| ERR[Runtime Error] - - DQ --> DS[DeferredStore::push] - DS --> RP[Replay Loop pop_ready] - RP --> IQ + P[Producer] --> IH[IntakeHandle] + IH --> IN[Intake Stream] + + IN --> G{depends_on empty?} + G -->|yes| EX[Executor] + G -->|no| DQ[Deferred Queue] + + EX -->|Executed| CM[CommitSink] + EX -->|Deferred| DQ + EX -->|Fatal| ER[Runtime Error] + + CM --> DR[Dependency Ready] + DR --> DS[DeferredStore] + DQ --> DS + DS --> RP[Replay pop_ready] + RP --> IN + + IH --> EP[EpochTracker] + EX --> EP + CM --> EP ``` -## Event Model - -`PipelineEvent` is the runtime contract between components: - -- `Runnable(Message

)` -- `Deferred(Message

)` -- `Executed { epoch, result }` -- `Fatal { msg_id, error }` - -This keeps routing explicit and avoids stage-specific output structs. - -## Component Model - -Runtime uses object-safe component interfaces: - -- `Scheduler` -- `Executor` -- `DeferredStore

` -- `CommitSink` -- `RuntimeMetrics` -- `PipelineBus` - -They are assembled through `RuntimeComponents`. - -## Public API +## 4. Runtime Layers -- `IngestRuntime::new(config, components)` -- `IngestRuntime::intake_handle() -> IntakeHandle

` -- `IngestRuntime::notify_dependency_ready(event)` -- `IngestRuntime::run_forever()` - -### Ingestion - -External code pushes messages through: - -- `IntakeHandle::submit(message).await` - -### Dependency Ready Notification +```mermaid +flowchart TB + subgraph API["API Layer"] + A1[IntakeHandle] + A2[notify_dependency_ready] + A3[new/seal/wait epoch] + end + + subgraph CORE["Runtime Core"] + C1[Kernel Event Loop] + C2[Flow Control] + C3[Worker Pool] + end + + subgraph SPI["Extension SPI"] + S1[Executor] + S2[DeferredStore] + S3[CommitSink] + S4[RuntimeMetrics] + S5[PipelineBus] + end + + API --> CORE + CORE --> SPI +``` -External dependency resolution notifies runtime through: +## 5. Lifecycle Model -- `notify_dependency_ready(DependencyReadyEvent)` +### 5.1 Message Lifecycle -This unblocks replay via `DeferredStore`. +```mermaid +stateDiagram-v2 + [*] --> Ingested + Ingested --> Runnable: no dependency + Ingested --> Deferred: dependency unresolved + Deferred --> Runnable: dependency ready + Runnable --> Executed + Runnable --> Deferred: executor defers + Runnable --> Fatal + Executed --> Committed + Committed --> [*] + Fatal --> [*] +``` -## Runtime Flow +### 5.2 Epoch Lifecycle -1. Producers push `Message

` into intake channel. -2. Kernel applies flow control (`max_in_flight`) and schedules each message. -3. `Scheduler` emits `PipelineEvent` values. -4. `Runnable` messages are executed immediately by `Executor`. -5. `Executed` events are grouped by epoch (within one message execution) and sent to `CommitSink`. -6. `Deferred` events go to deferred channel and are persisted by deferred sink. -7. Kernel replay loop polls `DeferredStore::pop_ready(limit)` and reinjects ready messages. +```mermaid +stateDiagram-v2 + [*] --> Open + Open --> Open: submit / internal derived + Open --> Sealed: seal_epoch + Sealed --> Completed: committed >= submitted + Completed --> [*] +``` -## Backpressure and Memory +## 6. Processing Sequence -- Both channels are bounded by `kernel_channel_capacity`. -- No extra ingress queue beyond bus channels. -- Deferred replay is pulled with `deferred_poll_limit`, limiting replay burst. +```mermaid +sequenceDiagram + participant U as Upstream + participant H as IntakeHandle + participant K as Kernel + participant X as Executor + participant C as CommitSink + participant S as DeferredStore + participant E as EpochTracker + + U->>H: new_epoch + U->>H: submit(messages) + H->>E: submitted += n + H->>K: enqueue + U->>H: seal_epoch + + K->>X: execute runnable + X-->>K: Executed / Deferred / Fatal + K->>S: persist deferred + K->>C: commit executed + C-->>K: commit ok + K->>S: notify_ready(message/resource) + K->>E: committed += m + S-->>K: replay ready messages + + U->>H: wait_epoch + H-->>U: return when completed +``` -## RuntimeConfig +## 7. Extension Contracts (SPI) -- `deferred_poll_limit`: max replay load per poll cycle. -- `kernel_channel_capacity`: channel capacity for intake/deferred. -- `max_in_flight`: max concurrent message workers inside kernel. -- `idle_sleep_ms`: replay loop sleep when no ready deferred message exists. +- `Executor`: business execution and event emission +- `DeferredStore

`: deferred storage, readiness evaluation, and replay source +- `CommitSink`: commit boundary and visibility control +- `RuntimeMetrics`: metrics collection +- `PipelineBus`: channel model abstraction -## Correctness Assumptions +## 8. Semantic Guarantees -- At-least-once processing; consumers should be idempotent. -- `DeferredStore` handles dedup/retry/readiness semantics. -- `CommitSink` defines commit visibility and idempotency policy. +- Processing semantics: at-least-once +- Idempotency: `Executor` and `CommitSink` should provide idempotency as needed +- Epoch completion: after `seal_epoch`, completion is `committed >= submitted` +- Internally derived deferred messages are counted into the same epoch's `submitted` -## Source Layout +## 9. Integration Checklist -- `src/runtime/mod.rs`: runtime assembly, handles, lifecycle. -- `src/runtime/kernel.rs`: event routing kernel + bus + worker loops. -- `src/traits.rs`: component contracts. -- `src/types.rs`: message/event/domain-neutral runtime types. -- `src/error.rs`: runtime error types. +1. Assemble `RuntimeComponents` (`Executor` / `DeferredStore` / `CommitSink` / `RuntimeMetrics` / `PipelineBus`). +2. Start `IngestRuntime::run_forever()`. +3. Submit batches through `IntakeHandle` and wait with epoch APIs. diff --git a/crates/ingest/src/lib.rs b/crates/ingest/src/lib.rs index e4d0fc9..f6625ed 100644 --- a/crates/ingest/src/lib.rs +++ b/crates/ingest/src/lib.rs @@ -5,11 +5,10 @@ pub mod types; pub use error::IngestError; pub use runtime::{ - DynCommitSink, DynDeferredStore, DynExecutor, DynPipelineBus, DynRuntimeMetrics, DynScheduler, - FlowControlConfig, IngestRuntime, IntakeHandle, PipelineBus, RuntimeComponents, - TokioPipelineBus, + DynCommitSink, DynDeferredStore, DynExecutor, DynPipelineBus, DynRuntimeMetrics, + FlowControlConfig, IngestRuntime, IntakeHandle, PipelineBus, RuntimeComponents, TokioPipelineBus, }; -pub use traits::{CommitSink, DeferredStore, Executor, RuntimeMetrics, Scheduler}; +pub use traits::{CommitSink, DeferredStore, Executor, RuntimeMetrics}; pub use types::{ DependencyKind, DependencyReadyEvent, DependencyRef, ExecutionResult, ExecutionStatus, Message, MessageGroup, MessageId, OperationBatch, PipelineEvent, ResourceKey, RuntimeConfig, Topic, diff --git a/crates/ingest/src/runtime/kernel.rs b/crates/ingest/src/runtime/kernel.rs index 760f991..e220459 100644 --- a/crates/ingest/src/runtime/kernel.rs +++ b/crates/ingest/src/runtime/kernel.rs @@ -7,10 +7,8 @@ use tracing::warn; use crate::error::IngestError; use crate::runtime::flow_control::{FlowControlConfig, FlowController}; -use crate::runtime::{ - DynCommitSink, DynDeferredStore, DynExecutor, DynRuntimeMetrics, DynScheduler, -}; -use crate::types::{ExecutionResult, Message, PipelineEvent}; +use crate::runtime::{DynCommitSink, DynDeferredStore, DynExecutor, DynRuntimeMetrics}; +use crate::types::{DependencyRef, ExecutionResult, Message, PipelineEvent}; pub struct BusChannels

where @@ -80,13 +78,37 @@ impl KernelRunStats { pub async fn run_pipeline( channels: BusChannels

, - scheduler: DynScheduler, executor: DynExecutor, deferred_store: DynDeferredStore

, commit_sink: DynCommitSink, metrics: DynRuntimeMetrics, config: &FlowControlConfig, ) -> Result +where + P: Clone + Send + Sync + 'static, + Op: Send + Sync + 'static, +{ + run_pipeline_with_epoch_tracker( + channels, + executor, + deferred_store, + commit_sink, + metrics, + None, + config, + ) + .await +} + +pub(crate) async fn run_pipeline_with_epoch_tracker( + channels: BusChannels

, + executor: DynExecutor, + deferred_store: DynDeferredStore

, + commit_sink: DynCommitSink, + metrics: DynRuntimeMetrics, + epoch_tracker: Option>, + config: &FlowControlConfig, +) -> Result where P: Clone + Send + Sync + 'static, Op: Send + Sync + 'static, @@ -150,20 +172,22 @@ where for msg in ready { let permit = flow.acquire_in_flight().await?; - let scheduler_cloned = Arc::clone(&scheduler); let executor_cloned = Arc::clone(&executor); let commit_sink_cloned = Arc::clone(&commit_sink); + let deferred_store_cloned = Arc::clone(&deferred_store); let deferred_tx_cloned = deferred_tx.clone(); let metrics_cloned = Arc::clone(&metrics); + let epoch_tracker_cloned = epoch_tracker.clone(); workers.spawn(async move { let _permit = permit; process_message( msg, - scheduler_cloned, executor_cloned, commit_sink_cloned, + deferred_store_cloned, deferred_tx_cloned, metrics_cloned, + epoch_tracker_cloned, ) .await }); @@ -174,20 +198,22 @@ where match maybe_msg { Some(msg) => { let permit = flow.acquire_in_flight().await?; - let scheduler_cloned = Arc::clone(&scheduler); let executor_cloned = Arc::clone(&executor); let commit_sink_cloned = Arc::clone(&commit_sink); + let deferred_store_cloned = Arc::clone(&deferred_store); let deferred_tx_cloned = deferred_tx.clone(); let metrics_cloned = Arc::clone(&metrics); + let epoch_tracker_cloned = epoch_tracker.clone(); workers.spawn(async move { let _permit = permit; process_message( msg, - scheduler_cloned, executor_cloned, commit_sink_cloned, + deferred_store_cloned, deferred_tx_cloned, metrics_cloned, + epoch_tracker_cloned, ) .await }); @@ -217,50 +243,36 @@ where async fn process_message( message: Message

, - scheduler: DynScheduler, executor: DynExecutor, commit_sink: DynCommitSink, + deferred_store: DynDeferredStore

, deferred_tx: mpsc::Sender>, metrics: DynRuntimeMetrics, + epoch_tracker: Option>, ) -> Result where P: Clone + Send + Sync + 'static, Op: Send + Sync + 'static, { let mut stats = MessageRunStats::default(); - - let scheduler_cloned = Arc::clone(&scheduler); - let schedule_events = - tokio::task::spawn_blocking(move || scheduler_cloned.schedule(vec![message])) - .await - .map_err(|e| IngestError::Execution(format!("schedule join failure: {e}")))??; - - for event in schedule_events { - match event { - PipelineEvent::Runnable(msg) => { - stats.runnable_messages += 1; - let msg_stats = execute_runnable( - msg, - Arc::clone(&executor), - Arc::clone(&commit_sink), - deferred_tx.clone(), - ) - .await?; - stats.deferred_from_execute += msg_stats.deferred_from_execute; - stats.committed_batches += msg_stats.committed_batches; - } - PipelineEvent::Deferred(msg) => { - stats.deferred_from_schedule += 1; - deferred_tx.send(msg).await.map_err(|_| { - IngestError::Execution("kernel deferred channel closed".to_string()) - })?; - } - _ => { - return Err(IngestError::Execution( - "scheduler emitted invalid event".to_string(), - )); - } - } + if !message.depends_on.is_empty() { + stats.deferred_from_schedule += 1; + deferred_tx.send(message).await.map_err(|_| { + IngestError::Execution("kernel deferred channel closed".to_string()) + })?; + } else { + stats.runnable_messages += 1; + let msg_stats = execute_runnable( + message, + Arc::clone(&executor), + Arc::clone(&commit_sink), + Arc::clone(&deferred_store), + deferred_tx.clone(), + epoch_tracker.clone(), + ) + .await?; + stats.deferred_from_execute += msg_stats.deferred_from_execute; + stats.committed_batches += msg_stats.committed_batches; } metrics.observe_throughput("kernel_message", 1); @@ -277,7 +289,9 @@ async fn execute_runnable( message: Message

, executor: DynExecutor, commit_sink: DynCommitSink, + deferred_store: DynDeferredStore

, deferred_tx: mpsc::Sender>, + epoch_tracker: Option>, ) -> Result where P: Clone + Send + Sync + 'static, @@ -297,6 +311,9 @@ where by_epoch.entry(epoch).or_default().push(result); } PipelineEvent::Deferred(msg) => { + if let Some(tracker) = epoch_tracker.as_ref() { + tracker.record_internal_submit(msg.epoch)?; + } stats.deferred_from_execute += 1; deferred_tx.send(msg).await.map_err(|_| { IngestError::Execution("kernel deferred channel closed".to_string()) @@ -318,11 +335,29 @@ where } for (epoch, results) in by_epoch { + let completed_results: Vec<(String, Vec)> = results + .iter() + .map(|r| (r.msg_id.clone(), r.next_dependencies.clone())) + .collect(); let sink = Arc::clone(&commit_sink); let committed = tokio::task::spawn_blocking(move || sink.commit_epoch(epoch, results)) .await .map_err(|e| IngestError::Execution(format!("commit join failure: {e}")))??; stats.committed_batches += committed; + + for (msg_id, next_dependencies) in completed_results { + notify_dependency_ready( + Arc::clone(&deferred_store), + DependencyRef::message(msg_id), + ) + .await?; + for dep in next_dependencies { + notify_dependency_ready(Arc::clone(&deferred_store), dep).await?; + } + if let Some(tracker) = epoch_tracker.as_ref() { + tracker.mark_committed(epoch)?; + } + } } Ok(stats) @@ -351,3 +386,17 @@ where .await .map_err(|e| IngestError::Execution(format!("deferred replay join failure: {e}")))? } + +async fn notify_dependency_ready

( + deferred_store: DynDeferredStore

, + dependency: DependencyRef, +) -> Result<(), IngestError> +where + P: Clone + Send + Sync + 'static, +{ + tokio::task::spawn_blocking(move || { + deferred_store.notify_ready(crate::types::DependencyReadyEvent { dependency }) + }) + .await + .map_err(|e| IngestError::Execution(format!("deferred notify join failure: {e}")))? +} diff --git a/crates/ingest/src/runtime/mod.rs b/crates/ingest/src/runtime/mod.rs index 0296792..ba07ae8 100644 --- a/crates/ingest/src/runtime/mod.rs +++ b/crates/ingest/src/runtime/mod.rs @@ -1,9 +1,10 @@ use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; -use tokio::sync::{Mutex, mpsc}; +use tokio::sync::{Mutex, Notify, mpsc}; use crate::error::IngestError; -use crate::traits::{CommitSink, DeferredStore, Executor, RuntimeMetrics, Scheduler}; +use crate::traits::{CommitSink, DeferredStore, Executor, RuntimeMetrics}; use crate::types::{DependencyReadyEvent, Message, RuntimeConfig}; pub mod flow_control; @@ -12,19 +13,163 @@ pub mod kernel; pub use flow_control::FlowControlConfig; pub use kernel::{PipelineBus, TokioPipelineBus}; -pub type DynScheduler = Arc + Send + Sync>; pub type DynExecutor = Arc + Send + Sync>; pub type DynDeferredStore

= Arc + Send + Sync>; pub type DynCommitSink = Arc + Send + Sync>; pub type DynPipelineBus = Arc + Send + Sync>; pub type DynRuntimeMetrics = Arc; +#[derive(Default)] +pub(super) struct EpochTracker { + inner: std::sync::Mutex>, +} + +struct EpochState { + submitted: usize, + committed: usize, + sealed: bool, + notify: Arc, +} + +impl EpochTracker { + fn record_submit(&self, epoch: u64) -> Result<(), IngestError> { + let mut guard = self + .inner + .lock() + .map_err(|_| IngestError::Execution("epoch tracker poisoned".to_string()))?; + let state = guard.entry(epoch).or_insert_with(|| EpochState { + submitted: 0, + committed: 0, + sealed: false, + notify: Arc::new(Notify::new()), + }); + if state.sealed { + return Err(IngestError::Execution(format!( + "epoch {epoch} is sealed; no more submissions allowed" + ))); + } + state.submitted += 1; + Ok(()) + } + + pub(super) fn record_internal_submit(&self, epoch: u64) -> Result<(), IngestError> { + let mut guard = self + .inner + .lock() + .map_err(|_| IngestError::Execution("epoch tracker poisoned".to_string()))?; + let state = guard.entry(epoch).or_insert_with(|| EpochState { + submitted: 0, + committed: 0, + sealed: false, + notify: Arc::new(Notify::new()), + }); + state.submitted += 1; + Ok(()) + } + + fn seal(&self, epoch: u64) -> Result<(), IngestError> { + let notify = { + let mut guard = self + .inner + .lock() + .map_err(|_| IngestError::Execution("epoch tracker poisoned".to_string()))?; + let state = guard.entry(epoch).or_insert_with(|| EpochState { + submitted: 0, + committed: 0, + sealed: false, + notify: Arc::new(Notify::new()), + }); + state.sealed = true; + if state.committed >= state.submitted { + Some(Arc::clone(&state.notify)) + } else { + None + } + }; + if let Some(n) = notify { + n.notify_waiters(); + } + Ok(()) + } + + fn rollback_submit(&self, epoch: u64) -> Result<(), IngestError> { + let mut guard = self + .inner + .lock() + .map_err(|_| IngestError::Execution("epoch tracker poisoned".to_string()))?; + let mut should_remove = false; + if let Some(state) = guard.get_mut(&epoch) { + if state.submitted > state.committed { + state.submitted -= 1; + } + if !state.sealed && state.submitted == 0 && state.committed == 0 { + should_remove = true; + } + } + if should_remove { + guard.remove(&epoch); + } + Ok(()) + } + + pub(super) fn mark_committed(&self, epoch: u64) -> Result<(), IngestError> { + let notify = { + let mut guard = self + .inner + .lock() + .map_err(|_| IngestError::Execution("epoch tracker poisoned".to_string()))?; + let state = guard.entry(epoch).or_insert_with(|| EpochState { + submitted: 0, + committed: 0, + sealed: false, + notify: Arc::new(Notify::new()), + }); + state.committed += 1; + if state.sealed && state.committed >= state.submitted { + Some(Arc::clone(&state.notify)) + } else { + None + } + }; + if let Some(n) = notify { + n.notify_waiters(); + } + Ok(()) + } + + async fn wait_epoch(&self, epoch: u64) -> Result<(), IngestError> { + loop { + let maybe_notify = { + let mut guard = self + .inner + .lock() + .map_err(|_| IngestError::Execution("epoch tracker poisoned".to_string()))?; + let state = guard.entry(epoch).or_insert_with(|| EpochState { + submitted: 0, + committed: 0, + sealed: false, + notify: Arc::new(Notify::new()), + }); + if state.sealed && state.committed >= state.submitted { + guard.remove(&epoch); + None + } else { + Some(Arc::clone(&state.notify)) + } + }; + match maybe_notify { + None => return Ok(()), + Some(n) => n.notified().await, + } + } + } +} + pub struct RuntimeComponents where P: Clone + Send + Sync + 'static, Op: Send + Sync + 'static, { - pub scheduler: DynScheduler, pub executor: DynExecutor, pub deferred_store: DynDeferredStore

, pub commit_sink: DynCommitSink, @@ -38,14 +183,12 @@ where Op: Send + Sync + 'static, { pub fn with_tokio_bus( - scheduler: DynScheduler, executor: DynExecutor, deferred_store: DynDeferredStore

, commit_sink: DynCommitSink, metrics: DynRuntimeMetrics, ) -> Self { Self { - scheduler, executor, deferred_store, commit_sink, @@ -58,6 +201,8 @@ where #[derive(Clone)] pub struct IntakeHandle

{ tx: mpsc::Sender>, + next_epoch: Arc, + epoch_tracker: Arc, } impl

IntakeHandle

@@ -65,21 +210,42 @@ where P: Clone + Send + Sync + 'static, { pub fn try_submit(&self, message: Message

) -> Result<(), IngestError> { - self.tx.try_send(message).map_err(|err| match err { + let epoch = message.epoch; + self.epoch_tracker.record_submit(epoch)?; + self.tx.try_send(message).map_err(|err| { + let _ = self.epoch_tracker.rollback_submit(epoch); + match err { tokio::sync::mpsc::error::TrySendError::Full(_) => { IngestError::Execution("ingest intake queue full".to_string()) } tokio::sync::mpsc::error::TrySendError::Closed(_) => { IngestError::Execution("ingest intake handle closed".to_string()) } - }) + }}) } pub async fn submit(&self, message: Message

) -> Result<(), IngestError> { + let epoch = message.epoch; + self.epoch_tracker.record_submit(epoch)?; self.tx .send(message) .await - .map_err(|_| IngestError::Execution("ingest intake handle closed".to_string())) + .map_err(|_| { + let _ = self.epoch_tracker.rollback_submit(epoch); + IngestError::Execution("ingest intake handle closed".to_string()) + }) + } + + pub fn new_epoch(&self) -> u64 { + self.next_epoch.fetch_add(1, Ordering::Relaxed) + } + + pub fn seal_epoch(&self, epoch: u64) -> Result<(), IngestError> { + self.epoch_tracker.seal(epoch) + } + + pub async fn wait_epoch(&self, epoch: u64) -> Result<(), IngestError> { + self.epoch_tracker.wait_epoch(epoch).await } } @@ -88,7 +254,6 @@ where P: Clone + Send + Sync + 'static, Op: Send + Sync + 'static, { - pub scheduler: DynScheduler, pub executor: DynExecutor, pub deferred_store: DynDeferredStore

, pub commit_sink: DynCommitSink, @@ -96,6 +261,8 @@ where pub metrics: DynRuntimeMetrics, pub flow_control: FlowControlConfig, intake_tx: mpsc::Sender>, + next_epoch: Arc, + epoch_tracker: Arc, pipeline_channels: Mutex>>, _marker: std::marker::PhantomData, } @@ -109,9 +276,9 @@ where let flow_control = FlowControlConfig::from(&config); let channels = components.bus.open_channels(flow_control.channel_capacity); let intake_tx = channels.intake_tx.clone(); + let epoch_tracker = Arc::new(EpochTracker::default()); Self { - scheduler: components.scheduler, executor: components.executor, deferred_store: components.deferred_store, commit_sink: components.commit_sink, @@ -119,6 +286,8 @@ where metrics: components.metrics, flow_control, intake_tx, + next_epoch: Arc::new(AtomicU64::new(1)), + epoch_tracker, pipeline_channels: Mutex::new(Some(channels)), _marker: std::marker::PhantomData, } @@ -127,6 +296,8 @@ where pub fn intake_handle(&self) -> IntakeHandle

{ IntakeHandle { tx: self.intake_tx.clone(), + next_epoch: Arc::clone(&self.next_epoch), + epoch_tracker: Arc::clone(&self.epoch_tracker), } } @@ -149,19 +320,19 @@ where .ok_or_else(|| IngestError::Execution("runtime already started".to_string()))?; let flow_control = self.flow_control.clone(); - let scheduler = Arc::clone(&self.scheduler); let executor = Arc::clone(&self.executor); let deferred_store = Arc::clone(&self.deferred_store); let commit_sink = Arc::clone(&self.commit_sink); let metrics = Arc::clone(&self.metrics); + let epoch_tracker = Arc::clone(&self.epoch_tracker); let kernel_task = tokio::spawn(async move { - kernel::run_pipeline( + kernel::run_pipeline_with_epoch_tracker( channels, - scheduler, executor, deferred_store, commit_sink, metrics, + Some(epoch_tracker.clone()), &flow_control, ) .await diff --git a/crates/ingest/src/traits.rs b/crates/ingest/src/traits.rs index 4388a84..9921d7e 100644 --- a/crates/ingest/src/traits.rs +++ b/crates/ingest/src/traits.rs @@ -1,11 +1,6 @@ use crate::error::IngestError; use crate::types::{DependencyReadyEvent, ExecutionResult, Message, PipelineEvent}; -pub trait Scheduler: Send + Sync { - fn schedule(&self, messages: Vec>) - -> Result>, IngestError>; -} - pub trait Executor: Send + Sync { fn execute(&self, message: Message

) -> Result>, IngestError>; } diff --git a/crates/ingest/tests/runtime_kernel.rs b/crates/ingest/tests/runtime_kernel.rs index c62988f..3daca48 100644 --- a/crates/ingest/tests/runtime_kernel.rs +++ b/crates/ingest/tests/runtime_kernel.rs @@ -4,8 +4,7 @@ use std::sync::{Arc, Mutex}; use naviscope_ingest::runtime::kernel; use naviscope_ingest::{ CommitSink, DeferredStore, Executor, FlowControlConfig, IngestError, IngestRuntime, - PipelineBus, PipelineEvent, RuntimeComponents, RuntimeConfig, RuntimeMetrics, Scheduler, - TokioPipelineBus, + PipelineBus, PipelineEvent, RuntimeComponents, RuntimeConfig, RuntimeMetrics, TokioPipelineBus, }; use naviscope_ingest::{ DependencyReadyEvent, DependencyRef, ExecutionResult, ExecutionStatus, Message, @@ -24,25 +23,6 @@ fn message(id: &str, epoch: u64, payload: u8) -> Message { } } -struct TestScheduler; -impl Scheduler for TestScheduler { - fn schedule( - &self, - messages: Vec>, - ) -> Result>, IngestError> { - Ok(messages - .into_iter() - .map(|m| { - if m.payload == 0 { - PipelineEvent::Deferred(m) - } else { - PipelineEvent::Runnable(m) - } - }) - .collect()) - } -} - struct TestExecutor; impl Executor for TestExecutor { fn execute(&self, message: Message) -> Result>, IngestError> { @@ -124,32 +104,15 @@ impl RuntimeMetrics for TestMetrics { fn observe_replay_result(&self, _ok: bool) {} } -struct InvalidEventScheduler; -impl Scheduler for InvalidEventScheduler { - fn schedule( - &self, - messages: Vec>, - ) -> Result>, IngestError> { - let m = messages - .into_iter() - .next() - .expect("test should provide one message"); - Ok(vec![PipelineEvent::Executed { - epoch: m.epoch, - result: ExecutionResult { - msg_id: m.msg_id, - status: ExecutionStatus::Done, - operations: vec!["op".to_string()], - next_dependencies: vec![], - error: None, - }, - }]) +struct InvalidEventExecutor; +impl Executor for InvalidEventExecutor { + fn execute(&self, message: Message) -> Result>, IngestError> { + Ok(vec![PipelineEvent::Runnable(message)]) } } #[tokio::test] async fn kernel_commits_runnable_messages() { - let scheduler = Arc::new(TestScheduler); let executor = Arc::new(TestExecutor); let store = Arc::new(TestDeferredStore::default()); let sink = Arc::new(TestCommitSink::default()); @@ -165,7 +128,6 @@ async fn kernel_commits_runnable_messages() { let stats = kernel::run_pipeline( channels, - scheduler, executor, store, sink.clone(), @@ -190,7 +152,6 @@ async fn kernel_commits_runnable_messages() { #[tokio::test] async fn kernel_persists_deferred_from_both_paths() { - let scheduler = Arc::new(TestScheduler); let executor = Arc::new(TestExecutor); let store = Arc::new(TestDeferredStore::default()); let sink = Arc::new(TestCommitSink::default()); @@ -199,7 +160,11 @@ async fn kernel_persists_deferred_from_both_paths() { let channels = >::open_channels(&bus, 8); let tx = channels.intake_tx.clone(); - tx.send(message("m_sched_deferred", 1, 0)) + let mut sched_deferred = message("m_sched_deferred", 1, 1); + sched_deferred + .depends_on + .push(DependencyRef::message("dep_not_ready")); + tx.send(sched_deferred) .await .expect("send should work"); tx.send(message("m_exec_deferred", 1, 2)) @@ -209,7 +174,6 @@ async fn kernel_persists_deferred_from_both_paths() { let stats = kernel::run_pipeline( channels, - scheduler, executor, store.clone(), sink, @@ -238,7 +202,6 @@ async fn runtime_notify_dependency_ready_delegates_to_store() { let runtime = IngestRuntime::new( RuntimeConfig::default(), RuntimeComponents::with_tokio_bus( - Arc::new(TestScheduler), Arc::new(TestExecutor), store.clone(), Arc::new(TestCommitSink::default()), @@ -261,7 +224,6 @@ async fn runtime_notify_dependency_ready_delegates_to_store() { #[tokio::test] async fn kernel_flushes_partial_batches_on_channel_close() { - let scheduler = Arc::new(TestScheduler); let executor = Arc::new(TestExecutor); let store = Arc::new(TestDeferredStore::default()); let sink = Arc::new(TestCommitSink::default()); @@ -277,7 +239,6 @@ async fn kernel_flushes_partial_batches_on_channel_close() { let stats = kernel::run_pipeline( channels, - scheduler, executor, store, sink.clone(), @@ -302,7 +263,6 @@ async fn kernel_flushes_partial_batches_on_channel_close() { #[tokio::test] async fn kernel_errors_on_executor_fatal_event() { - let scheduler = Arc::new(TestScheduler); let executor = Arc::new(TestExecutor); let store = Arc::new(TestDeferredStore::default()); let sink = Arc::new(TestCommitSink::default()); @@ -318,7 +278,6 @@ async fn kernel_errors_on_executor_fatal_event() { let err = kernel::run_pipeline( channels, - scheduler, executor, store, sink, @@ -339,9 +298,8 @@ async fn kernel_errors_on_executor_fatal_event() { } #[tokio::test] -async fn kernel_errors_on_invalid_scheduler_event() { - let scheduler = Arc::new(InvalidEventScheduler); - let executor = Arc::new(TestExecutor); +async fn kernel_errors_on_invalid_executor_event() { + let executor = Arc::new(InvalidEventExecutor); let store = Arc::new(TestDeferredStore::default()); let sink = Arc::new(TestCommitSink::default()); let metrics = Arc::new(TestMetrics); @@ -349,14 +307,13 @@ async fn kernel_errors_on_invalid_scheduler_event() { let channels = >::open_channels(&bus, 8); let tx = channels.intake_tx.clone(); - tx.send(message("m_bad_sched", 1, 1)) + tx.send(message("m_bad_exec", 1, 1)) .await .expect("send should work"); drop(tx); let err = kernel::run_pipeline( channels, - scheduler, executor, store, sink, @@ -369,7 +326,7 @@ async fn kernel_errors_on_invalid_scheduler_event() { }, ) .await - .expect_err("invalid scheduler event should fail pipeline"); + .expect_err("invalid executor event should fail pipeline"); - assert!(err.to_string().contains("scheduler emitted invalid event")); + assert!(err.to_string().contains("executor emitted invalid event")); } diff --git a/crates/lang-java/src/resolve/lang.rs b/crates/lang-java/src/resolve/lang.rs index 37e89d5..01ef1f2 100644 --- a/crates/lang-java/src/resolve/lang.rs +++ b/crates/lang-java/src/resolve/lang.rs @@ -4,271 +4,477 @@ use crate::inference::{TypeProvider, TypeResolutionContext}; use crate::model::JavaIndexMetadata; use crate::resolve::context::ResolutionContext; use naviscope_api::models::graph::{EdgeType, GraphEdge, NodeKind}; -use naviscope_api::models::symbol::{NodeId, SymbolResolution, TypeRef}; +use naviscope_api::models::symbol::{NodeId, SymbolResolution}; use naviscope_plugin::{ - GraphOp, IndexNode, ParsedContent, ParsedFile, ProjectContext, ResolvedUnit, SourceIndexCap, + GlobalParseResult, GraphOp, IndexNode, IndexRelation, ParsedContent, ParsedFile, ProjectContext, + ResolvedUnit, SourceAnalyzeArtifact, SourceCollectArtifact, SourceIndexCap, }; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; +struct CollectOutput { + unit: ResolvedUnit, + container_id: NodeId, +} + +struct AnalyzeOutput { + unit: ResolvedUnit, + res_ctx: TypeResolutionContext, + bound_relations: Vec, + deferred_relations: Vec, +} + +struct BoundRelation { + source_id: NodeId, + target_id: NodeId, + edge: GraphEdge, +} + +struct DeferredRelation { + raw_target: String, +} + +struct JavaCollectArtifact { + parse_result: GlobalParseResult, + collected: CollectOutput, + type_symbols: Vec, + method_symbols: Vec, + provided_dependency_symbols: Vec, + required_dependency_symbols: Vec, +} + +struct JavaAnalyzeArtifact { + parse_result: GlobalParseResult, + analyzed: AnalyzeOutput, +} + +impl SourceCollectArtifact for JavaCollectArtifact { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn into_any(self: Box) -> Box { + self + } + + fn collected_type_symbols(&self) -> &[String] { + &self.type_symbols + } + + fn collected_method_symbols(&self) -> &[String] { + &self.method_symbols + } + + fn provided_dependency_symbols(&self) -> &[String] { + &self.provided_dependency_symbols + } + + fn required_dependency_symbols(&self) -> &[String] { + &self.required_dependency_symbols + } +} + +impl SourceAnalyzeArtifact for JavaAnalyzeArtifact { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn into_any(self: Box) -> Box { + self + } +} + impl SourceIndexCap for JavaPlugin { - fn compile_source( + fn collect_source( &self, file: &ParsedFile, context: &ProjectContext, - ) -> std::result::Result> { - let mut unit = ResolvedUnit::new(); - let dummy_index = naviscope_plugin::EmptyCodeGraph; - let type_provider = HeuristicAdapter; - + ) -> std::result::Result, Box> + { let parse_result_owned; let parse_result = match &file.content { ParsedContent::Language(res) => res, ParsedContent::Unparsed(src) => { - if file.path().extension().map_or(false, |e| e == "java") { + if file.path().extension().is_some_and(|e| e == "java") { parse_result_owned = self.parser.parse_file(src, Some(&file.file.path))?; &parse_result_owned } else { - return Ok(unit); + return Err("Unsupported non-java file in Java collect_source".into()); } } ParsedContent::Lazy => { - if file.path().extension().map_or(false, |e| e == "java") { + if file.path().extension().is_some_and(|e| e == "java") { let src = std::fs::read_to_string(file.path()).map_err(|e| { format!("Failed to read file {}: {}", file.path().display(), e) })?; parse_result_owned = self.parser.parse_file(&src, Some(&file.file.path))?; &parse_result_owned } else { - return Ok(unit); + return Err("Unsupported non-java file in Java collect_source".into()); } } - _ => return Ok(unit), + _ => return Err("Unsupported parsed content in Java collect_source".into()), }; - { - unit.identifiers = parse_result.output.identifiers.clone(); - unit.ops.push(GraphOp::UpdateIdentifiers { - path: Arc::from(file.file.path.as_path()), - identifiers: unit.identifiers.clone(), - }); + let collected = self.collect_pass(file, context, parse_result); + let type_symbols: Vec = parse_result + .output + .nodes + .iter() + .filter(|node| { + matches!( + node.kind, + NodeKind::Class | NodeKind::Interface | NodeKind::Enum | NodeKind::Annotation + ) + }) + .map(|node| node.id.to_string()) + .collect(); + let method_symbols: Vec = parse_result + .output + .nodes + .iter() + .filter(|node| matches!(node.kind, NodeKind::Method | NodeKind::Constructor)) + .map(|node| node.id.to_string()) + .collect(); + let mut provided_dependency_symbols = type_symbols.clone(); + if let Some(pkg) = &parse_result.package_name { + provided_dependency_symbols.push(format!("package:{pkg}")); + } + let mut required_dependency_symbols = Vec::new(); + if let Some(pkg) = &parse_result.package_name { + required_dependency_symbols.push(format!("package:{pkg}")); + } + for import in &parse_result.imports { + if let Some(pkg) = import.strip_suffix(".*") { + required_dependency_symbols.push(format!("package:{pkg}")); + } else { + required_dependency_symbols.push(import.clone()); + } + } - let module_id = context - .find_module_for_path(&file.file.path) - .unwrap_or_else(|| "module::root".to_string()); - - let container_id = if let Some(pkg_name) = &parse_result.package_name { - let package_parts: Vec<_> = pkg_name - .split('.') - .map(|s| { - ( - naviscope_api::models::graph::NodeKind::Package, - s.to_string(), - ) - }) - .collect(); - let package_id = NodeId::Structured(package_parts); - - let package_node = IndexNode { - id: package_id.clone(), - name: pkg_name.to_string(), - kind: NodeKind::Package, - lang: "java".to_string(), - source: naviscope_api::models::graph::NodeSource::Project, - status: naviscope_api::models::graph::ResolutionStatus::Resolved, - location: None, - metadata: Arc::new(JavaIndexMetadata::Package), - }; - - unit.add_node(package_node); + Ok(Box::new(JavaCollectArtifact { + parse_result: parse_result.clone(), + collected, + type_symbols, + method_symbols, + provided_dependency_symbols, + required_dependency_symbols, + })) + } - unit.add_edge( - module_id.clone().into(), - package_id.clone(), - GraphEdge::new(EdgeType::Contains), - ); + fn analyze_source( + &self, + collected: Box, + context: &ProjectContext, + ) -> std::result::Result, Box> + { + let collected = collected + .into_any() + .downcast::() + .map_err(|_| "Java analyze_source received incompatible collect artifact")?; + let mut analyzed = self.analyze_pass(collected.collected, &collected.parse_result, context); + self.bind_all_relations(&mut analyzed, &collected.parse_result); - package_id - } else { - module_id.into() + Ok(Box::new(JavaAnalyzeArtifact { + parse_result: collected.parse_result, + analyzed, + })) + } + + fn lower_source( + &self, + analyzed: Box, + _context: &ProjectContext, + ) -> std::result::Result> { + let analyzed = analyzed + .into_any() + .downcast::() + .map_err(|_| "Java lower_source received incompatible analyze artifact")?; + self.lower_pass(analyzed.analyzed, &analyzed.parse_result) + } +} + +impl JavaPlugin { + fn collect_pass( + &self, + file: &ParsedFile, + context: &ProjectContext, + parse_result: &GlobalParseResult, + ) -> CollectOutput { + let mut unit = ResolvedUnit::new(); + unit.identifiers = parse_result.output.identifiers.clone(); + unit.ops.push(GraphOp::UpdateIdentifiers { + path: Arc::from(file.file.path.as_path()), + identifiers: unit.identifiers.clone(), + }); + + let module_id = context + .find_module_for_path(&file.file.path) + .unwrap_or_else(|| "module::root".to_string()); + + let container_id = if let Some(pkg_name) = &parse_result.package_name { + let package_parts: Vec<_> = pkg_name + .split('.') + .map(|s| (NodeKind::Package, s.to_string())) + .collect(); + let package_id = NodeId::Structured(package_parts); + + let package_node = IndexNode { + id: package_id.clone(), + name: pkg_name.to_string(), + kind: NodeKind::Package, + lang: "java".to_string(), + source: naviscope_api::models::graph::NodeSource::Project, + status: naviscope_api::models::graph::ResolutionStatus::Resolved, + location: None, + metadata: Arc::new(JavaIndexMetadata::Package), }; - let mut known_types = std::collections::HashSet::::new(); - let mut local_type_map = std::collections::HashMap::::new(); + unit.add_node(package_node); + unit.add_edge( + module_id.clone().into(), + package_id.clone(), + GraphEdge::new(EdgeType::Contains), + ); + package_id + } else { + module_id.into() + }; - for node in &parse_result.output.nodes { - if matches!( - node.kind, - NodeKind::Class | NodeKind::Interface | NodeKind::Enum | NodeKind::Annotation - ) { - known_types.insert(node.id.to_string()); - } + CollectOutput { + unit, + container_id, + } + } + + fn analyze_pass( + &self, + collected: CollectOutput, + parse_result: &GlobalParseResult, + context: &ProjectContext, + ) -> AnalyzeOutput { + let mut known_types = HashSet::::new(); + known_types.extend(context.symbol_table.type_symbols.iter().cloned()); + for node in &parse_result.output.nodes { + if matches!( + node.kind, + NodeKind::Class | NodeKind::Interface | NodeKind::Enum | NodeKind::Annotation + ) { + known_types.insert(node.id.to_string()); } + } + let res_ctx = TypeResolutionContext { + package: parse_result.package_name.clone(), + imports: parse_result.imports.clone(), + type_parameters: Vec::new(), + known_fqns: known_types.into_iter().collect(), + }; - let res_ctx = TypeResolutionContext { - package: parse_result.package_name.clone(), - imports: parse_result.imports.clone(), - type_parameters: Vec::new(), - known_fqns: known_types.iter().cloned().collect(), - }; + let type_provider = HeuristicAdapter; + let mut unit = collected.unit; - for node in &parse_result.output.nodes { - let mut node = node.clone(); - - if let Some(java_idx_meta) = - node.metadata.as_any().downcast_ref::() - { - let mut element = java_idx_meta.clone(); - - match &mut element { - JavaIndexMetadata::Method { - return_type, - parameters, - .. - } => { - *return_type = - self.resolve_type_ref(return_type, &type_provider, &res_ctx); - for param in parameters { - param.type_ref = self.resolve_type_ref( - ¶m.type_ref, - &type_provider, - &res_ctx, - ); - if let TypeRef::Id(type_fqn) = ¶m.type_ref { - local_type_map.insert(node.name.clone(), type_fqn.clone()); - } - } - } - JavaIndexMetadata::Field { type_ref, .. } => { - *type_ref = self.resolve_type_ref(type_ref, &type_provider, &res_ctx); - if let TypeRef::Id(type_fqn) = &type_ref { - local_type_map.insert(node.name.clone(), type_fqn.clone()); - } + for node in &parse_result.output.nodes { + let mut node = node.clone(); + + if let Some(java_idx_meta) = node.metadata.as_any().downcast_ref::() { + let mut element = java_idx_meta.clone(); + match &mut element { + JavaIndexMetadata::Method { + return_type, + parameters, + .. + } => { + *return_type = self.resolve_type_ref(return_type, &type_provider, &res_ctx); + for param in parameters { + param.type_ref = self.resolve_type_ref( + ¶m.type_ref, + &type_provider, + &res_ctx, + ); } - _ => {} } - node.metadata = Arc::new(element); + JavaIndexMetadata::Field { type_ref, .. } => { + *type_ref = self.resolve_type_ref(type_ref, &type_provider, &res_ctx); + } + _ => {} } + node.metadata = Arc::new(element); + } - let is_top = matches!( - node.kind, - NodeKind::Class | NodeKind::Interface | NodeKind::Enum | NodeKind::Annotation - ); + let is_top = matches!( + node.kind, + NodeKind::Class | NodeKind::Interface | NodeKind::Enum | NodeKind::Annotation + ); - unit.add_node(node.clone()); - if is_top { - unit.add_edge( - container_id.clone().into(), - node.id.clone(), - GraphEdge::new(EdgeType::Contains), - ); - } + unit.add_node(node.clone()); + if is_top { + unit.add_edge( + collected.container_id.clone().into(), + node.id.clone(), + GraphEdge::new(EdgeType::Contains), + ); } + } - for rel in &parse_result.output.relations { - let mut resolved_target_str = rel.target_id.to_string(); - - if let (Some(tree), Some(source)) = (&parse_result.tree, &parse_result.source) { - if let Some(r) = &rel.range { - let point = tree_sitter::Point::new(r.start_line, r.start_col); - if let Some(node) = tree - .root_node() - .named_descendant_for_point_range(point, point) - { - let context = ResolutionContext::new_with_unit( - node, - rel.target_id.to_string(), - &dummy_index, - Some(&unit), - source, - tree, - &self.parser, - ); + AnalyzeOutput { + unit, + res_ctx, + bound_relations: Vec::new(), + deferred_relations: Vec::new(), + } + } - if let Some(SymbolResolution::Precise(fqn, _)) = - self.resolve_symbol_internal(&context) - { - resolved_target_str = fqn; - } else { - // Fallback - if !resolved_target_str.contains('.') { - if let Some(res) = type_provider - .resolve_type_name(&resolved_target_str, &res_ctx) - { - resolved_target_str = res; - } - } - } - } + fn bind_all_relations( + &self, + analyzed: &mut AnalyzeOutput, + parse_result: &GlobalParseResult, + ) { + for rel in &parse_result.output.relations { + self.bind_relation(rel, parse_result, analyzed); + } + } + + fn lower_pass( + &self, + mut analyzed: AnalyzeOutput, + _parse_result: &GlobalParseResult, + ) -> std::result::Result> { + for bound in analyzed.bound_relations.drain(..) { + analyzed + .unit + .add_edge(bound.source_id, bound.target_id, bound.edge); + } + + for deferred in analyzed.deferred_relations.drain(..) { + analyzed.unit.deferred_symbols.push(naviscope_plugin::DeferredSymbol { + target: deferred.raw_target, + }); + } + + Ok(analyzed.unit) + } + + fn bind_relation( + &self, + rel: &IndexRelation, + parse_result: &GlobalParseResult, + analyzed: &mut AnalyzeOutput, + ) { + let dummy_index = naviscope_plugin::EmptyCodeGraph; + let type_provider = HeuristicAdapter; + + let original_target = rel.target_id.to_string(); + let mut resolved_target = original_target.clone(); + let mut precise_bound = false; + + if let (Some(tree), Some(source), Some(r)) = (&parse_result.tree, &parse_result.source, &rel.range) + { + let point = tree_sitter::Point::new(r.start_line, r.start_col); + if let Some(node) = tree + .root_node() + .named_descendant_for_point_range(point, point) + { + let context = ResolutionContext::new_with_unit( + node, + original_target.clone(), + &dummy_index, + Some(&analyzed.unit), + source, + tree, + &self.parser, + ); + + if let Some(SymbolResolution::Precise(fqn, _)) = self.resolve_symbol_internal(&context) { + resolved_target = fqn; + precise_bound = true; + } else if !resolved_target.contains('.') { + if let Some(res) = + type_provider.resolve_type_name(&resolved_target, &analyzed.res_ctx) + { + resolved_target = res; } } + } + } - let edge = GraphEdge::new(rel.edge_type.clone()); + if !precise_bound && !matches!(rel.target_id, NodeId::Structured(_)) { + analyzed.deferred_relations.push(DeferredRelation { + raw_target: original_target.clone(), + }); + } - if resolved_target_str == rel.target_id.to_string() - && matches!(rel.target_id, NodeId::Structured(_)) - { - unit.add_edge(rel.source_id.clone(), rel.target_id.clone(), edge); - continue; - } + let target_id = if resolved_target == original_target && matches!(rel.target_id, NodeId::Structured(_)) + { + rel.target_id.clone() + } else { + Self::build_target_node_id( + &resolved_target, + &rel.edge_type, + &analyzed.unit.nodes, + ) + }; - let segments: Vec<&str> = resolved_target_str - .split(|c| c == '.' || c == '#') - .collect(); - let mut structured_parts: Vec<(NodeKind, String)> = Vec::new(); - - for (i, part) in segments.iter().enumerate() { - let mut found_kind = NodeKind::Package; - let is_last = i == segments.len() - 1; - - let candidates = [ - NodeKind::Class, - NodeKind::Interface, - NodeKind::Enum, - NodeKind::Annotation, - NodeKind::Method, - NodeKind::Field, - NodeKind::Constructor, - ]; - - let mut matched = false; - for k in &candidates { - let mut probe_parts = structured_parts.clone(); - probe_parts.push((k.clone(), part.to_string())); - let id = NodeId::Structured(probe_parts); - if unit.nodes.contains_key(&id) { - found_kind = k.clone(); - matched = true; - break; - } - } + analyzed.bound_relations.push(BoundRelation { + source_id: rel.source_id.clone(), + target_id, + edge: GraphEdge::new(rel.edge_type.clone()), + }); + } - if !matched { - if is_last { - if rel.edge_type == EdgeType::Implements - || rel.edge_type == EdgeType::InheritsFrom - || rel.edge_type == EdgeType::TypedAs - || rel.edge_type == EdgeType::DecoratedBy - { - found_kind = NodeKind::Class; - } else if part.chars().next().map_or(false, |c| c.is_uppercase()) { - found_kind = NodeKind::Class; - } - } else if part.chars().next().map_or(false, |c| c.is_uppercase()) { - found_kind = NodeKind::Class; - } else { - found_kind = NodeKind::Package; - } - } + fn build_target_node_id( + target: &str, + edge_type: &EdgeType, + known_nodes: &HashMap, + ) -> NodeId { + let segments: Vec<&str> = target.split(['.', '#']).collect(); + let mut structured_parts: Vec<(NodeKind, String)> = Vec::new(); - structured_parts.push((found_kind, part.to_string())); - } + for (i, part) in segments.iter().enumerate() { + let mut found_kind = NodeKind::Package; + let is_last = i == segments.len() - 1; + let candidates = [ + NodeKind::Class, + NodeKind::Interface, + NodeKind::Enum, + NodeKind::Annotation, + NodeKind::Method, + NodeKind::Field, + NodeKind::Constructor, + ]; - let final_target_id = NodeId::Structured(structured_parts); + let mut matched = false; + for k in &candidates { + let mut probe_parts = structured_parts.clone(); + probe_parts.push((k.clone(), part.to_string())); + let id = NodeId::Structured(probe_parts); + if known_nodes.contains_key(&id) { + found_kind = k.clone(); + matched = true; + break; + } + } - unit.add_edge(rel.source_id.clone(), final_target_id, edge); + if !matched { + if is_last { + if *edge_type == EdgeType::Implements + || *edge_type == EdgeType::InheritsFrom + || *edge_type == EdgeType::TypedAs + || *edge_type == EdgeType::DecoratedBy + { + found_kind = NodeKind::Class; + } else if part.chars().next().is_some_and(|c| c.is_uppercase()) { + found_kind = NodeKind::Class; + } + } else if part.chars().next().is_some_and(|c| c.is_uppercase()) { + found_kind = NodeKind::Class; + } else { + found_kind = NodeKind::Package; + } } + + structured_parts.push((found_kind, part.to_string())); } - Ok(unit) + NodeId::Structured(structured_parts) } } diff --git a/crates/lang-java/tests/common/mod.rs b/crates/lang-java/tests/common/mod.rs index bb5bc33..88082f7 100644 --- a/crates/lang-java/tests/common/mod.rs +++ b/crates/lang-java/tests/common/mod.rs @@ -51,8 +51,10 @@ pub fn setup_java_test_graph( for (pf, content) in all_parsed_files { let tree = ts_parser.parse(&content, None).unwrap(); - // Use JavaResolver to get resolved unit - let unit = resolver.compile_source(&pf, &context).unwrap(); + // Run staged source indexing: collect -> analyze -> lower + let collected = resolver.collect_source(&pf, &context).unwrap(); + let analyzed = resolver.analyze_source(collected, &context).unwrap(); + let unit = resolver.lower_source(analyzed, &context).unwrap(); all_ops.extend(unit.ops); diff --git a/crates/plugin/src/cap/indexing.rs b/crates/plugin/src/cap/indexing.rs index 7b80e0d..88ec3d8 100644 --- a/crates/plugin/src/cap/indexing.rs +++ b/crates/plugin/src/cap/indexing.rs @@ -2,12 +2,39 @@ use crate::ResolvedUnit; use crate::asset::BoxError; use crate::indexing::ProjectContext; use crate::model::ParsedFile; +use std::any::Any; + +pub trait SourceCollectArtifact: Send + Sync { + fn as_any(&self) -> &dyn Any; + fn into_any(self: Box) -> Box; + fn collected_type_symbols(&self) -> &[String]; + fn collected_method_symbols(&self) -> &[String]; + fn provided_dependency_symbols(&self) -> &[String]; + fn required_dependency_symbols(&self) -> &[String]; +} + +pub trait SourceAnalyzeArtifact: Send + Sync { + fn as_any(&self) -> &dyn Any; + fn into_any(self: Box) -> Box; +} pub trait SourceIndexCap: Send + Sync { - fn compile_source( + fn collect_source( &self, file: &ParsedFile, context: &ProjectContext, + ) -> Result, BoxError>; + + fn analyze_source( + &self, + collected: Box, + context: &ProjectContext, + ) -> Result, BoxError>; + + fn lower_source( + &self, + analyzed: Box, + context: &ProjectContext, ) -> Result; } diff --git a/crates/plugin/src/graph.rs b/crates/plugin/src/graph.rs index bef7bf1..ad19e28 100644 --- a/crates/plugin/src/graph.rs +++ b/crates/plugin/src/graph.rs @@ -27,6 +27,11 @@ pub enum GraphOp { UpdateFile { metadata: SourceFile }, } +#[derive(Debug, Clone)] +pub struct DeferredSymbol { + pub target: String, +} + /// Result of resolving a single file or unit #[derive(Debug)] pub struct ResolvedUnit { @@ -38,6 +43,8 @@ pub struct ResolvedUnit { pub identifiers: Vec, /// Naming convention for upgrading FQNs pub naming_convention: Option>, + /// Deferred unresolved targets produced during analyze pass. + pub deferred_symbols: Vec, } impl ResolvedUnit { @@ -47,6 +54,7 @@ impl ResolvedUnit { nodes: HashMap::new(), identifiers: Vec::new(), naming_convention: None, + deferred_symbols: Vec::new(), } } diff --git a/crates/plugin/src/indexing/context.rs b/crates/plugin/src/indexing/context.rs index 89a379e..20394d7 100644 --- a/crates/plugin/src/indexing/context.rs +++ b/crates/plugin/src/indexing/context.rs @@ -1,17 +1,26 @@ use std::collections::HashMap; use std::path::{Path, PathBuf}; +#[derive(Debug, Clone, Default)] +pub struct ProjectSymbolTable { + pub type_symbols: std::collections::HashSet, + pub method_symbols: std::collections::HashSet, +} + /// Project context generated during build indexing. #[derive(Debug, Clone, Default)] pub struct ProjectContext { /// Mapping from path prefixes to module IDs (e.g., "/project/app" -> "module::app") pub path_to_module: HashMap, + /// Project-level collected symbol snapshot used by analyze/bind stage. + pub symbol_table: ProjectSymbolTable, } impl ProjectContext { pub fn new() -> Self { Self { path_to_module: HashMap::new(), + symbol_table: ProjectSymbolTable::default(), } } From 7b1789828d8a56bd8061e37401a586c3e97b0e5b Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Sun, 15 Feb 2026 02:59:23 +0800 Subject: [PATCH 43/49] feat: implement source runtime and batch compilation in Naviscope - Introduced `SourceCompilerRuntime` to manage the lifecycle of source compilation. - Added `BatchCompiler` to handle batch processing of source files and stub requests. - Implemented `CommitGraphSink` for committing graph operations. - Created `InMemoryDeferredQueue` for managing deferred messages. - Developed `IngestExecutor` to execute source compilation work items. - Added metrics handling with `NoopRuntimeMetrics`. - Refactored `NaviscopeEngine` to utilize the new source runtime and batch compiler. - Updated tests to reflect changes in stub request handling and batch compilation. --- crates/core/src/indexing/compiler.rs | 116 +++++++++++++++++- crates/core/src/indexing/mod.rs | 1 + .../source_runtime}/commit_sink.rs | 0 .../source_runtime}/deferred_queue.rs | 0 .../source_runtime}/executor.rs | 20 +-- .../source_runtime}/metrics.rs | 0 .../source_runtime}/mod.rs | 22 ++-- .../source_runtime}/stub_ops.rs | 0 crates/core/src/lib.rs | 1 - crates/core/src/runtime/lifecycle.rs | 53 +++----- crates/core/src/runtime/mod.rs | 48 +++++--- crates/core/tests/async_stubbing.rs | 4 +- 12 files changed, 183 insertions(+), 82 deletions(-) rename crates/core/src/{ingest => indexing/source_runtime}/commit_sink.rs (100%) rename crates/core/src/{ingest => indexing/source_runtime}/deferred_queue.rs (100%) rename crates/core/src/{ingest => indexing/source_runtime}/executor.rs (95%) rename crates/core/src/{ingest => indexing/source_runtime}/metrics.rs (100%) rename crates/core/src/{ingest => indexing/source_runtime}/mod.rs (90%) rename crates/core/src/{ingest => indexing/source_runtime}/stub_ops.rs (100%) diff --git a/crates/core/src/indexing/compiler.rs b/crates/core/src/indexing/compiler.rs index 2c0d620..f3ed6cb 100644 --- a/crates/core/src/indexing/compiler.rs +++ b/crates/core/src/indexing/compiler.rs @@ -1,16 +1,25 @@ use crate::error::Result; use crate::indexing::scanner::ParsedFile; +use crate::indexing::source_runtime::{self, SourceCompilerRuntime}; use crate::model::GraphOp; -use naviscope_plugin::{BuildCaps, BuildContent, ParsedContent, ProjectContext}; +use naviscope_plugin::{BuildCaps, BuildContent, LanguageCaps, ParsedContent, ProjectContext}; use std::fs; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; pub struct BatchCompiler { build_caps: Vec, + source_compiler_runtime: tokio::sync::OnceCell>, + pending_stub_requests: Mutex>, } impl BatchCompiler { pub fn with_caps(build_caps: Vec) -> Self { - Self { build_caps } + Self { + build_caps, + source_compiler_runtime: tokio::sync::OnceCell::const_new(), + pending_stub_requests: Mutex::new(Vec::new()), + } } pub fn compile_build_batch( @@ -42,6 +51,109 @@ impl BatchCompiler { Ok(all_ops) } + pub async fn start_source_runtime( + current: Arc>>, + naming_conventions: Arc< + std::collections::HashMap>, + >, + build_caps: Arc>, + lang_caps: Arc>, + stub_cache: Arc, + ) -> Result { + SourceCompilerRuntime::start( + current, + naming_conventions, + build_caps, + lang_caps, + stub_cache, + ) + .await + } + + pub async fn ensure_source_compiler_runtime( + &self, + current: Arc>>, + naming_conventions: Arc< + std::collections::HashMap>, + >, + build_caps: Arc>, + lang_caps: Arc>, + stub_cache: Arc, + ) -> Result> { + let runtime = self + .source_compiler_runtime + .get_or_try_init(|| async { + Self::start_source_runtime( + current, + naming_conventions, + build_caps, + lang_caps, + stub_cache, + ) + .await + .map(Arc::new) + }) + .await + .map(Arc::clone)?; + + let drained = match self.pending_stub_requests.lock() { + Ok(mut pending) => pending.drain(..).collect::>(), + Err(_) => Vec::new(), + }; + for req in drained { + if let Err(err) = Self::submit_stub_request(runtime.as_ref(), req).await { + tracing::warn!("Failed to submit deferred stub request: {}", err); + } + } + + Ok(runtime) + } + + pub async fn compile_source_batch( + runtime: &SourceCompilerRuntime, + source_files: Vec, + project_context: ProjectContext, + routes: std::collections::HashMap>, + ) -> Result<()> { + runtime + .submit_source_batch(source_files, project_context, routes) + .await + } + + pub async fn submit_stub_request( + runtime: &SourceCompilerRuntime, + req: crate::indexing::StubRequest, + ) -> Result<()> { + runtime.submit_stub_request(req).await + } + + pub fn try_submit_stub_request( + runtime: &SourceCompilerRuntime, + req: crate::indexing::StubRequest, + ) -> Result<()> { + runtime.try_submit_stub_request(req) + } + + pub fn try_submit_or_enqueue_stub_request(&self, req: crate::indexing::StubRequest) -> bool { + if let Some(runtime) = self.source_compiler_runtime.get() { + return runtime.try_submit_stub_request(req).is_ok(); + } + + if let Ok(mut pending) = self.pending_stub_requests.lock() { + pending.push(req); + return true; + } + + false + } + + pub fn plan_stub_requests( + ops: &[GraphOp], + routes: &std::collections::HashMap>, + ) -> Vec { + source_runtime::plan_stub_requests(ops, routes) + } + fn prepare_build_file(caps: &BuildCaps, file: &ParsedFile) -> Result { let source = match &file.content { ParsedContent::Unparsed(s) => s.clone(), diff --git a/crates/core/src/indexing/mod.rs b/crates/core/src/indexing/mod.rs index d3f05dd..a289d1f 100644 --- a/crates/core/src/indexing/mod.rs +++ b/crates/core/src/indexing/mod.rs @@ -1,5 +1,6 @@ pub mod compiler; pub mod scanner; +pub(crate) mod source_runtime; pub use naviscope_plugin::IndexNode; diff --git a/crates/core/src/ingest/commit_sink.rs b/crates/core/src/indexing/source_runtime/commit_sink.rs similarity index 100% rename from crates/core/src/ingest/commit_sink.rs rename to crates/core/src/indexing/source_runtime/commit_sink.rs diff --git a/crates/core/src/ingest/deferred_queue.rs b/crates/core/src/indexing/source_runtime/deferred_queue.rs similarity index 100% rename from crates/core/src/ingest/deferred_queue.rs rename to crates/core/src/indexing/source_runtime/deferred_queue.rs diff --git a/crates/core/src/ingest/executor.rs b/crates/core/src/indexing/source_runtime/executor.rs similarity index 95% rename from crates/core/src/ingest/executor.rs rename to crates/core/src/indexing/source_runtime/executor.rs index 52661c1..75a9fd7 100644 --- a/crates/core/src/ingest/executor.rs +++ b/crates/core/src/indexing/source_runtime/executor.rs @@ -10,9 +10,9 @@ use naviscope_plugin::{ }; use crate::indexing::StubRequest; -use crate::ingest::IngestWorkItem; use crate::model::{CodeGraph, GraphOp}; +use super::SourceCompileWorkItem; use super::stub_ops::{find_asset_for_fqn, generate_stub_ops, plan_stub_requests}; pub struct IngestExecutor { @@ -25,15 +25,15 @@ pub struct IngestExecutor { pub analyze_cache: Arc>>>, } -impl Executor for IngestExecutor { +impl Executor for IngestExecutor { fn execute( &self, - message: naviscope_ingest::Message, - ) -> Result>, IngestError> { + message: naviscope_ingest::Message, + ) -> Result>, IngestError> { let parent_msg_id = message.msg_id.clone(); let epoch = message.epoch; match message.payload.clone() { - IngestWorkItem::SourceCollect(file) => { + SourceCompileWorkItem::SourceCollect(file) => { let dep_state = self.execute_collect(file.clone())?; let analyze_msg = naviscope_ingest::Message { msg_id: next_stage_msg_id(&parent_msg_id, "collect", "analyze"), @@ -47,7 +47,7 @@ impl Executor for IngestExecutor { .map(|s| DependencyRef::resource(s, None)) .collect(), epoch, - payload: IngestWorkItem::SourceAnalyze(file), + payload: SourceCompileWorkItem::SourceAnalyze(file), metadata: BTreeMap::new(), }; Ok(vec![ @@ -64,7 +64,7 @@ impl Executor for IngestExecutor { PipelineEvent::Deferred(analyze_msg), ]) } - IngestWorkItem::SourceAnalyze(file) => { + SourceCompileWorkItem::SourceAnalyze(file) => { self.execute_analyze(file.clone())?; let lower_msg = naviscope_ingest::Message { msg_id: next_stage_msg_id(&parent_msg_id, "analyze", "lower"), @@ -73,7 +73,7 @@ impl Executor for IngestExecutor { version: 1, depends_on: vec![DependencyRef::message(parent_msg_id.clone())], epoch, - payload: IngestWorkItem::SourceLower(file), + payload: SourceCompileWorkItem::SourceLower(file), metadata: BTreeMap::new(), }; Ok(vec![ @@ -90,7 +90,7 @@ impl Executor for IngestExecutor { PipelineEvent::Deferred(lower_msg), ]) } - IngestWorkItem::SourceLower(file) => { + SourceCompileWorkItem::SourceLower(file) => { let outcome = self.execute_lower(file)?; Ok(vec![PipelineEvent::Executed { epoch, @@ -103,7 +103,7 @@ impl Executor for IngestExecutor { }, }]) } - IngestWorkItem::StubRequest(req) => { + SourceCompileWorkItem::StubRequest(req) => { let operations = self.execute_stub(req); Ok(vec![PipelineEvent::Executed { epoch, diff --git a/crates/core/src/ingest/metrics.rs b/crates/core/src/indexing/source_runtime/metrics.rs similarity index 100% rename from crates/core/src/ingest/metrics.rs rename to crates/core/src/indexing/source_runtime/metrics.rs diff --git a/crates/core/src/ingest/mod.rs b/crates/core/src/indexing/source_runtime/mod.rs similarity index 90% rename from crates/core/src/ingest/mod.rs rename to crates/core/src/indexing/source_runtime/mod.rs index 46d9bfd..709d9a6 100644 --- a/crates/core/src/ingest/mod.rs +++ b/crates/core/src/indexing/source_runtime/mod.rs @@ -24,7 +24,7 @@ use metrics::NoopRuntimeMetrics; pub use stub_ops::plan_stub_requests; #[derive(Clone)] -pub enum IngestWorkItem { +pub enum SourceCompileWorkItem { SourceCollect(ParsedFile), SourceAnalyze(ParsedFile), SourceLower(ParsedFile), @@ -37,14 +37,14 @@ struct StagedSourceItem { collect_id: String, } -pub struct IngestAdapter { - intake: IntakeHandle, +pub struct SourceCompilerRuntime { + intake: IntakeHandle, project_context: Arc>, routes: Arc>>>, runtime_task: tokio::task::JoinHandle<()>, } -impl IngestAdapter { +impl SourceCompilerRuntime { pub async fn start( current: Arc>>, naming_conventions: Arc>>, @@ -54,7 +54,7 @@ impl IngestAdapter { ) -> Result { let project_context = Arc::new(RwLock::new(ProjectContext::new())); let routes = Arc::new(RwLock::new(HashMap::new())); - let executor: naviscope_ingest::DynExecutor = + let executor: naviscope_ingest::DynExecutor = Arc::new(IngestExecutor { lang_caps, project_context: Arc::clone(&project_context), @@ -64,7 +64,7 @@ impl IngestAdapter { collect_cache: Arc::new(std::sync::Mutex::new(HashMap::new())), analyze_cache: Arc::new(std::sync::Mutex::new(HashMap::new())), }); - let deferred_store: naviscope_ingest::DynDeferredStore = + let deferred_store: naviscope_ingest::DynDeferredStore = Arc::new(InMemoryDeferredQueue::default()); let commit_sink: naviscope_ingest::DynCommitSink = Arc::new(CommitGraphSink { current, @@ -91,7 +91,7 @@ impl IngestAdapter { let runtime_clone = Arc::clone(&runtime); let runtime_task = tokio::spawn(async move { if let Err(err) = runtime_clone.run_forever().await { - tracing::warn!("ingest runtime stopped: {}", err); + tracing::warn!("source compiler runtime stopped: {}", err); } }); @@ -149,7 +149,7 @@ impl IngestAdapter { version: 1, depends_on: Vec::new(), epoch, - payload: IngestWorkItem::SourceCollect(file.clone()), + payload: SourceCompileWorkItem::SourceCollect(file.clone()), metadata: BTreeMap::new(), }; self.intake @@ -176,7 +176,7 @@ impl IngestAdapter { version: 1, depends_on: Vec::new(), epoch: 0, - payload: IngestWorkItem::StubRequest(req), + payload: SourceCompileWorkItem::StubRequest(req), metadata: BTreeMap::new(), }; self.intake @@ -194,7 +194,7 @@ impl IngestAdapter { version: 1, depends_on: Vec::new(), epoch: 0, - payload: IngestWorkItem::StubRequest(req), + payload: SourceCompileWorkItem::StubRequest(req), metadata: BTreeMap::new(), }; self.intake @@ -203,7 +203,7 @@ impl IngestAdapter { } } -impl Drop for IngestAdapter { +impl Drop for SourceCompilerRuntime { fn drop(&mut self) { self.runtime_task.abort(); } diff --git a/crates/core/src/ingest/stub_ops.rs b/crates/core/src/indexing/source_runtime/stub_ops.rs similarity index 100% rename from crates/core/src/ingest/stub_ops.rs rename to crates/core/src/indexing/source_runtime/stub_ops.rs diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 8f7a0b9..0d50e01 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -6,7 +6,6 @@ pub mod util; pub mod facade; pub mod features; -pub mod ingest; pub mod indexing; pub mod model; pub mod runtime; diff --git a/crates/core/src/runtime/lifecycle.rs b/crates/core/src/runtime/lifecycle.rs index acbcbd2..8166997 100644 --- a/crates/core/src/runtime/lifecycle.rs +++ b/crates/core/src/runtime/lifecycle.rs @@ -75,38 +75,6 @@ impl NaviscopeEngine { self.update_files(paths).await } - async fn ensure_ingest_adapter( - &self, - ) -> Result> { - let runtime = self - .ingest_adapter - .get_or_try_init(|| async { - crate::ingest::IngestAdapter::start( - self.current.clone(), - self.naming_conventions.clone(), - self.build_caps.clone(), - self.lang_caps.clone(), - self.stub_cache.clone(), - ) - .await - .map(Arc::new) - }) - .await - .map(Arc::clone)?; - - let drained = match self.pending_stub_requests.lock() { - Ok(mut pending) => pending.drain(..).collect::>(), - Err(_) => Vec::new(), - }; - for req in drained { - if let Err(err) = runtime.submit_stub_request(req).await { - tracing::warn!("Failed to submit deferred stub request: {}", err); - } - } - - Ok(runtime) - } - fn collect_existing_metadata( base_graph: &CodeGraph, ) -> std::collections::HashMap { @@ -206,7 +174,16 @@ impl NaviscopeEngine { return Ok(()); } - let ingest_adapter = self.ensure_ingest_adapter().await?; + let source_runtime = self + .batch_compiler + .ensure_source_compiler_runtime( + self.current_graph_arc(), + self.naming_conventions(), + self.build_caps_arc(), + self.lang_caps_arc(), + self.stub_cache_arc(), + ) + .await?; let routes = self.global_asset_routes(); for chunk in source_paths.chunks(SOURCE_SUBMIT_CHUNK_SIZE) { @@ -222,9 +199,13 @@ impl NaviscopeEngine { continue; } - ingest_adapter - .submit_source_batch(source_files, project_context.clone(), routes.clone()) - .await?; + crate::indexing::compiler::BatchCompiler::compile_source_batch( + source_runtime.as_ref(), + source_files, + project_context.clone(), + routes.clone(), + ) + .await?; } Ok(()) } diff --git a/crates/core/src/runtime/mod.rs b/crates/core/src/runtime/mod.rs index 17d242b..99d0f8b 100644 --- a/crates/core/src/runtime/mod.rs +++ b/crates/core/src/runtime/mod.rs @@ -2,6 +2,7 @@ use crate::asset::service::AssetStubService; use crate::error::{NaviscopeError, Result}; +use crate::indexing::compiler::BatchCompiler; use crate::indexing::scanner::Scanner; use crate::indexing::StubRequest; use crate::model::{CodeGraph, GraphOp}; @@ -10,7 +11,7 @@ use naviscope_plugin::{ }; use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use tokio::sync::RwLock; use xxhash_rust::xxh3::xxh3_64; @@ -52,11 +53,8 @@ pub struct NaviscopeEngine { /// Global asset service (new architecture) asset_service: Option>, - /// Resident ingest runtime for source/stub indexing. - ingest_adapter: tokio::sync::OnceCell>, - - /// Stub requests captured before ingest runtime is initialized. - pending_stub_requests: Mutex>, + /// Source compiler facade that owns source runtime lifecycle. + batch_compiler: Arc, } pub struct NaviscopeEngineBuilder { @@ -164,18 +162,21 @@ impl NaviscopeEngineBuilder { None }; + let build_caps = Arc::new(self.build_caps); + let lang_caps = Arc::new(self.lang_caps); + let batch_compiler = Arc::new(BatchCompiler::with_caps((*build_caps).clone())); + NaviscopeEngine { current: Arc::new(RwLock::new(Arc::new(CodeGraph::empty()))), project_root: canonical_root, index_path, - build_caps: Arc::new(self.build_caps), - lang_caps: Arc::new(self.lang_caps), + build_caps, + lang_caps, naming_conventions: Arc::new(conventions), cancel_token, stub_cache, asset_service, - ingest_adapter: tokio::sync::OnceCell::const_new(), - pending_stub_requests: Mutex::new(Vec::new()), + batch_compiler, } } } @@ -260,6 +261,22 @@ impl NaviscopeEngine { self.naming_conventions.clone() } + pub(crate) fn build_caps_arc(&self) -> Arc> { + Arc::clone(&self.build_caps) + } + + pub(crate) fn lang_caps_arc(&self) -> Arc> { + Arc::clone(&self.lang_caps) + } + + pub(crate) fn current_graph_arc(&self) -> Arc>> { + Arc::clone(&self.current) + } + + pub(crate) fn stub_cache_arc(&self) -> Arc { + Arc::clone(&self.stub_cache) + } + /// Get the asset service (if available) pub fn asset_service(&self) -> Option<&Arc> { self.asset_service.as_ref() @@ -283,16 +300,7 @@ impl NaviscopeEngine { candidate_paths, }; - if let Some(runtime) = self.ingest_adapter.get() { - return runtime.try_submit_stub_request(req).is_ok(); - } - - if let Ok(mut pending) = self.pending_stub_requests.lock() { - pending.push(req); - return true; - } - - false + self.batch_compiler.try_submit_or_enqueue_stub_request(req) } /// Run the global asset scan and populate routes diff --git a/crates/core/tests/async_stubbing.rs b/crates/core/tests/async_stubbing.rs index bc2ba87..cc70ba1 100644 --- a/crates/core/tests/async_stubbing.rs +++ b/crates/core/tests/async_stubbing.rs @@ -1,7 +1,7 @@ //! Tests for async stubbing workflow use naviscope_api::models::graph::ResolutionStatus; -use naviscope_core::ingest::plan_stub_requests; +use naviscope_core::indexing::compiler::BatchCompiler; use naviscope_core::model::GraphOp; use naviscope_core::runtime::NaviscopeEngine; use std::path::Path; @@ -70,7 +70,7 @@ async fn test_async_stubbing_with_jar() { }), }]; - let reqs = plan_stub_requests(&ops, &routes); + let reqs = BatchCompiler::plan_stub_requests(&ops, &routes); for req in reqs { assert!(engine.request_stub_for_fqn(&req.fqn)); } From 51e59f4c8615534cdf52384436c0d1db4de9bb47 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Sun, 15 Feb 2026 03:07:15 +0800 Subject: [PATCH 44/49] Refactor indexing module: Replace BatchCompiler with BuildCompiler and reorganize source handling - Introduced BuildCompiler in place of BatchCompiler for build file compilation. - Removed compiler.rs and reorganized source-related functionality into new modules: commit_sink, deferred_queue, executor, metrics, and stub_ops. - Updated NaviscopeEngine to utilize the new SourceCompiler for source compilation tasks. - Adjusted async stubbing tests to align with the new source compilation structure. --- crates/core/src/indexing/build.rs | 76 +++++++ crates/core/src/indexing/compiler.rs | 188 ------------------ crates/core/src/indexing/mod.rs | 4 +- .../{source_runtime => source}/commit_sink.rs | 0 .../deferred_queue.rs | 0 .../{source_runtime => source}/executor.rs | 0 .../{source_runtime => source}/metrics.rs | 0 .../{source_runtime => source}/mod.rs | 73 ++++++- .../{source_runtime => source}/stub_ops.rs | 0 crates/core/src/runtime/lifecycle.rs | 10 +- crates/core/src/runtime/mod.rs | 14 +- crates/core/tests/async_stubbing.rs | 4 +- 12 files changed, 159 insertions(+), 210 deletions(-) create mode 100644 crates/core/src/indexing/build.rs delete mode 100644 crates/core/src/indexing/compiler.rs rename crates/core/src/indexing/{source_runtime => source}/commit_sink.rs (100%) rename crates/core/src/indexing/{source_runtime => source}/deferred_queue.rs (100%) rename crates/core/src/indexing/{source_runtime => source}/executor.rs (100%) rename crates/core/src/indexing/{source_runtime => source}/metrics.rs (100%) rename crates/core/src/indexing/{source_runtime => source}/mod.rs (75%) rename crates/core/src/indexing/{source_runtime => source}/stub_ops.rs (100%) diff --git a/crates/core/src/indexing/build.rs b/crates/core/src/indexing/build.rs new file mode 100644 index 0000000..4d48b26 --- /dev/null +++ b/crates/core/src/indexing/build.rs @@ -0,0 +1,76 @@ +use crate::error::Result; +use crate::indexing::scanner::ParsedFile; +use crate::model::GraphOp; +use naviscope_plugin::{BuildCaps, BuildContent, ParsedContent, ProjectContext}; +use std::fs; + +pub struct BuildCompiler { + build_caps: Vec, +} + +impl BuildCompiler { + pub fn with_caps(build_caps: Vec) -> Self { + Self { build_caps } + } + + pub fn compile_build_batch( + &self, + build_files: &[ParsedFile], + context: &mut ProjectContext, + ) -> Result> { + let mut all_ops = Vec::new(); + for caps in &self.build_caps { + let tool_files: Vec<&ParsedFile> = build_files + .iter() + .filter(|f| caps.matcher.supports_path(f.path())) + .collect(); + + if !tool_files.is_empty() { + let parsed_tool_files: Vec = tool_files + .iter() + .map(|f| Self::prepare_build_file(caps, f)) + .collect::>>()?; + let parsed_tool_file_refs: Vec<&ParsedFile> = parsed_tool_files.iter().collect(); + let (unit, ctx) = caps + .indexing + .compile_build(&parsed_tool_file_refs) + .map_err(crate::error::NaviscopeError::from)?; + all_ops.extend(unit.ops); + context.path_to_module.extend(ctx.path_to_module); + } + } + Ok(all_ops) + } + + fn prepare_build_file(caps: &BuildCaps, file: &ParsedFile) -> Result { + let source = match &file.content { + ParsedContent::Unparsed(s) => s.clone(), + ParsedContent::Lazy => fs::read_to_string(file.path()).map_err(|e| { + crate::error::NaviscopeError::Internal(format!( + "Failed to read build file {}: {}", + file.path().display(), + e + )) + })?, + ParsedContent::Metadata(_) => return Ok(file.clone()), + ParsedContent::Language(_) => return Ok(file.clone()), + }; + + let parse_result = caps + .parser + .parse_build_file(&source) + .map_err(crate::error::NaviscopeError::from)?; + + let content = match parse_result.content { + BuildContent::Metadata(value) => ParsedContent::Metadata(value), + BuildContent::Unparsed(text) => ParsedContent::Unparsed(text), + // Build indexing currently consumes Metadata/Unparsed; preserve source for this case. + BuildContent::Parsed(_) => ParsedContent::Unparsed(source), + }; + + Ok(ParsedFile { + file: file.file.clone(), + content, + }) + } +} diff --git a/crates/core/src/indexing/compiler.rs b/crates/core/src/indexing/compiler.rs deleted file mode 100644 index f3ed6cb..0000000 --- a/crates/core/src/indexing/compiler.rs +++ /dev/null @@ -1,188 +0,0 @@ -use crate::error::Result; -use crate::indexing::scanner::ParsedFile; -use crate::indexing::source_runtime::{self, SourceCompilerRuntime}; -use crate::model::GraphOp; -use naviscope_plugin::{BuildCaps, BuildContent, LanguageCaps, ParsedContent, ProjectContext}; -use std::fs; -use std::path::PathBuf; -use std::sync::{Arc, Mutex}; - -pub struct BatchCompiler { - build_caps: Vec, - source_compiler_runtime: tokio::sync::OnceCell>, - pending_stub_requests: Mutex>, -} - -impl BatchCompiler { - pub fn with_caps(build_caps: Vec) -> Self { - Self { - build_caps, - source_compiler_runtime: tokio::sync::OnceCell::const_new(), - pending_stub_requests: Mutex::new(Vec::new()), - } - } - - pub fn compile_build_batch( - &self, - build_files: &[ParsedFile], - context: &mut ProjectContext, - ) -> Result> { - let mut all_ops = Vec::new(); - for caps in &self.build_caps { - let tool_files: Vec<&ParsedFile> = build_files - .iter() - .filter(|f| caps.matcher.supports_path(f.path())) - .collect(); - - if !tool_files.is_empty() { - let parsed_tool_files: Vec = tool_files - .iter() - .map(|f| Self::prepare_build_file(caps, f)) - .collect::>>()?; - let parsed_tool_file_refs: Vec<&ParsedFile> = parsed_tool_files.iter().collect(); - let (unit, ctx) = caps - .indexing - .compile_build(&parsed_tool_file_refs) - .map_err(crate::error::NaviscopeError::from)?; - all_ops.extend(unit.ops); - context.path_to_module.extend(ctx.path_to_module); - } - } - Ok(all_ops) - } - - pub async fn start_source_runtime( - current: Arc>>, - naming_conventions: Arc< - std::collections::HashMap>, - >, - build_caps: Arc>, - lang_caps: Arc>, - stub_cache: Arc, - ) -> Result { - SourceCompilerRuntime::start( - current, - naming_conventions, - build_caps, - lang_caps, - stub_cache, - ) - .await - } - - pub async fn ensure_source_compiler_runtime( - &self, - current: Arc>>, - naming_conventions: Arc< - std::collections::HashMap>, - >, - build_caps: Arc>, - lang_caps: Arc>, - stub_cache: Arc, - ) -> Result> { - let runtime = self - .source_compiler_runtime - .get_or_try_init(|| async { - Self::start_source_runtime( - current, - naming_conventions, - build_caps, - lang_caps, - stub_cache, - ) - .await - .map(Arc::new) - }) - .await - .map(Arc::clone)?; - - let drained = match self.pending_stub_requests.lock() { - Ok(mut pending) => pending.drain(..).collect::>(), - Err(_) => Vec::new(), - }; - for req in drained { - if let Err(err) = Self::submit_stub_request(runtime.as_ref(), req).await { - tracing::warn!("Failed to submit deferred stub request: {}", err); - } - } - - Ok(runtime) - } - - pub async fn compile_source_batch( - runtime: &SourceCompilerRuntime, - source_files: Vec, - project_context: ProjectContext, - routes: std::collections::HashMap>, - ) -> Result<()> { - runtime - .submit_source_batch(source_files, project_context, routes) - .await - } - - pub async fn submit_stub_request( - runtime: &SourceCompilerRuntime, - req: crate::indexing::StubRequest, - ) -> Result<()> { - runtime.submit_stub_request(req).await - } - - pub fn try_submit_stub_request( - runtime: &SourceCompilerRuntime, - req: crate::indexing::StubRequest, - ) -> Result<()> { - runtime.try_submit_stub_request(req) - } - - pub fn try_submit_or_enqueue_stub_request(&self, req: crate::indexing::StubRequest) -> bool { - if let Some(runtime) = self.source_compiler_runtime.get() { - return runtime.try_submit_stub_request(req).is_ok(); - } - - if let Ok(mut pending) = self.pending_stub_requests.lock() { - pending.push(req); - return true; - } - - false - } - - pub fn plan_stub_requests( - ops: &[GraphOp], - routes: &std::collections::HashMap>, - ) -> Vec { - source_runtime::plan_stub_requests(ops, routes) - } - - fn prepare_build_file(caps: &BuildCaps, file: &ParsedFile) -> Result { - let source = match &file.content { - ParsedContent::Unparsed(s) => s.clone(), - ParsedContent::Lazy => fs::read_to_string(file.path()).map_err(|e| { - crate::error::NaviscopeError::Internal(format!( - "Failed to read build file {}: {}", - file.path().display(), - e - )) - })?, - ParsedContent::Metadata(_) => return Ok(file.clone()), - ParsedContent::Language(_) => return Ok(file.clone()), - }; - - let parse_result = caps - .parser - .parse_build_file(&source) - .map_err(crate::error::NaviscopeError::from)?; - - let content = match parse_result.content { - BuildContent::Metadata(value) => ParsedContent::Metadata(value), - BuildContent::Unparsed(text) => ParsedContent::Unparsed(text), - // Build indexing currently consumes Metadata/Unparsed; preserve source for this case. - BuildContent::Parsed(_) => ParsedContent::Unparsed(source), - }; - - Ok(ParsedFile { - file: file.file.clone(), - content, - }) - } -} diff --git a/crates/core/src/indexing/mod.rs b/crates/core/src/indexing/mod.rs index a289d1f..e822cce 100644 --- a/crates/core/src/indexing/mod.rs +++ b/crates/core/src/indexing/mod.rs @@ -1,6 +1,6 @@ -pub mod compiler; +pub mod build; pub mod scanner; -pub(crate) mod source_runtime; +pub mod source; pub use naviscope_plugin::IndexNode; diff --git a/crates/core/src/indexing/source_runtime/commit_sink.rs b/crates/core/src/indexing/source/commit_sink.rs similarity index 100% rename from crates/core/src/indexing/source_runtime/commit_sink.rs rename to crates/core/src/indexing/source/commit_sink.rs diff --git a/crates/core/src/indexing/source_runtime/deferred_queue.rs b/crates/core/src/indexing/source/deferred_queue.rs similarity index 100% rename from crates/core/src/indexing/source_runtime/deferred_queue.rs rename to crates/core/src/indexing/source/deferred_queue.rs diff --git a/crates/core/src/indexing/source_runtime/executor.rs b/crates/core/src/indexing/source/executor.rs similarity index 100% rename from crates/core/src/indexing/source_runtime/executor.rs rename to crates/core/src/indexing/source/executor.rs diff --git a/crates/core/src/indexing/source_runtime/metrics.rs b/crates/core/src/indexing/source/metrics.rs similarity index 100% rename from crates/core/src/indexing/source_runtime/metrics.rs rename to crates/core/src/indexing/source/metrics.rs diff --git a/crates/core/src/indexing/source_runtime/mod.rs b/crates/core/src/indexing/source/mod.rs similarity index 75% rename from crates/core/src/indexing/source_runtime/mod.rs rename to crates/core/src/indexing/source/mod.rs index 709d9a6..eb77079 100644 --- a/crates/core/src/indexing/source_runtime/mod.rs +++ b/crates/core/src/indexing/source/mod.rs @@ -6,12 +6,12 @@ mod stub_ops; use std::collections::{BTreeMap, HashMap}; use std::path::PathBuf; -use std::sync::{Arc, RwLock}; +use std::sync::{Arc, Mutex, RwLock}; use naviscope_ingest::{ IngestError, IngestRuntime, IntakeHandle, RuntimeComponents, RuntimeConfig, }; -use naviscope_plugin::{BuildCaps, LanguageCaps, ParsedFile, ProjectContext}; +use naviscope_plugin::{LanguageCaps, ParsedFile, ProjectContext}; use crate::error::{NaviscopeError, Result}; use crate::indexing::StubRequest; @@ -44,11 +44,15 @@ pub struct SourceCompilerRuntime { runtime_task: tokio::task::JoinHandle<()>, } +pub struct SourceCompiler { + runtime: tokio::sync::OnceCell>, + pending_stub_requests: Mutex>, +} + impl SourceCompilerRuntime { pub async fn start( current: Arc>>, naming_conventions: Arc>>, - _build_caps: Arc>, lang_caps: Arc>, stub_cache: Arc, ) -> Result { @@ -209,6 +213,69 @@ impl Drop for SourceCompilerRuntime { } } +impl SourceCompiler { + pub fn new() -> Self { + Self { + runtime: tokio::sync::OnceCell::const_new(), + pending_stub_requests: Mutex::new(Vec::new()), + } + } + + pub async fn ensure_runtime( + &self, + current: Arc>>, + naming_conventions: Arc>>, + lang_caps: Arc>, + stub_cache: Arc, + ) -> Result> { + let runtime = self + .runtime + .get_or_try_init(|| async { + SourceCompilerRuntime::start(current, naming_conventions, lang_caps, stub_cache) + .await + .map(Arc::new) + }) + .await + .map(Arc::clone)?; + + let drained = match self.pending_stub_requests.lock() { + Ok(mut pending) => pending.drain(..).collect::>(), + Err(_) => Vec::new(), + }; + for req in drained { + if let Err(err) = runtime.submit_stub_request(req).await { + tracing::warn!("Failed to submit deferred stub request: {}", err); + } + } + + Ok(runtime) + } + + pub async fn compile_source_batch( + runtime: &SourceCompilerRuntime, + source_files: Vec, + project_context: ProjectContext, + routes: HashMap>, + ) -> Result<()> { + runtime + .submit_source_batch(source_files, project_context, routes) + .await + } + + pub fn try_submit_or_enqueue_stub_request(&self, req: StubRequest) -> bool { + if let Some(runtime) = self.runtime.get() { + return runtime.try_submit_stub_request(req).is_ok(); + } + + if let Ok(mut pending) = self.pending_stub_requests.lock() { + pending.push(req); + return true; + } + + false + } +} + fn ingest_to_naviscope_error(err: IngestError) -> NaviscopeError { NaviscopeError::Internal(err.to_string()) } diff --git a/crates/core/src/indexing/source_runtime/stub_ops.rs b/crates/core/src/indexing/source/stub_ops.rs similarity index 100% rename from crates/core/src/indexing/source_runtime/stub_ops.rs rename to crates/core/src/indexing/source/stub_ops.rs diff --git a/crates/core/src/runtime/lifecycle.rs b/crates/core/src/runtime/lifecycle.rs index 8166997..f9f4b30 100644 --- a/crates/core/src/runtime/lifecycle.rs +++ b/crates/core/src/runtime/lifecycle.rs @@ -127,8 +127,7 @@ impl NaviscopeEngine { return Ok((base_graph, Vec::new(), naviscope_plugin::ProjectContext::new())); } - let compiler = - crate::indexing::compiler::BatchCompiler::with_caps((*build_caps).clone()); + let compiler = crate::indexing::build::BuildCompiler::with_caps((*build_caps).clone()); let mut project_context = naviscope_plugin::ProjectContext::new(); let mut initial_ops = manual_ops; @@ -175,11 +174,10 @@ impl NaviscopeEngine { } let source_runtime = self - .batch_compiler - .ensure_source_compiler_runtime( + .source_compiler + .ensure_runtime( self.current_graph_arc(), self.naming_conventions(), - self.build_caps_arc(), self.lang_caps_arc(), self.stub_cache_arc(), ) @@ -199,7 +197,7 @@ impl NaviscopeEngine { continue; } - crate::indexing::compiler::BatchCompiler::compile_source_batch( + crate::indexing::source::SourceCompiler::compile_source_batch( source_runtime.as_ref(), source_files, project_context.clone(), diff --git a/crates/core/src/runtime/mod.rs b/crates/core/src/runtime/mod.rs index 99d0f8b..012d514 100644 --- a/crates/core/src/runtime/mod.rs +++ b/crates/core/src/runtime/mod.rs @@ -2,7 +2,7 @@ use crate::asset::service::AssetStubService; use crate::error::{NaviscopeError, Result}; -use crate::indexing::compiler::BatchCompiler; +use crate::indexing::source::SourceCompiler; use crate::indexing::scanner::Scanner; use crate::indexing::StubRequest; use crate::model::{CodeGraph, GraphOp}; @@ -54,7 +54,7 @@ pub struct NaviscopeEngine { asset_service: Option>, /// Source compiler facade that owns source runtime lifecycle. - batch_compiler: Arc, + source_compiler: Arc, } pub struct NaviscopeEngineBuilder { @@ -164,7 +164,7 @@ impl NaviscopeEngineBuilder { let build_caps = Arc::new(self.build_caps); let lang_caps = Arc::new(self.lang_caps); - let batch_compiler = Arc::new(BatchCompiler::with_caps((*build_caps).clone())); + let source_compiler = Arc::new(SourceCompiler::new()); NaviscopeEngine { current: Arc::new(RwLock::new(Arc::new(CodeGraph::empty()))), @@ -176,7 +176,7 @@ impl NaviscopeEngineBuilder { cancel_token, stub_cache, asset_service, - batch_compiler, + source_compiler, } } } @@ -261,10 +261,6 @@ impl NaviscopeEngine { self.naming_conventions.clone() } - pub(crate) fn build_caps_arc(&self) -> Arc> { - Arc::clone(&self.build_caps) - } - pub(crate) fn lang_caps_arc(&self) -> Arc> { Arc::clone(&self.lang_caps) } @@ -300,7 +296,7 @@ impl NaviscopeEngine { candidate_paths, }; - self.batch_compiler.try_submit_or_enqueue_stub_request(req) + self.source_compiler.try_submit_or_enqueue_stub_request(req) } /// Run the global asset scan and populate routes diff --git a/crates/core/tests/async_stubbing.rs b/crates/core/tests/async_stubbing.rs index cc70ba1..ad447f0 100644 --- a/crates/core/tests/async_stubbing.rs +++ b/crates/core/tests/async_stubbing.rs @@ -1,7 +1,7 @@ //! Tests for async stubbing workflow use naviscope_api::models::graph::ResolutionStatus; -use naviscope_core::indexing::compiler::BatchCompiler; +use naviscope_core::indexing::source::plan_stub_requests; use naviscope_core::model::GraphOp; use naviscope_core::runtime::NaviscopeEngine; use std::path::Path; @@ -70,7 +70,7 @@ async fn test_async_stubbing_with_jar() { }), }]; - let reqs = BatchCompiler::plan_stub_requests(&ops, &routes); + let reqs = plan_stub_requests(&ops, &routes); for req in reqs { assert!(engine.request_stub_for_fqn(&req.fqn)); } From 8d37b35bbd66f22d69a5e9f086b710dcb612d537 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Sun, 15 Feb 2026 17:13:44 +0800 Subject: [PATCH 45/49] feat: add incremental consistency tests for Java navigation - Introduced a new test file `incremental_consistency.rs` to validate the consistency between incremental and full symbol resolution in Java. - Implemented functions to observe symbol resolutions, definitions, and references in the context of Java files. - Created a test case that compares observations from both incremental and full indexing processes. refactor: enhance Java external resolver with class byte loading - Added `load_class_bytes_for_fqn` and `generate_related_for_class` methods to `JavaExternalResolver` for improved handling of class files. - Updated the `generate_stubs` method to return multiple stubs, including related nodes for richer symbol resolution. fix: improve error handling and logging in LSP operations - Enhanced error handling in LSP functions such as `definition`, `type_definition`, `references`, and `implementation` to log errors with context. - Refactored location conversion logic to handle potential failures gracefully. feat: implement call hierarchy features in LSP - Added functions to build call hierarchy items and parse hierarchy FQN data. - Improved handling of incoming and outgoing calls with better error logging and data serialization. test: add unit tests for LSP call hierarchy functions - Implemented tests for `parse_hierarchy_fqn` and `build_call_hierarchy_item` to ensure correct functionality and error handling. refactor: update document symbol handling in LSP - Modified `document_symbol` and `convert_api_symbol` functions to skip entries without locations, improving robustness. - Added tests to verify the behavior of symbol conversion. chore: update stub generator trait for better extensibility - Changed the `generate` method in the `StubGenerator` trait to `generate_stubs`, allowing for the generation of multiple stubs from a single asset. --- crates/core/src/facade/semantic.rs | 92 ++++-- crates/core/src/indexing/source/stub_ops.rs | 67 +++-- crates/core/tests/async_stubbing.rs | 282 +++++++++++++++++++ crates/core/tests/incremental_consistency.rs | 202 +++++++++++++ crates/lang-java/src/resolve/external/mod.rs | 163 ++++++++++- crates/lsp/src/goto.rs | 144 ++++++---- crates/lsp/src/hierarchy.rs | 208 +++++++++++--- crates/lsp/src/highlight.rs | 5 +- crates/lsp/src/hover.rs | 9 +- crates/lsp/src/symbols.rs | 46 ++- crates/plugin/src/asset.rs | 8 +- 11 files changed, 1068 insertions(+), 158 deletions(-) create mode 100644 crates/core/tests/incremental_consistency.rs diff --git a/crates/core/src/facade/semantic.rs b/crates/core/src/facade/semantic.rs index d25d912..ebb2445 100644 --- a/crates/core/src/facade/semantic.rs +++ b/crates/core/src/facade/semantic.rs @@ -19,6 +19,14 @@ use std::path::PathBuf; use std::sync::Arc; use tokio::time::{Duration, sleep}; +fn path_from_uri_like(uri: &str) -> PathBuf { + if uri.starts_with("file://") { + PathBuf::from(uri.strip_prefix("file://").unwrap_or(uri)) + } else { + PathBuf::from(uri) + } +} + impl EngineHandle { async fn hydrate_symbol_if_missing(&self, fqn: &str) -> ApiResult<()> { if self @@ -64,12 +72,7 @@ impl SymbolNavigator for EngineHandle { &self, ctx: &PositionContext, ) -> ApiResult> { - let uri_str = &ctx.uri; - let path = if uri_str.starts_with("file://") { - PathBuf::from(uri_str.strip_prefix("file://").unwrap()) - } else { - PathBuf::from(uri_str) - }; + let path = path_from_uri_like(&ctx.uri); let (semantic, _lang) = match self.get_services_for_path(&path) { Some(x) => x, @@ -99,12 +102,7 @@ impl SymbolNavigator for EngineHandle { } async fn find_highlights(&self, ctx: &PositionContext) -> ApiResult> { - let uri_str = &ctx.uri; - let path = if uri_str.starts_with("file://") { - PathBuf::from(uri_str.strip_prefix("file://").unwrap()) - } else { - PathBuf::from(uri_str) - }; + let path = path_from_uri_like(&ctx.uri); let (semantic, _) = match self.get_services_for_path(&path) { Some(x) => x, @@ -290,7 +288,10 @@ impl ReferenceAnalyzer for EngineHandle { let content = match fs::read_to_string(&path) { Ok(c) => c, - Err(_) => return Vec::new(), + Err(e) => { + tracing::warn!("find_references failed to read {}: {}", path.display(), e); + return Vec::new(); + } }; let discovery = DiscoveryEngine::new(graph_snap.as_ref(), conventions_clone); @@ -298,7 +299,10 @@ impl ReferenceAnalyzer for EngineHandle { let uri_str = format!("file://{}", path.display()); let uri: lsp_types::Uri = match uri_str.parse() { Ok(u) => u, - Err(_) => return Vec::new(), + Err(e) => { + tracing::warn!("find_references failed to parse URI {}: {}", uri_str, e); + return Vec::new(); + } }; let locations = discovery.scan_file(semantic.as_ref(), &content, &resolution, &uri); @@ -426,14 +430,28 @@ impl CallHierarchyAnalyzer for EngineHandle { let content = match fs::read_to_string(&path) { Ok(c) => c, - Err(_) => return vec![], + Err(e) => { + tracing::warn!( + "find_incoming_calls failed to read {}: {}", + path.display(), + e + ); + return vec![]; + } }; let discovery = DiscoveryEngine::new(graph_snap.as_ref(), conventions_clone); let uri_str = format!("file://{}", path.display()); let uri: lsp_types::Uri = match uri_str.parse() { Ok(u) => u, - Err(_) => return vec![], + Err(e) => { + tracing::warn!( + "find_incoming_calls failed to parse URI {}: {}", + uri_str, + e + ); + return vec![]; + } }; // Verification @@ -513,7 +531,9 @@ impl CallHierarchyAnalyzer for EngineHandle { None => return Ok(vec![]), }; - let node = graph.get_node(node_idx).unwrap(); + let node = graph + .get_node(node_idx) + .ok_or_else(|| ApiError::Internal(format!("missing node for index {}", node_idx.index())))?; let symbols = graph.symbols(); let path_str = node .path(symbols) @@ -549,7 +569,9 @@ impl CallHierarchyAnalyzer for EngineHandle { if n_range.end_point.row < range.start_line { // Not in range, but children might be for i in 0..n.child_count() { - stack.push(n.child(i as u32).unwrap()); + if let Some(child) = n.child(i as u32) { + stack.push(child); + } } continue; } @@ -586,7 +608,9 @@ impl CallHierarchyAnalyzer for EngineHandle { // Recurse children for i in 0..n.child_count() { - stack.push(n.child(i as u32).unwrap()); + if let Some(child) = n.child(i as u32) { + stack.push(child); + } } } @@ -624,11 +648,7 @@ impl SymbolInfoProvider for EngineHandle { } async fn get_document_symbols(&self, uri: &str) -> ApiResult> { - let path = if uri.starts_with("file://") { - PathBuf::from(uri.strip_prefix("file://").unwrap()) - } else { - PathBuf::from(uri) - }; + let path = path_from_uri_like(uri); let (semantic, _lang) = match self.get_services_for_path(&path) { Some(x) => x, @@ -652,12 +672,26 @@ impl SymbolInfoProvider for EngineHandle { } async fn get_language_for_document(&self, uri: &str) -> ApiResult> { - let path = if uri.starts_with("file://") { - PathBuf::from(uri.strip_prefix("file://").unwrap()) - } else { - PathBuf::from(uri) - }; + let path = path_from_uri_like(uri); Ok(self.get_language_for_path(&path)) } } + +#[cfg(test)] +mod tests { + use super::path_from_uri_like; + use std::path::PathBuf; + + #[test] + fn path_from_uri_like_handles_file_uri() { + let p = path_from_uri_like("file:///tmp/naviscope_test.java"); + assert_eq!(p, PathBuf::from("/tmp/naviscope_test.java")); + } + + #[test] + fn path_from_uri_like_keeps_plain_path() { + let p = path_from_uri_like("/tmp/naviscope_test.java"); + assert_eq!(p, PathBuf::from("/tmp/naviscope_test.java")); + } +} diff --git a/crates/core/src/indexing/source/stub_ops.rs b/crates/core/src/indexing/source/stub_ops.rs index 4498a68..745606a 100644 --- a/crates/core/src/indexing/source/stub_ops.rs +++ b/crates/core/src/indexing/source/stub_ops.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use std::{collections::HashMap, path::PathBuf}; +use naviscope_api::models::EdgeType; use naviscope_api::models::graph::NodeSource; use naviscope_plugin::{AssetEntry, AssetSource, LanguageCaps}; @@ -85,17 +86,9 @@ pub fn generate_stub_ops( } for asset_path in &req.candidate_paths { + let entry = AssetEntry::new(asset_path.clone(), AssetSource::Unknown); let asset_key = crate::cache::AssetKey::from_path(asset_path).ok(); - if let Some(ref key) = asset_key - && let Some(cached_stub) = stub_cache.lookup(key, &req.fqn) - { - ops.push(GraphOp::AddNode { - data: Some(cached_stub), - }); - break; - } - for caps in lang_caps.iter() { let Some(generator) = caps.asset.stub_generator() else { continue; @@ -103,20 +96,56 @@ pub fn generate_stub_ops( if !generator.can_generate(asset_path) { continue; } + let cached_primary = asset_key + .as_ref() + .and_then(|k| stub_cache.lookup(k, &req.fqn)); + + match generator.generate_stubs(&req.fqn, &entry) { + Ok(mut nodes) => { + if let Some(cached) = cached_primary { + let cached_fqn = cached.id.to_string(); + if !nodes.iter().any(|n| n.id.to_string() == cached_fqn) { + nodes.insert(0, cached); + } + } - let entry = AssetEntry::new(asset_path.clone(), AssetSource::Unknown); - match generator.generate(&req.fqn, &entry) { - Ok(stub) => { - if let Some(ref key) = asset_key { - stub_cache.store(key, &stub); + if nodes.is_empty() { + continue; + } + + let primary_fqn = nodes + .iter() + .find(|n| n.id.to_string() == req.fqn) + .map(|n| n.id.to_string()) + .unwrap_or_else(|| nodes[0].id.to_string()); + + if let Some(ref key) = asset_key + && let Some(primary) = nodes.iter().find(|n| n.id.to_string() == req.fqn) + { + stub_cache.store(key, primary); + } + + let mut seen = std::collections::HashSet::new(); + for node in nodes { + let fqn = node.id.to_string(); + if !seen.insert(fqn.clone()) { + continue; + } + ops.push(GraphOp::AddNode { data: Some(node) }); + if fqn != primary_fqn { + ops.push(GraphOp::AddEdge { + from_id: naviscope_api::models::symbol::NodeId::Flat( + primary_fqn.clone(), + ), + to_id: naviscope_api::models::symbol::NodeId::Flat(fqn), + edge: naviscope_api::models::GraphEdge::new(EdgeType::Contains), + }); + } } - ops.push(GraphOp::AddNode { data: Some(stub) }); break; } - Err(err) => { - tracing::debug!("Failed to generate stub for {}: {}", req.fqn, err); - } - } + Err(err) => tracing::debug!("Failed to generate stub for {}: {}", req.fqn, err), + }; } if !ops.is_empty() { diff --git a/crates/core/tests/async_stubbing.rs b/crates/core/tests/async_stubbing.rs index ad447f0..a4403a4 100644 --- a/crates/core/tests/async_stubbing.rs +++ b/crates/core/tests/async_stubbing.rs @@ -1,12 +1,46 @@ //! Tests for async stubbing workflow use naviscope_api::models::graph::ResolutionStatus; +use naviscope_api::graph::GraphService; +use naviscope_api::models::{PositionContext, SymbolResolution}; +use naviscope_api::semantic::SymbolInfoProvider; +use naviscope_api::semantic::SymbolNavigator; use naviscope_core::indexing::source::plan_stub_requests; +use naviscope_core::facade::EngineHandle; use naviscope_core::model::GraphOp; use naviscope_core::runtime::NaviscopeEngine; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; +use std::sync::Once; + +fn ensure_test_index_dir() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + let dir = std::env::temp_dir().join("naviscope_test_index_dir_async_stubbing"); + std::fs::create_dir_all(&dir).unwrap(); + unsafe { + std::env::set_var("NAVISCOPE_INDEX_DIR", dir); + } + }); +} + +fn offset_to_point(content: &str, offset: usize) -> (usize, usize) { + let mut line = 0usize; + let mut col = 0usize; + for (i, ch) in content.char_indices() { + if i >= offset { + break; + } + if ch == '\n' { + line += 1; + col = 0; + } else { + col += ch.len_utf16(); + } + } + (line, col) +} /// Test that JavaPlugin correctly reports external asset handling #[test] @@ -32,6 +66,7 @@ fn test_java_plugin_handles_external_assets() { #[tokio::test] async fn test_async_stubbing_with_jar() { use std::time::Duration; + ensure_test_index_dir(); // Create a temporary directory for the test project let temp_dir = std::env::temp_dir().join("naviscope_stub_test"); @@ -89,3 +124,250 @@ async fn test_async_stubbing_with_jar() { let _ = std::fs::remove_dir_all(&temp_dir); } + +/// Ensure realtime stub requests can materialize external symbols once source runtime is started. +#[tokio::test] +async fn test_realtime_stub_hydration_after_runtime_started() { + use std::time::Duration; + ensure_test_index_dir(); + + let temp_dir = std::env::temp_dir().join("naviscope_stub_runtime_started"); + if temp_dir.exists() { + let _ = std::fs::remove_dir_all(&temp_dir); + } + std::fs::create_dir_all(&temp_dir).unwrap(); + + let java_home = std::env::var("JAVA_HOME").ok(); + let jmod_path = java_home + .as_ref() + .map(|h| PathBuf::from(h).join("jmods").join("java.base.jmod")); + let Some(_jmod) = jmod_path.filter(|p| p.exists()) else { + println!("Skipping realtime stub hydration test: JAVA_HOME not set or jmods not found"); + let _ = std::fs::remove_dir_all(&temp_dir); + return; + }; + + let java_caps = naviscope_java::java_caps().expect("Failed to create Java caps"); + let engine = Arc::new( + NaviscopeEngine::builder(temp_dir.clone()) + .with_language_caps(java_caps) + .build(), + ); + + // Build+source update starts source runtime lazily via submit_source_stream. + let java_file = temp_dir.join("Bootstrap.java"); + std::fs::write(&java_file, "class Bootstrap {}").unwrap(); + engine.update_files(vec![java_file]).await.unwrap(); + let _ = engine.scan_global_assets().await; + + assert!(engine.request_stub_for_fqn("java.lang.String")); + + let handle = EngineHandle::from_engine(Arc::clone(&engine)); + let mut found = None; + for _ in 0..20 { + if let Ok(Some(node)) = handle.get_node_display("java.lang.String").await { + found = Some(node); + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + + let node = found.expect("expected java.lang.String to be hydrated from stub request"); + assert_eq!(node.source, naviscope_api::models::NodeSource::External); + + // Also verify SymbolInfoProvider path (used by hover chain) can resolve hydrated stub symbol. + let symbol_info = handle + .get_symbol_info("java.lang.String") + .await + .expect("get_symbol_info should not fail") + .expect("java.lang.String should exist after stub hydration"); + assert_eq!(symbol_info.source, naviscope_api::models::NodeSource::External); + assert_eq!(symbol_info.id, "java.lang.String"); + + let _ = std::fs::remove_dir_all(&temp_dir); +} + +/// Verify overload resolution remains precise for external/stub symbols. +#[tokio::test] +async fn test_external_stub_overload_resolution_is_precise() { + use std::time::Duration; + ensure_test_index_dir(); + + let temp_dir = std::env::temp_dir().join("naviscope_stub_overload_precise"); + if temp_dir.exists() { + let _ = std::fs::remove_dir_all(&temp_dir); + } + std::fs::create_dir_all(&temp_dir).unwrap(); + + let java_home = std::env::var("JAVA_HOME").ok(); + let jmod_path = java_home + .as_ref() + .map(|h| PathBuf::from(h).join("jmods").join("java.base.jmod")); + let Some(_jmod) = jmod_path.filter(|p| p.exists()) else { + println!("Skipping external overload test: JAVA_HOME not set or jmods not found"); + let _ = std::fs::remove_dir_all(&temp_dir); + return; + }; + + let java_caps = naviscope_java::java_caps().expect("Failed to create Java caps"); + let engine = Arc::new( + NaviscopeEngine::builder(temp_dir.clone()) + .with_language_caps(java_caps) + .build(), + ); + + let file = temp_dir.join("Use.java"); + let source = r#" +class Use { + void run() { + "abc".indexOf(97); + "abc".indexOf("a"); + } +} +"#; + std::fs::write(&file, source).unwrap(); + + // Start source runtime and index the source file. + engine.update_files(vec![file.clone()]).await.unwrap(); + let _ = engine.scan_global_assets().await; + assert!(engine.request_stub_for_fqn("java.lang.String")); + + let handle = EngineHandle::from_engine(Arc::clone(&engine)); + + // Ensure base class stub is hydrated first. + let mut string_ready = false; + for _ in 0..20 { + if let Ok(Some(node)) = handle.get_node_display("java.lang.String").await + && node.source == naviscope_api::models::NodeSource::External + { + string_ready = true; + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + assert!(string_ready, "java.lang.String should be hydrated before overload checks"); + + let call_int = source.find("indexOf(97)").expect("indexOf(97) should exist"); + let (line_int, col_int) = offset_to_point(source, call_int); + let call_str = source + .find("indexOf(\"a\")") + .expect("indexOf(\"a\") should exist"); + let (line_str, col_str) = offset_to_point(source, call_str); + + let mut maybe_int = None; + let mut maybe_bool = None; + for _ in 0..30 { + maybe_int = handle + .resolve_symbol_at(&PositionContext { + uri: format!("file://{}", file.display()), + line: line_int as u32, + char: col_int as u32, + content: Some(source.to_string()), + }) + .await + .ok() + .flatten(); + maybe_bool = handle + .resolve_symbol_at(&PositionContext { + uri: format!("file://{}", file.display()), + line: line_str as u32, + char: col_str as u32, + content: Some(source.to_string()), + }) + .await + .ok() + .flatten(); + + if maybe_int.is_some() && maybe_bool.is_some() { + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + + let res_int = maybe_int.expect("int call should resolve"); + let res_bool = maybe_bool.expect("bool call should resolve"); + + let fqn_int = match res_int { + SymbolResolution::Precise(fqn, _) | SymbolResolution::Global(fqn) => fqn, + SymbolResolution::Local(_, _) => panic!("expected non-local resolution for int call"), + }; + let fqn_bool = match res_bool { + SymbolResolution::Precise(fqn, _) | SymbolResolution::Global(fqn) => fqn, + SymbolResolution::Local(_, _) => panic!("expected non-local resolution for string call"), + }; + + assert_ne!( + fqn_int, fqn_bool, + "different indexOf overload callsites should resolve to different FQNs" + ); + assert!( + fqn_int.starts_with("java.lang.String#indexOf(") + && fqn_bool.starts_with("java.lang.String#indexOf("), + "both resolutions should map to String.indexOf overloads" + ); + + let _ = std::fs::remove_dir_all(&temp_dir); +} + +/// Ensure stub requests queued before source runtime startup are replayed after runtime starts. +#[tokio::test] +async fn test_stub_request_replayed_after_runtime_start() { + use std::time::Duration; + ensure_test_index_dir(); + + let temp_dir = std::env::temp_dir().join("naviscope_stub_replay_after_start"); + if temp_dir.exists() { + let _ = std::fs::remove_dir_all(&temp_dir); + } + std::fs::create_dir_all(&temp_dir).unwrap(); + + let java_home = std::env::var("JAVA_HOME").ok(); + let jmod_path = java_home + .as_ref() + .map(|h| PathBuf::from(h).join("jmods").join("java.base.jmod")); + let Some(_jmod) = jmod_path.filter(|p| p.exists()) else { + println!("Skipping pending-stub replay test: JAVA_HOME not set or jmods not found"); + let _ = std::fs::remove_dir_all(&temp_dir); + return; + }; + + let java_caps = naviscope_java::java_caps().expect("Failed to create Java caps"); + let engine = Arc::new( + NaviscopeEngine::builder(temp_dir.clone()) + .with_language_caps(java_caps) + .build(), + ); + + // Build asset routes first; runtime is still not started. + let _ = engine.scan_global_assets().await; + assert!( + engine.request_stub_for_fqn("java.lang.String"), + "request should be accepted and queued before runtime start" + ); + + let handle = EngineHandle::from_engine(Arc::clone(&engine)); + let before_start = handle.get_node_display("java.lang.String").await.unwrap(); + assert!( + before_start.is_none(), + "queued request should not materialize symbol before runtime start" + ); + + // Trigger runtime startup through source update. + let java_file = temp_dir.join("Boot.java"); + std::fs::write(&java_file, "class Boot {}").unwrap(); + engine.update_files(vec![java_file]).await.unwrap(); + + let mut found = None; + for _ in 0..20 { + if let Ok(Some(node)) = handle.get_node_display("java.lang.String").await { + found = Some(node); + break; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + + let node = found.expect("queued stub request should be replayed after runtime starts"); + assert_eq!(node.source, naviscope_api::models::NodeSource::External); + + let _ = std::fs::remove_dir_all(&temp_dir); +} diff --git a/crates/core/tests/incremental_consistency.rs b/crates/core/tests/incremental_consistency.rs new file mode 100644 index 0000000..9c191d8 --- /dev/null +++ b/crates/core/tests/incremental_consistency.rs @@ -0,0 +1,202 @@ +use naviscope_api::models::{Language, PositionContext, ReferenceQuery, SymbolQuery, SymbolResolution}; +use naviscope_api::semantic::{ReferenceAnalyzer, SymbolInfoProvider, SymbolNavigator}; +use naviscope_core::facade::EngineHandle; +use naviscope_core::runtime::NaviscopeEngine as CoreEngine; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::Once; + +fn ensure_test_index_dir() { + static INIT: Once = Once::new(); + INIT.call_once(|| { + let dir = std::env::temp_dir().join("naviscope_test_index_dir_incremental_consistency"); + std::fs::create_dir_all(&dir).unwrap(); + unsafe { + std::env::set_var("NAVISCOPE_INDEX_DIR", dir); + } + }); +} + +fn offset_to_point(content: &str, offset: usize) -> (usize, usize) { + let mut line = 0usize; + let mut col = 0usize; + for (i, ch) in content.char_indices() { + if i >= offset { + break; + } + if ch == '\n' { + line += 1; + col = 0; + } else { + col += ch.len_utf16(); + } + } + (line, col) +} + +fn normalize_path(path: &Path) -> String { + path.file_name() + .and_then(|s| s.to_str()) + .unwrap_or_default() + .to_string() +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Observation { + resolved_fqn: String, + defs: Vec<(String, usize, usize, usize, usize)>, + refs: Vec<(String, usize, usize, usize, usize)>, + symbol_id: String, + symbol_source: naviscope_api::models::NodeSource, +} + +async fn observe( + engine: Arc, + use_path: &Path, + use_content: &str, +) -> Observation { + let handle = EngineHandle::from_engine(engine); + let call_offset = use_content + .find("target();") + .expect("use file should contain target() call"); + let (line, col) = offset_to_point(use_content, call_offset); + + let ctx = PositionContext { + uri: format!("file://{}", use_path.display()), + line: line as u32, + char: col as u32, + content: Some(use_content.to_string()), + }; + + let resolution = handle + .resolve_symbol_at(&ctx) + .await + .unwrap() + .expect("callsite should resolve"); + + let resolved_fqn = match &resolution { + SymbolResolution::Precise(fqn, _) | SymbolResolution::Global(fqn) => fqn.clone(), + SymbolResolution::Local(_, _) => panic!("method call should not resolve as local"), + }; + + let query = SymbolQuery { + language: Language::JAVA, + resolution: resolution.clone(), + }; + + let mut defs: Vec<_> = handle + .find_definitions(&query) + .await + .unwrap() + .into_iter() + .map(|loc| { + ( + normalize_path(&loc.path), + loc.range.start_line, + loc.range.start_col, + loc.range.end_line, + loc.range.end_col, + ) + }) + .collect(); + defs.sort(); + + let mut refs: Vec<_> = handle + .find_references(&ReferenceQuery { + language: Language::JAVA, + resolution, + include_declaration: false, + }) + .await + .unwrap() + .into_iter() + .map(|loc| { + ( + normalize_path(&loc.path), + loc.range.start_line, + loc.range.start_col, + loc.range.end_line, + loc.range.end_col, + ) + }) + .collect(); + refs.sort(); + + let info = handle + .get_symbol_info(&resolved_fqn) + .await + .unwrap() + .expect("resolved symbol should have display info"); + + Observation { + resolved_fqn, + defs, + refs, + symbol_id: info.id, + symbol_source: info.source, + } +} + +#[tokio::test] +async fn test_incremental_vs_full_consistency_for_java_navigation() { + ensure_test_index_dir(); + let full_dir = std::env::temp_dir().join("naviscope_full_consistency_java"); + let inc_dir = std::env::temp_dir().join("naviscope_inc_consistency_java"); + + for dir in [&full_dir, &inc_dir] { + if dir.exists() { + let _ = std::fs::remove_dir_all(dir); + } + std::fs::create_dir_all(dir.join("com/example")).unwrap(); + } + + let a_rel = PathBuf::from("com/example/A.java"); + let use_rel = PathBuf::from("com/example/Use.java"); + let a_src = "package com.example; public class A { void target() {} }"; + let use_src = r#" +package com.example; +public class Use { + void run() { + new A().target(); + } +} +"#; + + std::fs::write(full_dir.join(&a_rel), a_src).unwrap(); + std::fs::write(full_dir.join(&use_rel), use_src).unwrap(); + std::fs::write(inc_dir.join(&a_rel), a_src).unwrap(); + std::fs::write(inc_dir.join(&use_rel), use_src).unwrap(); + + let java_caps_full = naviscope_java::java_caps().expect("Failed to create Java caps"); + let engine_full = Arc::new( + CoreEngine::builder(full_dir.clone()) + .with_language_caps(java_caps_full) + .build(), + ); + engine_full + .update_files(vec![full_dir.join(&a_rel), full_dir.join(&use_rel)]) + .await + .unwrap(); + let full_obs = observe(Arc::clone(&engine_full), &full_dir.join(&use_rel), use_src).await; + + let java_caps_inc = naviscope_java::java_caps().expect("Failed to create Java caps"); + let engine_inc = Arc::new( + CoreEngine::builder(inc_dir.clone()) + .with_language_caps(java_caps_inc) + .build(), + ); + engine_inc + .update_files(vec![inc_dir.join(&a_rel)]) + .await + .unwrap(); + engine_inc + .update_files(vec![inc_dir.join(&use_rel)]) + .await + .unwrap(); + let inc_obs = observe(Arc::clone(&engine_inc), &inc_dir.join(&use_rel), use_src).await; + + assert_eq!(full_obs, inc_obs, "incremental and full observations must match"); + + let _ = std::fs::remove_dir_all(&full_dir); + let _ = std::fs::remove_dir_all(&inc_dir); +} diff --git a/crates/lang-java/src/resolve/external/mod.rs b/crates/lang-java/src/resolve/external/mod.rs index 4599960..3c52b76 100644 --- a/crates/lang-java/src/resolve/external/mod.rs +++ b/crates/lang-java/src/resolve/external/mod.rs @@ -66,6 +66,155 @@ impl JavaExternalResolver { } impl JavaExternalResolver { + fn load_class_bytes_for_fqn( + &self, + class_fqn: &str, + asset: &Path, + ) -> std::result::Result, Box> { + let file = File::open(asset)?; + + if let Ok(mut archive) = ZipArchive::new(file) { + let class_path = class_fqn.replace('.', "/") + ".class"; + if let Ok(mut entry) = archive.by_name(&class_path) { + let mut b = Vec::new(); + entry.read_to_end(&mut b)?; + return Ok(b); + } + + let inner_path = class_fqn.replace('.', "/"); + if let Some(idx) = inner_path.rfind('/') { + let mut try_inner = inner_path.clone(); + try_inner.replace_range(idx..idx + 1, "$"); + if let Ok(mut entry) = archive.by_name(&(try_inner + ".class")) { + let mut b = Vec::new(); + entry.read_to_end(&mut b)?; + return Ok(b); + } + } + } else { + let image = Image::from_file(asset)?; + let class_path = class_fqn.replace('.', "/") + ".class"; + for resource_result in image.iter() { + if let Ok(resource) = resource_result { + let name = resource.name(); + if name == class_path || name.ends_with(&format!("/{}", class_path)) { + return Ok(resource.data().to_vec()); + } + } + } + + let inner_path = class_fqn.replace('.', "/"); + if let Some(idx) = inner_path.rfind('/') { + let mut try_inner = inner_path.clone(); + try_inner.replace_range(idx..idx + 1, "$"); + let try_inner_path = try_inner + ".class"; + + for resource_result in image.iter() { + if let Ok(resource) = resource_result { + let name = resource.name(); + if name == try_inner_path || name.ends_with(&format!("/{}", try_inner_path)) + { + return Ok(resource.data().to_vec()); + } + } + } + } + } + + Err(format!("Class {} not found in {}", class_fqn, asset.display()).into()) + } + + fn generate_related_for_class( + &self, + class_fqn: &str, + asset: &Path, + ) -> std::result::Result, Box> { + let bytes = self.load_class_bytes_for_fqn(class_fqn, asset)?; + let class = ClassFile::from_bytes(&mut Cursor::new(bytes)) + .map_err(|e| format!("Failed to parse class: {e:?}"))?; + + let mut out = Vec::new(); + let class_simple_name = class_fqn.split('.').next_back().unwrap_or(class_fqn); + + for field in &class.fields { + let field_name = class + .constant_pool + .try_get_utf8(field.name_index) + .map_err(|e| format!("Failed to parse field name: {e:?}"))?; + let node_fqn = crate::naming::build_member_fqn(class_fqn, field_name); + let type_ref = JavaTypeConverter::convert_field(&field.field_type); + let modifiers = JavaModifierConverter::parse_field(field.access_flags); + let metadata = crate::model::JavaIndexMetadata::Field { + modifiers, + type_ref, + }; + out.push(IndexNode { + id: naviscope_api::models::symbol::NodeId::Flat(node_fqn), + name: field_name.to_string(), + kind: naviscope_api::models::graph::NodeKind::Field, + lang: "java".to_string(), + source: naviscope_api::models::graph::NodeSource::External, + status: naviscope_api::models::graph::ResolutionStatus::Stubbed, + location: None, + metadata: Arc::new(metadata), + }); + } + + for method in &class.methods { + let method_name = class + .constant_pool + .try_get_utf8(method.name_index) + .map_err(|e| format!("Failed to parse method name: {e:?}"))?; + + if method_name == "" { + continue; + } + + let method_descriptor = class + .constant_pool + .try_get_utf8(method.descriptor_index) + .map_err(|e| format!("Failed to parse method descriptor: {e:?}"))?; + let is_varargs = method.access_flags.contains(MethodAccessFlags::VARARGS); + let (return_type, parameters) = + JavaTypeConverter::convert_method(method_descriptor, is_varargs) + .map_err(|e| format!("Failed to parse method signature: {e:?}"))?; + + let display_name = if method_name == "" { + class_simple_name.to_string() + } else { + method_name.to_string() + }; + let param_types: Vec = + parameters.iter().map(|p| p.type_ref.clone()).collect(); + let signed_name = crate::naming::build_java_method_name(&display_name, ¶m_types); + let node_fqn = crate::naming::build_member_fqn(class_fqn, &signed_name); + + let modifiers = JavaModifierConverter::parse_method(method.access_flags); + let metadata = crate::model::JavaIndexMetadata::Method { + modifiers, + return_type, + parameters, + is_constructor: method_name == "", + }; + out.push(IndexNode { + id: naviscope_api::models::symbol::NodeId::Flat(node_fqn), + name: display_name, + kind: if method_name == "" { + naviscope_api::models::graph::NodeKind::Constructor + } else { + naviscope_api::models::graph::NodeKind::Method + }, + lang: "java".to_string(), + source: naviscope_api::models::graph::NodeSource::External, + status: naviscope_api::models::graph::ResolutionStatus::Stubbed, + location: None, + metadata: Arc::new(metadata), + }); + } + + Ok(out) + } + pub fn index_asset( &self, asset: &Path, @@ -359,12 +508,20 @@ impl StubGenerator for JavaExternalResolver { self.can_index(asset) } - fn generate( + fn generate_stubs( &self, fqn: &str, entry: &AssetEntry, - ) -> std::result::Result> { - self.generate_stub(fqn, &entry.path) + ) -> std::result::Result, Box> { + if fqn.contains('#') { + return Ok(vec![self.generate_stub(fqn, &entry.path)?]); + } + let primary = self.generate_stub(fqn, &entry.path)?; + let mut nodes = vec![primary]; + if let Ok(related) = self.generate_related_for_class(fqn, &entry.path) { + nodes.extend(related); + } + Ok(nodes) } } diff --git a/crates/lsp/src/goto.rs b/crates/lsp/src/goto.rs index 3dc8668..16b7a7b 100644 --- a/crates/lsp/src/goto.rs +++ b/crates/lsp/src/goto.rs @@ -1,8 +1,25 @@ use crate::LspServer; -use naviscope_api::models::{PositionContext, SymbolQuery, SymbolResolution}; +use naviscope_api::models::{PositionContext, SymbolLocation, SymbolQuery, SymbolResolution}; use tower_lsp::jsonrpc::Result; use tower_lsp::lsp_types::*; +fn to_lsp_location(loc: SymbolLocation) -> Option { + let uri = match Url::from_file_path(&*loc.path) { + Ok(uri) => uri, + Err(()) => { + tracing::warn!("failed to convert definition path to file URL: {:?}", loc.path); + return None; + } + }; + Some(Location { + uri, + range: Range { + start: Position::new(loc.range.start_line as u32, loc.range.start_col as u32), + end: Position::new(loc.range.end_line as u32, loc.range.end_col as u32), + }, + }) +} + pub async fn definition( server: &LspServer, params: GotoDefinitionParams, @@ -32,7 +49,10 @@ pub async fn definition( let resolution = match engine.resolve_symbol_at(&ctx).await { Ok(Some(r)) => r, Ok(None) => return Ok(None), - Err(_) => return Ok(None), // Log error? + Err(e) => { + tracing::warn!("definition resolve_symbol_at failed for {}: {}", uri, e); + return Ok(None); + } }; if let SymbolResolution::Local(range, _) = resolution { @@ -62,19 +82,13 @@ pub async fn definition( let definitions = match engine.find_definitions(&query).await { Ok(defs) => defs, - Err(_) => return Ok(None), + Err(e) => { + tracing::warn!("find_definitions failed for {}: {}", uri, e); + return Ok(None); + } }; - let locations: Vec = definitions - .into_iter() - .map(|loc| Location { - uri: Url::from_file_path(&*loc.path).unwrap(), - range: Range { - start: Position::new(loc.range.start_line as u32, loc.range.start_col as u32), - end: Position::new(loc.range.end_line as u32, loc.range.end_col as u32), - }, - }) - .collect(); + let locations: Vec = definitions.into_iter().filter_map(to_lsp_location).collect(); if !locations.is_empty() { if locations.len() == 1 { @@ -112,7 +126,10 @@ pub async fn type_definition( let resolution = match engine.resolve_symbol_at(&ctx).await { Ok(Some(r)) => r, Ok(None) => return Ok(None), - Err(_) => return Ok(None), + Err(e) => { + tracing::warn!("type_definition resolve_symbol_at failed for {}: {}", uri, e); + return Ok(None); + } }; let language = match server.documents.get(&uri).map(|d| d.language.clone()) { @@ -127,19 +144,13 @@ pub async fn type_definition( let locations = match engine.find_type_definitions(&query).await { Ok(locs) => locs, - Err(_) => return Ok(None), + Err(e) => { + tracing::warn!("find_type_definitions failed for {}: {}", uri, e); + return Ok(None); + } }; - let lsp_locations: Vec = locations - .into_iter() - .map(|loc| Location { - uri: Url::from_file_path(&*loc.path).unwrap(), - range: Range { - start: Position::new(loc.range.start_line as u32, loc.range.start_col as u32), - end: Position::new(loc.range.end_line as u32, loc.range.end_col as u32), - }, - }) - .collect(); + let lsp_locations: Vec = locations.into_iter().filter_map(to_lsp_location).collect(); if !lsp_locations.is_empty() { return Ok(Some(GotoDefinitionResponse::Array(lsp_locations))); @@ -172,7 +183,10 @@ pub async fn references( let resolution = match engine.resolve_symbol_at(&ctx).await { Ok(Some(r)) => r, Ok(None) => return Ok(None), - Err(_) => return Ok(None), + Err(e) => { + tracing::warn!("references resolve_symbol_at failed for {}: {}", uri, e); + return Ok(None); + } }; let language = match server.documents.get(&uri).map(|d| d.language.clone()) { @@ -202,7 +216,10 @@ pub async fn references( let locations = match engine.find_references(&query).await { Ok(locs) => locs, - Err(_) => return Ok(None), + Err(e) => { + tracing::warn!("find_references failed for {}: {}", uri, e); + return Ok(None); + } }; // If local references are found by engine, they are returned. @@ -211,16 +228,7 @@ pub async fn references( // `scout_references` usually returns files containing the token. This includes the current file. // So the current file should be in the list and scanned. - let lsp_locations: Vec = locations - .into_iter() - .map(|loc| Location { - uri: Url::from_file_path(&*loc.path).unwrap(), - range: Range { - start: Position::new(loc.range.start_line as u32, loc.range.start_col as u32), - end: Position::new(loc.range.end_line as u32, loc.range.end_col as u32), - }, - }) - .collect(); + let lsp_locations: Vec = locations.into_iter().filter_map(to_lsp_location).collect(); if !lsp_locations.is_empty() { return Ok(Some(lsp_locations)); @@ -253,7 +261,10 @@ pub async fn implementation( let resolution = match engine.resolve_symbol_at(&ctx).await { Ok(Some(r)) => r, Ok(None) => return Ok(None), - Err(_) => return Ok(None), + Err(e) => { + tracing::warn!("implementation resolve_symbol_at failed for {}: {}", uri, e); + return Ok(None); + } }; let language = match server.documents.get(&uri).map(|d| d.language.clone()) { @@ -268,19 +279,13 @@ pub async fn implementation( let locations = match engine.find_implementations(&query).await { Ok(locs) => locs, - Err(_) => return Ok(None), + Err(e) => { + tracing::warn!("find_implementations failed for {}: {}", uri, e); + return Ok(None); + } }; - let lsp_locations: Vec = locations - .into_iter() - .map(|loc| Location { - uri: Url::from_file_path(&*loc.path).unwrap(), - range: Range { - start: Position::new(loc.range.start_line as u32, loc.range.start_col as u32), - end: Position::new(loc.range.end_line as u32, loc.range.end_col as u32), - }, - }) - .collect(); + let lsp_locations: Vec = locations.into_iter().filter_map(to_lsp_location).collect(); if !lsp_locations.is_empty() { return Ok(Some(GotoDefinitionResponse::Array(lsp_locations))); @@ -288,3 +293,44 @@ pub async fn implementation( Ok(None) } + +#[cfg(test)] +mod tests { + use super::to_lsp_location; + use naviscope_api::models::{Range as ApiRange, SymbolLocation}; + use std::path::PathBuf; + use std::sync::Arc; + + #[test] + fn to_lsp_location_handles_absolute_path() { + let abs = std::env::temp_dir().join("naviscope_goto_test.java"); + let loc = SymbolLocation { + path: Arc::from(abs.as_path()), + range: ApiRange { + start_line: 1, + start_col: 2, + end_line: 3, + end_col: 4, + }, + selection_range: None, + }; + + let mapped = to_lsp_location(loc).expect("absolute path should map"); + assert_eq!(mapped.range.start.line, 1); + assert_eq!(mapped.range.start.character, 2); + assert_eq!(mapped.range.end.line, 3); + assert_eq!(mapped.range.end.character, 4); + } + + #[test] + fn to_lsp_location_rejects_relative_path() { + let rel = PathBuf::from("relative/path/Foo.java"); + let loc = SymbolLocation { + path: Arc::from(rel.as_path()), + range: ApiRange::default(), + selection_range: None, + }; + + assert!(to_lsp_location(loc).is_none()); + } +} diff --git a/crates/lsp/src/hierarchy.rs b/crates/lsp/src/hierarchy.rs index 404ee97..10579aa 100644 --- a/crates/lsp/src/hierarchy.rs +++ b/crates/lsp/src/hierarchy.rs @@ -1,8 +1,54 @@ use crate::LspServer; -use naviscope_api::models::{PositionContext, SymbolResolution}; +use naviscope_api::models::{DisplayGraphNode, PositionContext, SymbolResolution}; use tower_lsp::jsonrpc::Result; use tower_lsp::lsp_types::*; +fn parse_hierarchy_fqn(data: Option) -> Option { + match serde_json::from_value(data.unwrap_or_default()) { + Ok(fqn) => Some(fqn), + Err(e) => { + tracing::warn!("failed to parse call hierarchy item data: {}", e); + None + } + } +} + +fn build_call_hierarchy_item(info: DisplayGraphNode, fqn: String) -> Option { + let loc = info.location.as_ref()?; + let lsp_range = Range { + start: Position::new(loc.range.start_line as u32, loc.range.start_col as u32), + end: Position::new(loc.range.end_line as u32, loc.range.end_col as u32), + }; + let uri = match Url::from_file_path(&loc.path) { + Ok(uri) => uri, + Err(()) => { + tracing::warn!( + "prepare_call_hierarchy failed to convert path to file URL: {:?}", + loc.path + ); + return None; + } + }; + let data = match serde_json::to_value(fqn.clone()) { + Ok(v) => Some(v), + Err(e) => { + tracing::warn!("prepare_call_hierarchy failed to serialize fqn: {}", e); + None + } + }; + + Some(CallHierarchyItem { + name: info.name, + kind: SymbolKind::METHOD, // Default for call hierarchy + tags: None, + detail: Some(fqn), + uri, + range: lsp_range, + selection_range: lsp_range, + data, + }) +} + pub async fn prepare_call_hierarchy( server: &LspServer, params: CallHierarchyPrepareParams, @@ -41,21 +87,9 @@ pub async fn prepare_call_hierarchy( _ => return Ok(None), }; - let loc = info.location.as_ref().expect("Symbol must have location"); - let lsp_range = Range { - start: Position::new(loc.range.start_line as u32, loc.range.start_col as u32), - end: Position::new(loc.range.end_line as u32, loc.range.end_col as u32), - }; - - let item = CallHierarchyItem { - name: info.name, - kind: SymbolKind::METHOD, // Default for call hierarchy - tags: None, - detail: Some(fqn.clone()), - uri: Url::from_file_path(&loc.path).unwrap(), - range: lsp_range, - selection_range: lsp_range, - data: Some(serde_json::to_value(fqn).unwrap()), + let Some(item) = build_call_hierarchy_item(info, fqn.clone()) else { + tracing::warn!("prepare_call_hierarchy missing/invalid location for {}", fqn); + return Ok(None); }; Ok(Some(vec![item])) @@ -65,8 +99,10 @@ pub async fn incoming_calls( server: &LspServer, params: CallHierarchyIncomingCallsParams, ) -> Result>> { - let fqn: String = - serde_json::from_value(params.item.data.unwrap_or_default()).unwrap_or_default(); + let fqn = match parse_hierarchy_fqn(params.item.data) { + Some(fqn) => fqn, + None => return Ok(None), + }; if fqn.is_empty() { return Ok(None); } @@ -79,31 +115,41 @@ pub async fn incoming_calls( let calls = match engine.find_incoming_calls(&fqn).await { Ok(c) => c, - Err(_) => return Ok(None), + Err(e) => { + tracing::warn!("find_incoming_calls failed for {}: {}", fqn, e); + return Ok(None); + } }; let lsp_calls: Vec = calls .into_iter() - .map(|item| { - let loc = item - .from - .location - .as_ref() - .expect("Caller must have location"); + .filter_map(|item| { + let loc = item.from.location.as_ref()?; let lsp_range = Range { start: Position::new(loc.range.start_line as u32, loc.range.start_col as u32), end: Position::new(loc.range.end_line as u32, loc.range.end_col as u32), }; - CallHierarchyIncomingCall { + let uri = match Url::from_file_path(&loc.path) { + Ok(uri) => uri, + Err(()) => { + tracing::warn!( + "incoming_calls failed to convert path to file URL: {:?}", + loc.path + ); + return None; + } + }; + let data = serde_json::to_value(item.from.id.clone()).ok(); + Some(CallHierarchyIncomingCall { from: CallHierarchyItem { name: item.from.name, kind: SymbolKind::METHOD, tags: None, detail: Some(item.from.id.clone()), - uri: Url::from_file_path(&loc.path).unwrap(), + uri, range: lsp_range, selection_range: lsp_range, - data: Some(serde_json::to_value(item.from.id).unwrap()), + data, }, from_ranges: item .from_ranges @@ -113,7 +159,7 @@ pub async fn incoming_calls( end: Position::new(r.end_line as u32, r.end_col as u32), }) .collect(), - } + }) }) .collect(); @@ -124,8 +170,10 @@ pub async fn outgoing_calls( server: &LspServer, params: CallHierarchyOutgoingCallsParams, ) -> Result>> { - let fqn: String = - serde_json::from_value(params.item.data.unwrap_or_default()).unwrap_or_default(); + let fqn = match parse_hierarchy_fqn(params.item.data) { + Some(fqn) => fqn, + None => return Ok(None), + }; if fqn.is_empty() { return Ok(None); } @@ -138,31 +186,41 @@ pub async fn outgoing_calls( let calls = match engine.find_outgoing_calls(&fqn).await { Ok(c) => c, - Err(_) => return Ok(None), + Err(e) => { + tracing::warn!("find_outgoing_calls failed for {}: {}", fqn, e); + return Ok(None); + } }; let lsp_calls: Vec = calls .into_iter() - .map(|item| { - let loc = item - .to - .location - .as_ref() - .expect("Callee must have location"); + .filter_map(|item| { + let loc = item.to.location.as_ref()?; let lsp_range = Range { start: Position::new(loc.range.start_line as u32, loc.range.start_col as u32), end: Position::new(loc.range.end_line as u32, loc.range.end_col as u32), }; - CallHierarchyOutgoingCall { + let uri = match Url::from_file_path(&loc.path) { + Ok(uri) => uri, + Err(()) => { + tracing::warn!( + "outgoing_calls failed to convert path to file URL: {:?}", + loc.path + ); + return None; + } + }; + let data = serde_json::to_value(item.to.id.clone()).ok(); + Some(CallHierarchyOutgoingCall { to: CallHierarchyItem { name: item.to.name, kind: SymbolKind::METHOD, tags: None, detail: Some(item.to.id.clone()), - uri: Url::from_file_path(&loc.path).unwrap(), + uri, range: lsp_range, selection_range: lsp_range, - data: Some(serde_json::to_value(item.to.id).unwrap()), + data, }, from_ranges: item .from_ranges @@ -172,9 +230,75 @@ pub async fn outgoing_calls( end: Position::new(r.end_line as u32, r.end_col as u32), }) .collect(), - } + }) }) .collect(); Ok(Some(lsp_calls)) } + +#[cfg(test)] +mod tests { + use super::{build_call_hierarchy_item, parse_hierarchy_fqn}; + use naviscope_api::models::graph::{DisplaySymbolLocation, NodeKind, NodeSource, ResolutionStatus}; + use naviscope_api::models::DisplayGraphNode; + use naviscope_api::models::Range as ApiRange; + + #[test] + fn parse_hierarchy_fqn_accepts_string_value() { + let data = Some(serde_json::Value::String("com.example.A#m()".to_string())); + let parsed = parse_hierarchy_fqn(data); + assert_eq!(parsed.as_deref(), Some("com.example.A#m()")); + } + + #[test] + fn parse_hierarchy_fqn_rejects_non_string_value() { + let data = Some(serde_json::json!({ "bad": true })); + assert!(parse_hierarchy_fqn(data).is_none()); + } + + #[test] + fn build_call_hierarchy_item_rejects_missing_location() { + let info = DisplayGraphNode { + id: "com.example.A#m()".to_string(), + name: "m".to_string(), + kind: NodeKind::Method, + lang: "java".to_string(), + source: NodeSource::Project, + status: ResolutionStatus::Resolved, + location: None, + detail: None, + signature: None, + modifiers: vec![], + children: None, + }; + assert!(build_call_hierarchy_item(info, "com.example.A#m()".to_string()).is_none()); + } + + #[test] + fn build_call_hierarchy_item_accepts_location() { + let info = DisplayGraphNode { + id: "com.example.A#m()".to_string(), + name: "m".to_string(), + kind: NodeKind::Method, + lang: "java".to_string(), + source: NodeSource::Project, + status: ResolutionStatus::Resolved, + location: Some(DisplaySymbolLocation { + path: "/tmp/naviscope_hierarchy_test.java".to_string(), + range: ApiRange { + start_line: 1, + start_col: 2, + end_line: 3, + end_col: 4, + }, + selection_range: None, + }), + detail: None, + signature: None, + modifiers: vec![], + children: None, + }; + assert!(build_call_hierarchy_item(info, "com.example.A#m()".to_string()).is_some()); + } +} diff --git a/crates/lsp/src/highlight.rs b/crates/lsp/src/highlight.rs index 6271699..9fc6e26 100644 --- a/crates/lsp/src/highlight.rs +++ b/crates/lsp/src/highlight.rs @@ -27,7 +27,10 @@ pub async fn highlight( let highlights = match engine.find_highlights(&ctx).await { Ok(h) => h, - Err(_) => return Ok(None), + Err(e) => { + tracing::warn!("find_highlights failed for {}: {}", uri, e); + return Ok(None); + } }; let lsp_highlights: Vec = highlights diff --git a/crates/lsp/src/hover.rs b/crates/lsp/src/hover.rs index c4f9732..4d5e5f0 100644 --- a/crates/lsp/src/hover.rs +++ b/crates/lsp/src/hover.rs @@ -31,6 +31,7 @@ fn format_fallback_hover( 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; + let content = server.documents.get(&uri).map(|d| d.content.clone()); let engine_lock = server.engine.read().await; let engine = match engine_lock.as_ref() { @@ -42,7 +43,7 @@ pub async fn hover(server: &LspServer, params: HoverParams) -> Result Result res, Ok(None) => return Ok(None), Err(e) => { - return Err(tower_lsp::jsonrpc::Error::invalid_params(format!( - "Resolution error: {}", - e - ))); + tracing::warn!("hover resolve_symbol_at failed for {}: {}", uri, e); + return Ok(None); } }; diff --git a/crates/lsp/src/symbols.rs b/crates/lsp/src/symbols.rs index 43bf402..b5de571 100644 --- a/crates/lsp/src/symbols.rs +++ b/crates/lsp/src/symbols.rs @@ -17,7 +17,10 @@ pub async fn document_symbol( let api_symbols = match engine.get_document_symbols(uri.as_str()).await { Ok(s) => s, - Err(_) => return Ok(None), + Err(e) => { + tracing::warn!("get_document_symbols failed for {}: {}", uri, e); + return Ok(None); + } }; let lsp_symbols = convert_api_symbols(api_symbols); @@ -25,11 +28,11 @@ pub async fn document_symbol( } fn convert_api_symbols(symbols: Vec) -> Vec { - symbols.into_iter().map(convert_api_symbol).collect() + symbols.into_iter().filter_map(convert_api_symbol).collect() } -fn convert_api_symbol(sym: DisplayGraphNode) -> DocumentSymbol { - let loc = sym.location.as_ref().expect("Symbol must have location"); +fn convert_api_symbol(sym: DisplayGraphNode) -> Option { + let loc = sym.location.as_ref()?; let range = Range { start: Position::new(loc.range.start_line as u32, loc.range.start_col as u32), end: Position::new(loc.range.end_line as u32, loc.range.end_col as u32), @@ -43,7 +46,7 @@ fn convert_api_symbol(sym: DisplayGraphNode) -> DocumentSymbol { .unwrap_or(range); #[allow(deprecated)] - DocumentSymbol { + Some(DocumentSymbol { name: sym.name, detail: sym.detail, kind: node_kind_to_symbol_kind(&sym.kind), @@ -52,7 +55,7 @@ fn convert_api_symbol(sym: DisplayGraphNode) -> DocumentSymbol { range, selection_range, children: sym.children.map(convert_api_symbols), - } + }) } fn node_kind_to_symbol_kind(kind: &NodeKind) -> SymbolKind { @@ -100,7 +103,10 @@ pub async fn workspace_symbol( let result = match engine.query(&query).await { Ok(r) => r, - Err(_) => return Ok(None), + Err(e) => { + tracing::warn!("workspace_symbol query failed: {}", e); + return Ok(None); + } }; let symbols: Vec = result @@ -131,3 +137,29 @@ pub async fn workspace_symbol( Ok(Some(symbols)) } + +#[cfg(test)] +mod tests { + use super::convert_api_symbols; + use naviscope_api::models::graph::{DisplayGraphNode, NodeKind, NodeSource, ResolutionStatus}; + + #[test] + fn convert_api_symbols_skips_entries_without_location() { + let symbols = vec![DisplayGraphNode { + id: "com.example.Missing".to_string(), + name: "Missing".to_string(), + kind: NodeKind::Class, + lang: "java".to_string(), + source: NodeSource::Project, + status: ResolutionStatus::Resolved, + location: None, + detail: None, + signature: None, + modifiers: vec![], + children: None, + }]; + + let converted = convert_api_symbols(symbols); + assert!(converted.is_empty()); + } +} diff --git a/crates/plugin/src/asset.rs b/crates/plugin/src/asset.rs index 056538e..9617dd6 100644 --- a/crates/plugin/src/asset.rs +++ b/crates/plugin/src/asset.rs @@ -144,9 +144,11 @@ pub trait StubGenerator: Send + Sync { /// Check if this generator can handle the asset fn can_generate(&self, asset: &Path) -> bool; - /// Generate stub for the specified FQN from asset - /// source is used to set the source info on generated nodes - fn generate(&self, fqn: &str, entry: &AssetEntry) -> Result; + /// Generate one or more stubs for the specified FQN from asset. + /// + /// Implementations should include the primary requested node when possible, + /// and may include related nodes (e.g. class members) for richer resolution. + fn generate_stubs(&self, fqn: &str, entry: &AssetEntry) -> Result, BoxError>; } /// Stub request (with source info) From cf3818bfa827f8a8b53b50904d839518fc5d0beb Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Sun, 15 Feb 2026 17:16:40 +0800 Subject: [PATCH 46/49] chore: update dependencies in Cargo.lock and Cargo.toml --- Cargo.lock | 196 +++++++++++++++++++++++++++++++++++++++++++++++++---- Cargo.toml | 2 +- 2 files changed, 182 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 446cbbf..058c155 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -364,9 +364,9 @@ checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "constant_time_eq" -version = "0.3.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" [[package]] name = "convert_case" @@ -865,12 +865,25 @@ name = "getrandom" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi", "wasip2", + "wasip3", "wasm-bindgen", ] @@ -1114,6 +1127,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -1276,6 +1295,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libbz2-rs-sys" version = "0.2.2" @@ -1369,9 +1394,9 @@ dependencies = [ [[package]] name = "lzma-rust2" -version = "0.15.2" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96d43a6fec3e2f1176fd435ff6f0e337dab57361918f0f51bbc75995151e2ca0" +checksum = "d673a11333485e7d8b93d62a9a5b07b22daf5e8a8655a44c1bb18aa4bf3d1524" dependencies = [ "crc", "sha2", @@ -1677,9 +1702,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-traits" @@ -1845,6 +1870,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -2522,9 +2557,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.45" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9e442fc33d7fdb45aa9bfeb312c095964abdf596f7567261062b2a7107aaabd" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", @@ -2538,15 +2573,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b36ee98fd31ec7426d599183e8fe26932a8dc1fb76ddb6214d05493377d34ca" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.25" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e552d1249bf61ac2a52db88179fd0673def1e1ad8243a00d9ec9ed71fee3dd" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -2857,6 +2892,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "url" version = "2.5.8" @@ -2934,6 +2975,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.108" @@ -2979,6 +3029,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "winapi" version = "0.3.9" @@ -3230,6 +3314,88 @@ name = "wit-bindgen" version = "0.51.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" @@ -3362,9 +3528,9 @@ dependencies = [ [[package]] name = "zip" -version = "7.3.0" +version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "268bf6f9ceb991e07155234071501490bb41fd1e39c6a588106dad10ae2a5804" +checksum = "79b32dd4ad3aca14ae109f8cce0495ac1c57f6f4f00ad459a40e582f89440d97" dependencies = [ "aes", "bzip2", @@ -3372,7 +3538,7 @@ dependencies = [ "crc32fast", "deflate64", "flate2", - "getrandom 0.3.4", + "getrandom 0.4.1", "hmac", "indexmap", "lzma-rust2", diff --git a/Cargo.toml b/Cargo.toml index d604f6b..4ef308a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,6 @@ tree-sitter-java = "0.23.5" tree-sitter-groovy = "0.1.2" mimalloc = "0.1" tempfile = "3.10" -zip = "7.3.0" +zip = "8.0.0" ristretto_jimage = "0.29.0" ristretto_classfile = "0.29.0" From 80be93a4e99ed823693524932796d5b664310f17 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Thu, 19 Feb 2026 15:41:15 +0800 Subject: [PATCH 47/49] Refactor source indexing and execution phases - Renamed `IngestExecutor` to `SourcePhaseExecutor` for clarity. - Introduced `SourceFlowControl` struct to manage parallelism and cache limits. - Reorganized source execution logic into distinct methods: `collect_file`, `analyze_file`, and `lower_file`. - Implemented a new `resolve_stub_requests` function to handle stub request resolution. - Removed unused `metrics` module and related code. - Updated `NaviscopeEngine` to streamline source phase execution and improve performance. - Added regression tests to ensure stability with external imports and cross-file type resolution. --- Cargo.lock | 22 +- Cargo.toml | 1 + crates/core/Cargo.toml | 2 +- .../core/src/indexing/source/commit_sink.rs | 50 -- .../src/indexing/source/deferred_queue.rs | 119 ----- crates/core/src/indexing/source/executor.rs | 292 ++++-------- .../core/src/indexing/source/flow_control.rs | 32 ++ crates/core/src/indexing/source/metrics.rs | 13 - crates/core/src/indexing/source/mod.rs | 444 +++++++++--------- crates/core/src/indexing/source/stub_ops.rs | 65 ++- crates/core/src/runtime/lifecycle.rs | 59 +-- crates/core/src/runtime/mod.rs | 8 +- crates/core/tests/async_stubbing.rs | 119 +++++ 13 files changed, 562 insertions(+), 664 deletions(-) delete mode 100644 crates/core/src/indexing/source/commit_sink.rs delete mode 100644 crates/core/src/indexing/source/deferred_queue.rs create mode 100644 crates/core/src/indexing/source/flow_control.rs delete mode 100644 crates/core/src/indexing/source/metrics.rs diff --git a/Cargo.lock b/Cargo.lock index 058c155..7429d04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1517,12 +1517,12 @@ dependencies = [ "log", "lsp-types 0.97.0", "naviscope-api", - "naviscope-ingest", "naviscope-java", "naviscope-plugin", "notify", "once_cell", "petgraph", + "rayon", "regex", "rmp-serde", "schemars", @@ -1955,6 +1955,26 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" diff --git a/Cargo.toml b/Cargo.toml index 4ef308a..0f65a04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,6 +64,7 @@ lasso = { version = "0.7", features = ["serialize", "multi-threaded"] } zstd = "0.13" async-trait = "0.1" url = "2.5.8" +rayon = "1.10.0" tree-sitter-java = "0.23.5" tree-sitter-groovy = "0.1.2" mimalloc = "0.1" diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 272b2c6..f926c3b 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -29,11 +29,11 @@ lasso = { workspace = true } zstd = { workspace = true } naviscope-api = { workspace = true } naviscope-plugin = { workspace = true } -naviscope-ingest = { workspace = true } async-trait = { workspace = true } url = { workspace = true } dirs = { workspace = true } serde_bytes = { workspace = true } +rayon = { workspace = true } [dev-dependencies] tree-sitter-java = { workspace = true } diff --git a/crates/core/src/indexing/source/commit_sink.rs b/crates/core/src/indexing/source/commit_sink.rs deleted file mode 100644 index 2248dde..0000000 --- a/crates/core/src/indexing/source/commit_sink.rs +++ /dev/null @@ -1,50 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use naviscope_ingest::{CommitSink, ExecutionResult, IngestError}; -use naviscope_plugin::NamingConvention; - -use crate::model::{CodeGraph, GraphOp}; - -pub struct CommitGraphSink { - pub current: Arc>>, - pub naming_conventions: Arc>>, -} - -impl CommitSink for CommitGraphSink { - fn commit_epoch( - &self, - _epoch: u64, - results: Vec>, - ) -> Result { - if results.is_empty() { - return Ok(0); - } - - let mut ops = Vec::new(); - for result in results { - ops.extend(result.operations); - } - - if !ops.is_empty() { - let current = Arc::clone(&self.current); - let naming_conventions = Arc::clone(&self.naming_conventions); - tokio::runtime::Handle::current().block_on(async move { - let mut lock = current.write().await; - let mut builder = lock.as_ref().to_builder(); - for (lang, naming) in naming_conventions.iter() { - builder - .naming_conventions - .insert(crate::model::Language::new(lang.clone()), Arc::clone(naming)); - } - builder - .apply_ops(ops) - .map_err(|e| IngestError::Execution(e.to_string()))?; - *lock = Arc::new(builder.build()); - Ok::<(), IngestError>(()) - })?; - } - - Ok(1) - } -} diff --git a/crates/core/src/indexing/source/deferred_queue.rs b/crates/core/src/indexing/source/deferred_queue.rs deleted file mode 100644 index 54af756..0000000 --- a/crates/core/src/indexing/source/deferred_queue.rs +++ /dev/null @@ -1,119 +0,0 @@ -use std::collections::{HashMap, HashSet, VecDeque}; -use std::sync::Mutex; - -use naviscope_ingest::{ - DeferredStore, DependencyKind, DependencyReadyEvent, DependencyRef, IngestError, Message, -}; - -pub struct InMemoryDeferredQueue

{ - deferred: Mutex>>, - ready_messages: Mutex>, - ready_resources: Mutex>, -} - -impl

Default for InMemoryDeferredQueue

{ - fn default() -> Self { - Self { - deferred: Mutex::new(VecDeque::new()), - ready_messages: Mutex::new(HashSet::new()), - ready_resources: Mutex::new(HashMap::new()), - } - } -} - -impl

DeferredStore

for InMemoryDeferredQueue

-where - P: Clone + Send + Sync + 'static, -{ - fn push(&self, message: Message

) -> Result<(), IngestError> { - let mut queue = self - .deferred - .lock() - .map_err(|_| IngestError::Execution("deferred queue poisoned".to_string()))?; - queue.push_back(message); - Ok(()) - } - - fn pop_ready(&self, limit: usize) -> Result>, IngestError> { - if limit == 0 { - return Ok(Vec::new()); - } - - let ready_messages = self - .ready_messages - .lock() - .map_err(|_| IngestError::Execution("ready message set poisoned".to_string()))? - .clone(); - let ready_resources = self - .ready_resources - .lock() - .map_err(|_| IngestError::Execution("ready resource map poisoned".to_string()))? - .clone(); - - let mut queue = self - .deferred - .lock() - .map_err(|_| IngestError::Execution("deferred queue poisoned".to_string()))?; - - let mut out = Vec::new(); - let original_len = queue.len(); - for _ in 0..original_len { - let Some(mut msg) = queue.pop_front() else { - break; - }; - - let satisfied = msg - .depends_on - .iter() - .all(|dep| dependency_satisfied(dep, &ready_messages, &ready_resources)); - - if satisfied && out.len() < limit { - msg.depends_on.clear(); - out.push(msg); - } else { - queue.push_back(msg); - } - } - - Ok(out) - } - - fn notify_ready(&self, event: DependencyReadyEvent) -> Result<(), IngestError> { - match event.dependency.kind { - DependencyKind::Message => { - let mut ready = self - .ready_messages - .lock() - .map_err(|_| IngestError::Execution("ready message set poisoned".to_string()))?; - ready.insert(event.dependency.target); - } - DependencyKind::Resource => { - let mut ready = self - .ready_resources - .lock() - .map_err(|_| IngestError::Execution("ready resource map poisoned".to_string()))?; - let version = event.dependency.min_version.unwrap_or(0); - ready.entry(event.dependency.target) - .and_modify(|v| *v = (*v).max(version)) - .or_insert(version); - } - } - Ok(()) - } -} - -fn dependency_satisfied( - dep: &DependencyRef, - ready_messages: &HashSet, - ready_resources: &HashMap, -) -> bool { - match dep.kind { - DependencyKind::Message => ready_messages.contains(&dep.target), - DependencyKind::Resource => match dep.min_version { - Some(min) => ready_resources - .get(&dep.target) - .is_some_and(|version| *version >= min), - None => ready_resources.contains_key(&dep.target), - }, - } -} diff --git a/crates/core/src/indexing/source/executor.rs b/crates/core/src/indexing/source/executor.rs index 75a9fd7..6497a08 100644 --- a/crates/core/src/indexing/source/executor.rs +++ b/crates/core/src/indexing/source/executor.rs @@ -1,21 +1,18 @@ -use std::collections::{BTreeMap, HashMap}; +use std::collections::HashMap; use std::path::PathBuf; use std::sync::{Arc, Mutex, RwLock}; -use naviscope_ingest::{ - DependencyRef, ExecutionResult, ExecutionStatus, Executor, IngestError, PipelineEvent, -}; use naviscope_plugin::{ LanguageCaps, ParsedFile, ProjectContext, SourceAnalyzeArtifact, SourceCollectArtifact, }; +use crate::error::{NaviscopeError, Result}; use crate::indexing::StubRequest; use crate::model::{CodeGraph, GraphOp}; -use super::SourceCompileWorkItem; -use super::stub_ops::{find_asset_for_fqn, generate_stub_ops, plan_stub_requests}; +use super::stub_ops::{find_asset_for_fqn, plan_stub_requests, resolve_stub_requests}; -pub struct IngestExecutor { +pub struct SourcePhaseExecutor { pub lang_caps: Arc>, pub project_context: Arc>, pub routes: Arc>>>, @@ -23,187 +20,55 @@ pub struct IngestExecutor { pub stub_cache: Arc, pub collect_cache: Arc>>>, pub analyze_cache: Arc>>>, + pub collect_cache_limit: usize, + pub analyze_cache_limit: usize, } -impl Executor for IngestExecutor { - fn execute( - &self, - message: naviscope_ingest::Message, - ) -> Result>, IngestError> { - let parent_msg_id = message.msg_id.clone(); - let epoch = message.epoch; - match message.payload.clone() { - SourceCompileWorkItem::SourceCollect(file) => { - let dep_state = self.execute_collect(file.clone())?; - let analyze_msg = naviscope_ingest::Message { - msg_id: next_stage_msg_id(&parent_msg_id, "collect", "analyze"), - topic: "source-analyze".to_string(), - message_group: file.path().to_string_lossy().to_string(), - version: 1, - depends_on: dep_state - .required_resources - .iter() - .cloned() - .map(|s| DependencyRef::resource(s, None)) - .collect(), - epoch, - payload: SourceCompileWorkItem::SourceAnalyze(file), - metadata: BTreeMap::new(), - }; - Ok(vec![ - PipelineEvent::Executed { - epoch, - result: ExecutionResult { - msg_id: parent_msg_id, - status: ExecutionStatus::Done, - operations: Vec::new(), - next_dependencies: dep_state.provided_resources, - error: None, - }, - }, - PipelineEvent::Deferred(analyze_msg), - ]) - } - SourceCompileWorkItem::SourceAnalyze(file) => { - self.execute_analyze(file.clone())?; - let lower_msg = naviscope_ingest::Message { - msg_id: next_stage_msg_id(&parent_msg_id, "analyze", "lower"), - topic: "source-lower".to_string(), - message_group: message.message_group.clone(), - version: 1, - depends_on: vec![DependencyRef::message(parent_msg_id.clone())], - epoch, - payload: SourceCompileWorkItem::SourceLower(file), - metadata: BTreeMap::new(), - }; - Ok(vec![ - PipelineEvent::Executed { - epoch, - result: ExecutionResult { - msg_id: parent_msg_id, - status: ExecutionStatus::Done, - operations: Vec::new(), - next_dependencies: Vec::new(), - error: None, - }, - }, - PipelineEvent::Deferred(lower_msg), - ]) - } - SourceCompileWorkItem::SourceLower(file) => { - let outcome = self.execute_lower(file)?; - Ok(vec![PipelineEvent::Executed { - epoch, - result: ExecutionResult { - msg_id: parent_msg_id, - status: ExecutionStatus::Done, - operations: outcome.operations, - next_dependencies: Vec::new(), - error: None, - }, - }]) - } - SourceCompileWorkItem::StubRequest(req) => { - let operations = self.execute_stub(req); - Ok(vec![PipelineEvent::Executed { - epoch, - result: ExecutionResult { - msg_id: parent_msg_id, - status: ExecutionStatus::Done, - operations, - next_dependencies: Vec::new(), - error: None, - }, - }]) - } - } - } -} - -struct SourceExecutionOutcome { - operations: Vec, -} - -struct CollectDependencyState { - provided_resources: Vec, - required_resources: Vec, +pub struct SourceLowerOutput { + pub ops: Vec, + pub stub_requests: Vec, } -impl IngestExecutor { - fn execute_collect(&self, file: ParsedFile) -> Result { +impl SourcePhaseExecutor { + pub fn collect_file(&self, file: &ParsedFile) -> Result<()> { let caps = self .lang_caps .iter() .find(|c| c.matcher.supports_path(file.path())); let Some(caps) = caps else { - return Ok(CollectDependencyState { - provided_resources: Vec::new(), - required_resources: Vec::new(), - }); + return Ok(()); }; let mut cache = self .collect_cache .lock() - .map_err(|_| IngestError::Execution("collect cache poisoned".to_string()))?; + .map_err(|_| NaviscopeError::Internal("collect cache poisoned".to_string()))?; + if let Some(collected) = cache.get(file.path()) { - let mut ctx = self - .project_context - .write() - .map_err(|_| IngestError::Execution("project context poisoned".to_string()))?; - for sym in collected.collected_type_symbols() { - ctx.symbol_table.type_symbols.insert(sym.clone()); - } - for sym in collected.collected_method_symbols() { - ctx.symbol_table.method_symbols.insert(sym.clone()); - } - return Ok(CollectDependencyState { - provided_resources: collected - .provided_dependency_symbols() - .iter() - .cloned() - .map(|s| DependencyRef::resource(s, None)) - .collect(), - required_resources: collected.required_dependency_symbols().to_vec(), - }); + self.merge_collected_symbols(collected.as_ref())?; + return Ok(()); } let context = self .project_context .read() - .map_err(|_| IngestError::Execution("project context poisoned".to_string()))? + .map_err(|_| NaviscopeError::Internal("project context poisoned".to_string()))? .clone(); let collected = caps .indexing - .collect_source(&file, &context) - .map_err(|e| IngestError::Execution(e.to_string()))?; - { - let mut ctx = self - .project_context - .write() - .map_err(|_| IngestError::Execution("project context poisoned".to_string()))?; - for sym in collected.collected_type_symbols() { - ctx.symbol_table.type_symbols.insert(sym.clone()); - } - for sym in collected.collected_method_symbols() { - ctx.symbol_table.method_symbols.insert(sym.clone()); - } - } - let provided_resources = collected - .provided_dependency_symbols() - .iter() - .cloned() - .map(|s| DependencyRef::resource(s, None)) - .collect(); - let required_resources = collected.required_dependency_symbols().to_vec(); - cache.insert(file.path().to_path_buf(), collected); - Ok(CollectDependencyState { - provided_resources, - required_resources, - }) + .collect_source(file, &context) + .map_err(|e| NaviscopeError::Internal(e.to_string()))?; + self.merge_collected_symbols(collected.as_ref())?; + bounded_insert( + &mut cache, + file.path().to_path_buf(), + collected, + self.collect_cache_limit, + ); + Ok(()) } - fn execute_analyze(&self, file: ParsedFile) -> Result<(), IngestError> { + pub fn analyze_file(&self, file: &ParsedFile) -> Result<()> { let caps = self .lang_caps .iter() @@ -215,77 +80,83 @@ impl IngestExecutor { let context = self .project_context .read() - .map_err(|_| IngestError::Execution("project context poisoned".to_string()))? + .map_err(|_| NaviscopeError::Internal("project context poisoned".to_string()))? .clone(); let collected = { let mut cache = self .collect_cache .lock() - .map_err(|_| IngestError::Execution("collect cache poisoned".to_string()))?; + .map_err(|_| NaviscopeError::Internal("collect cache poisoned".to_string()))?; if let Some(c) = cache.remove(file.path()) { c } else { caps.indexing - .collect_source(&file, &context) - .map_err(|e| IngestError::Execution(e.to_string()))? + .collect_source(file, &context) + .map_err(|e| NaviscopeError::Internal(e.to_string()))? } }; let analyzed = caps .indexing .analyze_source(collected, &context) - .map_err(|e| IngestError::Execution(e.to_string()))?; + .map_err(|e| NaviscopeError::Internal(e.to_string()))?; let mut cache = self .analyze_cache .lock() - .map_err(|_| IngestError::Execution("analyze cache poisoned".to_string()))?; - cache.insert(file.path().to_path_buf(), analyzed); + .map_err(|_| NaviscopeError::Internal("analyze cache poisoned".to_string()))?; + bounded_insert( + &mut cache, + file.path().to_path_buf(), + analyzed, + self.analyze_cache_limit, + ); Ok(()) } - fn execute_lower(&self, file: ParsedFile) -> Result { + pub fn lower_file(&self, file: &ParsedFile) -> Result { let caps = self .lang_caps .iter() .find(|c| c.matcher.supports_path(file.path())); let Some(caps) = caps else { - return Ok(SourceExecutionOutcome { - operations: Vec::new(), + return Ok(SourceLowerOutput { + ops: Vec::new(), + stub_requests: Vec::new(), }); }; + let context = self .project_context .read() - .map_err(|_| IngestError::Execution("project context poisoned".to_string()))? + .map_err(|_| NaviscopeError::Internal("project context poisoned".to_string()))? .clone(); let analyzed = { let mut cache = self .analyze_cache .lock() - .map_err(|_| IngestError::Execution("analyze cache poisoned".to_string()))?; + .map_err(|_| NaviscopeError::Internal("analyze cache poisoned".to_string()))?; if let Some(a) = cache.remove(file.path()) { a } else { let collected = caps .indexing - .collect_source(&file, &context) - .map_err(|e| IngestError::Execution(e.to_string()))?; + .collect_source(file, &context) + .map_err(|e| NaviscopeError::Internal(e.to_string()))?; caps.indexing .analyze_source(collected, &context) - .map_err(|e| IngestError::Execution(e.to_string()))? + .map_err(|e| NaviscopeError::Internal(e.to_string()))? } }; let unit = caps .indexing .lower_source(analyzed, &context) - .map_err(|e| IngestError::Execution(e.to_string()))?; + .map_err(|e| NaviscopeError::Internal(e.to_string()))?; let path = file.path().to_path_buf(); - let mut ops = Vec::with_capacity(8); ops.push(GraphOp::RemovePath { path: Arc::from(path.as_path()), @@ -301,40 +172,49 @@ impl IngestExecutor { let routes_snapshot = self .routes .read() - .map_err(|_| IngestError::Execution("routes map poisoned".to_string()))? + .map_err(|_| NaviscopeError::Internal("routes map poisoned".to_string()))? .clone(); - let stub_requests = plan_stub_requests(&ops, &routes_snapshot); - for req in stub_requests { - ops.extend(self.execute_stub(req)); - } - + let mut stub_requests = plan_stub_requests(&ops, &routes_snapshot); let deferred_stub_requests = deferred_targets_to_stub_requests(&deferred_targets, &routes_snapshot); - for req in deferred_stub_requests { - ops.extend(self.execute_stub(req)); - } + stub_requests.extend(deferred_stub_requests); - Ok(SourceExecutionOutcome { operations: ops }) + Ok(SourceLowerOutput { ops, stub_requests }) } - fn execute_stub(&self, req: StubRequest) -> Vec { - generate_stub_ops( - &req, + pub fn stub_phase(&self, requests: Vec) -> Vec { + resolve_stub_requests( + requests, Arc::clone(&self.current), Arc::clone(&self.lang_caps), Arc::clone(&self.stub_cache), ) } + + fn merge_collected_symbols(&self, collected: &dyn SourceCollectArtifact) -> Result<()> { + let mut ctx = self + .project_context + .write() + .map_err(|_| NaviscopeError::Internal("project context poisoned".to_string()))?; + for sym in collected.collected_type_symbols() { + ctx.symbol_table.type_symbols.insert(sym.clone()); + } + for sym in collected.collected_method_symbols() { + ctx.symbol_table.method_symbols.insert(sym.clone()); + } + Ok(()) + } } -fn next_stage_msg_id(current_msg_id: &str, from: &str, to: &str) -> String { - let suffix = format!(":{from}"); - if let Some(base) = current_msg_id.strip_suffix(&suffix) { - format!("{base}:{to}") - } else { - format!("{current_msg_id}:{to}") +fn bounded_insert(cache: &mut HashMap, key: PathBuf, value: T, limit: usize) { + let cap = limit.max(1); + if cache.len() >= cap { + if let Some(evict_key) = cache.keys().next().cloned() { + cache.remove(&evict_key); + } } + cache.insert(key, value); } fn deferred_targets_to_stub_requests( @@ -345,13 +225,13 @@ fn deferred_targets_to_stub_requests( let mut seen = std::collections::HashSet::new(); for target in deferred_targets { - if let Some(paths) = find_asset_for_fqn(target, routes) { - if seen.insert(target.clone()) { - out.push(StubRequest { - fqn: target.clone(), - candidate_paths: paths.clone(), - }); - } + if let Some(paths) = find_asset_for_fqn(target, routes) + && seen.insert(target.clone()) + { + out.push(StubRequest { + fqn: target.clone(), + candidate_paths: paths.clone(), + }); } } diff --git a/crates/core/src/indexing/source/flow_control.rs b/crates/core/src/indexing/source/flow_control.rs new file mode 100644 index 0000000..2b033eb --- /dev/null +++ b/crates/core/src/indexing/source/flow_control.rs @@ -0,0 +1,32 @@ +#[derive(Clone, Copy)] +pub(super) struct SourceFlowControl { + pub(super) max_parallelism: usize, + pub(super) collect_cache_limit: usize, + pub(super) analyze_cache_limit: usize, +} + +impl Default for SourceFlowControl { + fn default() -> Self { + let max_parallelism = std::env::var("NAVISCOPE_SOURCE_MAX_PARALLELISM") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|v| *v > 0) + .unwrap_or_else(|| std::thread::available_parallelism().map_or(4, usize::from)); + let collect_cache_limit = std::env::var("NAVISCOPE_SOURCE_COLLECT_CACHE_LIMIT") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|v| *v > 0) + .unwrap_or(512); + let analyze_cache_limit = std::env::var("NAVISCOPE_SOURCE_ANALYZE_CACHE_LIMIT") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|v| *v > 0) + .unwrap_or(512); + + Self { + max_parallelism, + collect_cache_limit, + analyze_cache_limit, + } + } +} diff --git a/crates/core/src/indexing/source/metrics.rs b/crates/core/src/indexing/source/metrics.rs deleted file mode 100644 index 232f2eb..0000000 --- a/crates/core/src/indexing/source/metrics.rs +++ /dev/null @@ -1,13 +0,0 @@ -use naviscope_ingest::RuntimeMetrics; - -pub struct NoopRuntimeMetrics; - -impl RuntimeMetrics for NoopRuntimeMetrics { - fn observe_queue_depth(&self, _queue: &'static str, _depth: usize) {} - - fn observe_throughput(&self, _stage: &'static str, _count: usize) {} - - fn observe_latency_ms(&self, _stage: &'static str, _p95_ms: u64, _p99_ms: u64) {} - - fn observe_replay_result(&self, _ok: bool) {} -} diff --git a/crates/core/src/indexing/source/mod.rs b/crates/core/src/indexing/source/mod.rs index eb77079..d2d4f79 100644 --- a/crates/core/src/indexing/source/mod.rs +++ b/crates/core/src/indexing/source/mod.rs @@ -1,281 +1,265 @@ -mod commit_sink; -mod deferred_queue; mod executor; -mod metrics; +mod flow_control; mod stub_ops; -use std::collections::{BTreeMap, HashMap}; +use std::collections::HashMap; use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, RwLock}; -use naviscope_ingest::{ - IngestError, IngestRuntime, IntakeHandle, RuntimeComponents, RuntimeConfig, -}; -use naviscope_plugin::{LanguageCaps, ParsedFile, ProjectContext}; +use naviscope_plugin::{LanguageCaps, NamingConvention, ParsedFile, ProjectContext}; +use rayon::prelude::*; use crate::error::{NaviscopeError, Result}; use crate::indexing::StubRequest; -use crate::model::{CodeGraph, GraphOp}; +use crate::model::{CodeGraph, GraphOp, Language}; -use commit_sink::CommitGraphSink; -use deferred_queue::InMemoryDeferredQueue; -use executor::IngestExecutor; -use metrics::NoopRuntimeMetrics; +use executor::{SourceLowerOutput, SourcePhaseExecutor}; +use flow_control::SourceFlowControl; +use stub_ops::resolve_stub_requests; pub use stub_ops::plan_stub_requests; -#[derive(Clone)] -pub enum SourceCompileWorkItem { - SourceCollect(ParsedFile), - SourceAnalyze(ParsedFile), - SourceLower(ParsedFile), - StubRequest(StubRequest), -} - -#[derive(Clone)] -struct StagedSourceItem { - file: ParsedFile, - collect_id: String, -} - -pub struct SourceCompilerRuntime { - intake: IntakeHandle, - project_context: Arc>, - routes: Arc>>>, - runtime_task: tokio::task::JoinHandle<()>, -} - pub struct SourceCompiler { - runtime: tokio::sync::OnceCell>, - pending_stub_requests: Mutex>, + inflight_compiles: AtomicUsize, + completed_source_epochs: AtomicU64, + pending_stub_requests: Arc>>, + flow_control: SourceFlowControl, } -impl SourceCompilerRuntime { - pub async fn start( +impl SourceCompiler { + pub fn new() -> Self { + Self { + inflight_compiles: AtomicUsize::new(0), + completed_source_epochs: AtomicU64::new(0), + pending_stub_requests: Arc::new(Mutex::new(Vec::new())), + flow_control: SourceFlowControl::default(), + } + } + + #[allow(clippy::too_many_arguments)] + pub async fn compile_source_files( + &self, + base_graph: CodeGraph, + source_files: Vec, + project_context: ProjectContext, + routes: HashMap>, current: Arc>>, - naming_conventions: Arc>>, + naming_conventions: Arc>>, lang_caps: Arc>, stub_cache: Arc, - ) -> Result { - let project_context = Arc::new(RwLock::new(ProjectContext::new())); - let routes = Arc::new(RwLock::new(HashMap::new())); - let executor: naviscope_ingest::DynExecutor = - Arc::new(IngestExecutor { - lang_caps, - project_context: Arc::clone(&project_context), - routes: Arc::clone(&routes), - current: Arc::clone(¤t), - stub_cache, - collect_cache: Arc::new(std::sync::Mutex::new(HashMap::new())), - analyze_cache: Arc::new(std::sync::Mutex::new(HashMap::new())), - }); - let deferred_store: naviscope_ingest::DynDeferredStore = - Arc::new(InMemoryDeferredQueue::default()); - let commit_sink: naviscope_ingest::DynCommitSink = Arc::new(CommitGraphSink { - current, - naming_conventions, - }); - let metrics: naviscope_ingest::DynRuntimeMetrics = Arc::new(NoopRuntimeMetrics); - - let runtime = Arc::new(IngestRuntime::new( - RuntimeConfig { - kernel_channel_capacity: 500, - max_in_flight: 256, - deferred_poll_limit: 256, - idle_sleep_ms: 10, - }, - RuntimeComponents::with_tokio_bus( - executor, - deferred_store, - commit_sink, - metrics, - ), - )); + ) -> Result { + if source_files.is_empty() { + return Ok(base_graph); + } - let intake = runtime.intake_handle(); - let runtime_clone = Arc::clone(&runtime); - let runtime_task = tokio::spawn(async move { - if let Err(err) = runtime_clone.run_forever().await { - tracing::warn!("source compiler runtime stopped: {}", err); + self.inflight_compiles.fetch_add(1, Ordering::AcqRel); + let _compile_guard = CompileGuard { + inflight_compiles: &self.inflight_compiles, + }; + let phase_ops = tokio::task::spawn_blocking({ + let pending_queue = Arc::clone(&self.pending_stub_requests); + let phase_current = Arc::clone(¤t); + let phase_lang_caps = Arc::clone(&lang_caps); + let phase_stub_cache = Arc::clone(&stub_cache); + let flow = self.flow_control; + move || { + run_source_phases_blocking( + source_files, + project_context, + routes, + pending_queue, + phase_current, + phase_lang_caps, + phase_stub_cache, + flow, + ) } - }); - - Ok(Self { - intake, - project_context, - routes, - runtime_task, }) + .await + .map_err(|e| NaviscopeError::Internal(e.to_string()))??; + + let next_graph = apply_ops_to_graph(base_graph, naming_conventions, phase_ops)?; + self.completed_source_epochs.fetch_add(1, Ordering::AcqRel); + Ok(next_graph) } - pub async fn submit_source_batch( + pub fn try_submit_or_enqueue_stub_request( &self, - source_files: Vec, - project_context: ProjectContext, - routes: HashMap>, - ) -> Result<()> { - { - let mut guard = self - .project_context - .write() - .map_err(|_| NaviscopeError::Internal("project context poisoned".to_string()))?; - *guard = project_context; - } - { - let mut guard = self - .routes - .write() - .map_err(|_| NaviscopeError::Internal("routes map poisoned".to_string()))?; - *guard = routes; + req: StubRequest, + current: Arc>>, + naming_conventions: Arc>>, + lang_caps: Arc>, + stub_cache: Arc, + ) -> bool { + if let Ok(mut pending) = self.pending_stub_requests.lock() { + pending.push(req); + } else { + return false; } - if source_files.is_empty() { - return Ok(()); + // No completed source phase yet: queue only (replayed in next compile). + if self.completed_source_epochs.load(Ordering::Acquire) == 0 { + return true; } - - let mut staged = Vec::new(); - let epoch = self.intake.new_epoch(); - for (index, file) in source_files.into_iter().enumerate() { - let base = format!("src:{}:{}", index, file.path().display()); - let collect_id = format!("{base}:collect"); - staged.push(StagedSourceItem { - file, - collect_id, - }); + // Source phase in progress: queue only (drained inside phase). + if self.inflight_compiles.load(Ordering::Acquire) > 0 { + return true; } - for item in staged { - let file = item.file; - let collect_id = item.collect_id; - let collect_msg = naviscope_ingest::Message { - msg_id: collect_id.clone(), - topic: "source-collect".to_string(), - message_group: file.path().to_string_lossy().to_string(), - version: 1, - depends_on: Vec::new(), - epoch, - payload: SourceCompileWorkItem::SourceCollect(file.clone()), - metadata: BTreeMap::new(), - }; - self.intake - .submit(collect_msg) - .await - .map_err(ingest_to_naviscope_error)?; + let queued = Self::drain_pending_stub_requests(&self.pending_stub_requests); + if queued.is_empty() { + return true; } - self.intake - .seal_epoch(epoch) - .map_err(ingest_to_naviscope_error)?; - self.intake - .wait_epoch(epoch) - .await - .map_err(ingest_to_naviscope_error) - } + let ops = resolve_stub_requests(queued, current.clone(), lang_caps, stub_cache); + if ops.is_empty() { + return true; + } - pub async fn submit_stub_request(&self, req: StubRequest) -> Result<()> { - let msg_id = format!("stub:{}", req.fqn); - let msg = naviscope_ingest::Message { - msg_id, - topic: "stub-index".to_string(), - message_group: req.fqn.clone(), - version: 1, - depends_on: Vec::new(), - epoch: 0, - payload: SourceCompileWorkItem::StubRequest(req), - metadata: BTreeMap::new(), - }; - self.intake - .submit(msg) - .await - .map_err(ingest_to_naviscope_error) + apply_ops_to_current(current, naming_conventions, ops).is_ok() } - pub fn try_submit_stub_request(&self, req: StubRequest) -> Result<()> { - let msg_id = format!("stub:{}", req.fqn); - let msg = naviscope_ingest::Message { - msg_id, - topic: "stub-index".to_string(), - message_group: req.fqn.clone(), - version: 1, - depends_on: Vec::new(), - epoch: 0, - payload: SourceCompileWorkItem::StubRequest(req), - metadata: BTreeMap::new(), - }; - self.intake - .try_submit(msg) - .map_err(ingest_to_naviscope_error) + fn drain_pending_stub_requests(queue: &Arc>>) -> Vec { + match queue.lock() { + Ok(mut pending) => pending.drain(..).collect(), + Err(_) => Vec::new(), + } } } -impl Drop for SourceCompilerRuntime { - fn drop(&mut self) { - self.runtime_task.abort(); - } -} +fn run_source_phases_blocking( + source_files: Vec, + project_context: ProjectContext, + routes: HashMap>, + pending_stub_requests: Arc>>, + current: Arc>>, + lang_caps: Arc>, + stub_cache: Arc, + flow: SourceFlowControl, +) -> Result> { + let mut queued_stub_requests = + SourceCompiler::drain_pending_stub_requests(&pending_stub_requests); -impl SourceCompiler { - pub fn new() -> Self { - Self { - runtime: tokio::sync::OnceCell::const_new(), - pending_stub_requests: Mutex::new(Vec::new()), - } - } + let executor = Arc::new(SourcePhaseExecutor { + lang_caps, + project_context: Arc::new(RwLock::new(project_context)), + routes: Arc::new(RwLock::new(routes)), + current, + stub_cache, + collect_cache: Arc::new(Mutex::new(HashMap::new())), + analyze_cache: Arc::new(Mutex::new(HashMap::new())), + collect_cache_limit: flow.collect_cache_limit, + analyze_cache_limit: flow.analyze_cache_limit, + }); - pub async fn ensure_runtime( - &self, - current: Arc>>, - naming_conventions: Arc>>, - lang_caps: Arc>, - stub_cache: Arc, - ) -> Result> { - let runtime = self - .runtime - .get_or_try_init(|| async { - SourceCompilerRuntime::start(current, naming_conventions, lang_caps, stub_cache) - .await - .map(Arc::new) - }) - .await - .map(Arc::clone)?; + let thread_pool = rayon::ThreadPoolBuilder::new() + .num_threads(flow.max_parallelism.max(1)) + .build() + .map_err(|e| NaviscopeError::Internal(e.to_string()))?; - let drained = match self.pending_stub_requests.lock() { - Ok(mut pending) => pending.drain(..).collect::>(), - Err(_) => Vec::new(), - }; - for req in drained { - if let Err(err) = runtime.submit_stub_request(req).await { - tracing::warn!("Failed to submit deferred stub request: {}", err); - } - } + let collect_results: Vec> = thread_pool.install(|| { + source_files + .par_iter() + .map(|file| executor.collect_file(file)) + .collect() + }); + for result in collect_results { + result?; + } - Ok(runtime) + let analyze_results: Vec> = thread_pool.install(|| { + source_files + .par_iter() + .map(|file| executor.analyze_file(file)) + .collect() + }); + for result in analyze_results { + result?; } - pub async fn compile_source_batch( - runtime: &SourceCompilerRuntime, - source_files: Vec, - project_context: ProjectContext, - routes: HashMap>, - ) -> Result<()> { - runtime - .submit_source_batch(source_files, project_context, routes) - .await + let lowered_results: Vec> = thread_pool.install(|| { + source_files + .par_iter() + .map(|file| executor.lower_file(file)) + .collect() + }); + + let mut ops = Vec::new(); + let mut stub_requests = Vec::new(); + for result in lowered_results { + let output = result?; + ops.extend(output.ops); + stub_requests.extend(output.stub_requests); + } + queued_stub_requests.extend(stub_requests); + queued_stub_requests.extend(SourceCompiler::drain_pending_stub_requests( + &pending_stub_requests, + )); + let stub_ops = executor.stub_phase(queued_stub_requests); + if !stub_ops.is_empty() { + ops.extend(stub_ops); } - pub fn try_submit_or_enqueue_stub_request(&self, req: StubRequest) -> bool { - if let Some(runtime) = self.runtime.get() { - return runtime.try_submit_stub_request(req).is_ok(); - } + Ok(ops) +} - if let Ok(mut pending) = self.pending_stub_requests.lock() { - pending.push(req); - return true; - } +fn apply_ops_to_graph( + base_graph: CodeGraph, + naming_conventions: Arc>>, + ops: Vec, +) -> Result { + if ops.is_empty() { + return Ok(base_graph); + } - false + let mut builder = base_graph.to_builder(); + for (lang, naming) in naming_conventions.iter() { + builder + .naming_conventions + .insert(Language::new(lang.clone()), Arc::clone(naming)); } + builder + .apply_ops(ops) + .map_err(|e| NaviscopeError::Internal(e.to_string()))?; + Ok(builder.build()) } -fn ingest_to_naviscope_error(err: IngestError) -> NaviscopeError { - NaviscopeError::Internal(err.to_string()) +fn apply_ops_to_current( + current: Arc>>, + naming_conventions: Arc>>, + ops: Vec, +) -> Result<()> { + if let Ok(handle) = tokio::runtime::Handle::try_current() { + std::thread::spawn(move || { + handle.block_on(async move { + let mut lock = current.write().await; + let next = apply_ops_to_graph(lock.as_ref().clone(), naming_conventions, ops)?; + *lock = Arc::new(next); + Ok(()) + }) + }) + .join() + .map_err(|_| NaviscopeError::Internal("stub apply thread panicked".to_string()))? + } else { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| NaviscopeError::Internal(e.to_string()))?; + runtime.handle().block_on(async move { + let mut lock = current.write().await; + let next = apply_ops_to_graph(lock.as_ref().clone(), naming_conventions, ops)?; + *lock = Arc::new(next); + Ok(()) + }) + } +} + +struct CompileGuard<'a> { + inflight_compiles: &'a AtomicUsize, +} + +impl Drop for CompileGuard<'_> { + fn drop(&mut self) { + self.inflight_compiles.fetch_sub(1, Ordering::AcqRel); + } } diff --git a/crates/core/src/indexing/source/stub_ops.rs b/crates/core/src/indexing/source/stub_ops.rs index 745606a..b8cad3a 100644 --- a/crates/core/src/indexing/source/stub_ops.rs +++ b/crates/core/src/indexing/source/stub_ops.rs @@ -71,16 +71,40 @@ pub fn generate_stub_ops( let mut ops = Vec::new(); // Skip if node already exists and resolved. - let already_resolved = tokio::runtime::Handle::current().block_on(async { - let lock = current.read().await; - let graph = &**lock; - if let Some(idx) = graph.find_node(&req.fqn) - && let Some(node) = graph.get_node(idx) - { - return node.status == naviscope_api::models::graph::ResolutionStatus::Resolved; - } - false - }); + let already_resolved = if let Ok(handle) = tokio::runtime::Handle::try_current() { + let current = Arc::clone(¤t); + let fqn = req.fqn.clone(); + std::thread::spawn(move || { + handle.block_on(async move { + let lock = current.read().await; + let graph = &**lock; + if let Some(idx) = graph.find_node(&fqn) + && let Some(node) = graph.get_node(idx) + { + return node.status == naviscope_api::models::graph::ResolutionStatus::Resolved; + } + false + }) + }) + .join() + .unwrap_or(false) + } else { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("failed to build temporary tokio runtime for stub lookup"); + let fqn = req.fqn.clone(); + runtime.handle().block_on(async move { + let lock = current.read().await; + let graph = &**lock; + if let Some(idx) = graph.find_node(&fqn) + && let Some(node) = graph.get_node(idx) + { + return node.status == naviscope_api::models::graph::ResolutionStatus::Resolved; + } + false + }) + }; if already_resolved { return ops; } @@ -155,3 +179,24 @@ pub fn generate_stub_ops( ops } + +pub fn resolve_stub_requests( + requests: Vec, + current: Arc>>, + lang_caps: Arc>, + stub_cache: Arc, +) -> Vec { + let mut out = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for req in requests { + if seen.insert(req.fqn.clone()) { + out.extend(generate_stub_ops( + &req, + Arc::clone(¤t), + Arc::clone(&lang_caps), + Arc::clone(&stub_cache), + )); + } + } + out +} diff --git a/crates/core/src/runtime/lifecycle.rs b/crates/core/src/runtime/lifecycle.rs index f9f4b30..cc006dd 100644 --- a/crates/core/src/runtime/lifecycle.rs +++ b/crates/core/src/runtime/lifecycle.rs @@ -58,8 +58,10 @@ impl NaviscopeEngine { let existing_metadata = Self::collect_existing_metadata(&base_graph); let (graph_after_build, source_paths, project_context) = self.run_build_phase(base_graph, files, existing_metadata).await?; - self.apply_graph_snapshot(graph_after_build).await; - self.submit_source_stream(source_paths, project_context).await?; + let next_graph = self + .run_source_phase(graph_after_build, source_paths, project_context) + .await?; + self.apply_graph_snapshot(next_graph).await; self.finalize_update().await?; Ok(()) } @@ -163,49 +165,40 @@ impl NaviscopeEngine { *lock = Arc::new(graph); } - async fn submit_source_stream( + async fn run_source_phase( &self, + base_graph: CodeGraph, source_paths: Vec, project_context: naviscope_plugin::ProjectContext, - ) -> Result<()> { - const SOURCE_SUBMIT_CHUNK_SIZE: usize = 256; + ) -> Result { if source_paths.is_empty() { - return Ok(()); + return Ok(base_graph); } - let source_runtime = self - .source_compiler - .ensure_runtime( + let routes = self.global_asset_routes(); + let source_files = tokio::task::spawn_blocking(move || { + let existing = std::collections::HashMap::new(); + Scanner::scan_files_iter(source_paths, &existing).collect::>() + }) + .await + .map_err(|e| NaviscopeError::Internal(e.to_string()))?; + + if source_files.is_empty() { + return Ok(base_graph); + } + + self.source_compiler + .compile_source_files( + base_graph, + source_files, + project_context, + routes, self.current_graph_arc(), self.naming_conventions(), self.lang_caps_arc(), self.stub_cache_arc(), ) - .await?; - let routes = self.global_asset_routes(); - - for chunk in source_paths.chunks(SOURCE_SUBMIT_CHUNK_SIZE) { - let chunk_paths = chunk.to_vec(); - let source_files = tokio::task::spawn_blocking(move || { - let existing = std::collections::HashMap::new(); - Scanner::scan_files_iter(chunk_paths, &existing).collect::>() - }) .await - .map_err(|e| NaviscopeError::Internal(e.to_string()))?; - - if source_files.is_empty() { - continue; - } - - crate::indexing::source::SourceCompiler::compile_source_batch( - source_runtime.as_ref(), - source_files, - project_context.clone(), - routes.clone(), - ) - .await?; - } - Ok(()) } async fn finalize_update(&self) -> Result<()> { diff --git a/crates/core/src/runtime/mod.rs b/crates/core/src/runtime/mod.rs index 012d514..6b3a199 100644 --- a/crates/core/src/runtime/mod.rs +++ b/crates/core/src/runtime/mod.rs @@ -296,7 +296,13 @@ impl NaviscopeEngine { candidate_paths, }; - self.source_compiler.try_submit_or_enqueue_stub_request(req) + self.source_compiler.try_submit_or_enqueue_stub_request( + req, + self.current_graph_arc(), + self.naming_conventions(), + self.lang_caps_arc(), + self.stub_cache_arc(), + ) } /// Run the global asset scan and populate routes diff --git a/crates/core/tests/async_stubbing.rs b/crates/core/tests/async_stubbing.rs index a4403a4..ebfd0cb 100644 --- a/crates/core/tests/async_stubbing.rs +++ b/crates/core/tests/async_stubbing.rs @@ -371,3 +371,122 @@ async fn test_stub_request_replayed_after_runtime_start() { let _ = std::fs::remove_dir_all(&temp_dir); } + +/// Regression: source indexing with external imports should not deadlock waiting for +/// unreachable resource dependencies. +#[tokio::test] +async fn test_update_files_with_external_import_does_not_hang() { + use std::time::Duration; + ensure_test_index_dir(); + + let temp_dir = std::env::temp_dir().join("naviscope_external_import_no_hang"); + if temp_dir.exists() { + let _ = std::fs::remove_dir_all(&temp_dir); + } + std::fs::create_dir_all(&temp_dir).unwrap(); + + let java_caps = naviscope_java::java_caps().expect("Failed to create Java caps"); + let engine = NaviscopeEngine::builder(temp_dir.clone()) + .with_language_caps(java_caps) + .build(); + + let a_file = temp_dir.join("A.java"); + let b_file = temp_dir.join("B.java"); + + std::fs::write( + &a_file, + r#" +import java.util.List; + +class A { + B ref; + List names; +} +"#, + ) + .unwrap(); + std::fs::write( + &b_file, + r#" +class B { + String value; +} +"#, + ) + .unwrap(); + + tokio::time::timeout(Duration::from_secs(10), engine.update_files(vec![a_file, b_file])) + .await + .expect("update_files should finish within timeout") + .expect("update_files should succeed"); + + let graph = engine.snapshot().await; + assert!(graph.node_count() > 0, "graph should contain indexed nodes"); + + let _ = std::fs::remove_dir_all(&temp_dir); +} + +/// Regression: with collect->analyze phase barrier, cross-file type resolution should +/// remain stable and resolve to FQN. +#[tokio::test] +async fn test_cross_file_type_resolution_stays_fqn_precise() { + ensure_test_index_dir(); + + let temp_dir = std::env::temp_dir().join("naviscope_cross_file_fqn_precise"); + if temp_dir.exists() { + let _ = std::fs::remove_dir_all(&temp_dir); + } + std::fs::create_dir_all(temp_dir.join("p")).unwrap(); + + let java_caps = naviscope_java::java_caps().expect("Failed to create Java caps"); + let engine = Arc::new( + NaviscopeEngine::builder(temp_dir.clone()) + .with_language_caps(java_caps) + .build(), + ); + + let a_file = temp_dir.join("p").join("A.java"); + let b_file = temp_dir.join("p").join("B.java"); + + let a_source = r#" +package p; + +class A { + B ref; +} +"#; + let b_source = r#" +package p; + +class B { + String value; +} +"#; + + std::fs::write(&a_file, a_source).unwrap(); + std::fs::write(&b_file, b_source).unwrap(); + + engine.update_files(vec![a_file.clone(), b_file]).await.unwrap(); + + let handle = EngineHandle::from_engine(Arc::clone(&engine)); + let type_off = a_source.find("B ref").expect("B ref should exist"); + let (line, col) = offset_to_point(a_source, type_off); + let resolution = handle + .resolve_symbol_at(&PositionContext { + uri: format!("file://{}", a_file.display()), + line: line as u32, + char: col as u32, + content: Some(a_source.to_string()), + }) + .await + .expect("resolve_symbol_at should succeed") + .expect("type usage should resolve"); + + let fqn = match resolution { + SymbolResolution::Precise(fqn, _) | SymbolResolution::Global(fqn) => fqn, + SymbolResolution::Local(_, _) => panic!("expected global/precise resolution for type"), + }; + assert_eq!(fqn, "p.B"); + + let _ = std::fs::remove_dir_all(&temp_dir); +} From e497817b3c41ac8f5d1a19ef95474055cc406c28 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Thu, 19 Feb 2026 15:44:15 +0800 Subject: [PATCH 48/49] refactor: remove ingest module and its components - Deleted the error handling module and its associated types. - Removed the runtime module, including flow control and kernel logic. - Eliminated traits related to execution, deferred storage, and metrics. - Cleared out types related to messages, dependencies, and execution results. - Removed tests associated with the ingest runtime and its components. --- Cargo.lock | 11 - Cargo.toml | 2 - crates/ingest/Cargo.toml | 11 - crates/ingest/README.md | 163 --------- crates/ingest/src/error.rs | 15 - crates/ingest/src/lib.rs | 15 - crates/ingest/src/runtime/flow_control.rs | 70 ---- crates/ingest/src/runtime/kernel.rs | 402 ---------------------- crates/ingest/src/runtime/mod.rs | 354 ------------------- crates/ingest/src/traits.rs | 27 -- crates/ingest/src/types.rs | 112 ------ crates/ingest/tests/runtime_kernel.rs | 332 ------------------ 12 files changed, 1514 deletions(-) delete mode 100644 crates/ingest/Cargo.toml delete mode 100644 crates/ingest/README.md delete mode 100644 crates/ingest/src/error.rs delete mode 100644 crates/ingest/src/lib.rs delete mode 100644 crates/ingest/src/runtime/flow_control.rs delete mode 100644 crates/ingest/src/runtime/kernel.rs delete mode 100644 crates/ingest/src/runtime/mod.rs delete mode 100644 crates/ingest/src/traits.rs delete mode 100644 crates/ingest/src/types.rs delete mode 100644 crates/ingest/tests/runtime_kernel.rs diff --git a/Cargo.lock b/Cargo.lock index 7429d04..4f1ad96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1566,17 +1566,6 @@ dependencies = [ "walkdir", ] -[[package]] -name = "naviscope-ingest" -version = "0.5.5" -dependencies = [ - "async-trait", - "naviscope-api", - "thiserror", - "tokio", - "tracing", -] - [[package]] name = "naviscope-java" version = "0.5.5" diff --git a/Cargo.toml b/Cargo.toml index 0f65a04..55f91c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,6 @@ resolver = "2" members = [ "crates/core", - "crates/ingest", "crates/lang-java", "crates/lang-gradle", "crates/cli", @@ -15,7 +14,6 @@ members = [ [workspace.dependencies] naviscope-core = { path = "crates/core" } -naviscope-ingest = { path = "crates/ingest" } naviscope-java = { path = "crates/lang-java" } naviscope-gradle = { path = "crates/lang-gradle" } naviscope-lsp = { path = "crates/lsp" } diff --git a/crates/ingest/Cargo.toml b/crates/ingest/Cargo.toml deleted file mode 100644 index a4017fc..0000000 --- a/crates/ingest/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "naviscope-ingest" -version = "0.5.5" -edition = "2024" - -[dependencies] -thiserror = { workspace = true } -tokio = { workspace = true } -tracing = { workspace = true } -naviscope-api = { workspace = true } -async-trait = { workspace = true } diff --git a/crates/ingest/README.md b/crates/ingest/README.md deleted file mode 100644 index d95d64a..0000000 --- a/crates/ingest/README.md +++ /dev/null @@ -1,163 +0,0 @@ -# naviscope-ingest - -## 1. Definition - -`naviscope-ingest` is a DAG execution runtime built on a message queue. - -It drives task transitions between `runnable` and `deferred` via dependencies (`depends_on`), and uses `epoch` for batch completion semantics. - -- Component type: runtime middleware -- Input: `Message

` -- Output: committed operation results through `CommitSink` -- Goal: stable dependency-aware execution and batch completion under high concurrency - -## 2. Responsibility Boundaries - -### 2.1 In Scope - -- Message intake and backpressure control -- Deferred persistence and replay for unresolved dependencies -- Dependency-ready event dispatch (`message` / `resource`) -- Batch (`epoch`) completion lifecycle -- Unified runtime observability hooks - -### 2.2 Out of Scope - -- Business semantics -- Source scanning and incremental discovery -- Concrete storage engine strategy - -## 3. Architecture Model - -```mermaid -flowchart LR - P[Producer] --> IH[IntakeHandle] - IH --> IN[Intake Stream] - - IN --> G{depends_on empty?} - G -->|yes| EX[Executor] - G -->|no| DQ[Deferred Queue] - - EX -->|Executed| CM[CommitSink] - EX -->|Deferred| DQ - EX -->|Fatal| ER[Runtime Error] - - CM --> DR[Dependency Ready] - DR --> DS[DeferredStore] - DQ --> DS - DS --> RP[Replay pop_ready] - RP --> IN - - IH --> EP[EpochTracker] - EX --> EP - CM --> EP -``` - -## 4. Runtime Layers - -```mermaid -flowchart TB - subgraph API["API Layer"] - A1[IntakeHandle] - A2[notify_dependency_ready] - A3[new/seal/wait epoch] - end - - subgraph CORE["Runtime Core"] - C1[Kernel Event Loop] - C2[Flow Control] - C3[Worker Pool] - end - - subgraph SPI["Extension SPI"] - S1[Executor] - S2[DeferredStore] - S3[CommitSink] - S4[RuntimeMetrics] - S5[PipelineBus] - end - - API --> CORE - CORE --> SPI -``` - -## 5. Lifecycle Model - -### 5.1 Message Lifecycle - -```mermaid -stateDiagram-v2 - [*] --> Ingested - Ingested --> Runnable: no dependency - Ingested --> Deferred: dependency unresolved - Deferred --> Runnable: dependency ready - Runnable --> Executed - Runnable --> Deferred: executor defers - Runnable --> Fatal - Executed --> Committed - Committed --> [*] - Fatal --> [*] -``` - -### 5.2 Epoch Lifecycle - -```mermaid -stateDiagram-v2 - [*] --> Open - Open --> Open: submit / internal derived - Open --> Sealed: seal_epoch - Sealed --> Completed: committed >= submitted - Completed --> [*] -``` - -## 6. Processing Sequence - -```mermaid -sequenceDiagram - participant U as Upstream - participant H as IntakeHandle - participant K as Kernel - participant X as Executor - participant C as CommitSink - participant S as DeferredStore - participant E as EpochTracker - - U->>H: new_epoch - U->>H: submit(messages) - H->>E: submitted += n - H->>K: enqueue - U->>H: seal_epoch - - K->>X: execute runnable - X-->>K: Executed / Deferred / Fatal - K->>S: persist deferred - K->>C: commit executed - C-->>K: commit ok - K->>S: notify_ready(message/resource) - K->>E: committed += m - S-->>K: replay ready messages - - U->>H: wait_epoch - H-->>U: return when completed -``` - -## 7. Extension Contracts (SPI) - -- `Executor`: business execution and event emission -- `DeferredStore

`: deferred storage, readiness evaluation, and replay source -- `CommitSink`: commit boundary and visibility control -- `RuntimeMetrics`: metrics collection -- `PipelineBus`: channel model abstraction - -## 8. Semantic Guarantees - -- Processing semantics: at-least-once -- Idempotency: `Executor` and `CommitSink` should provide idempotency as needed -- Epoch completion: after `seal_epoch`, completion is `committed >= submitted` -- Internally derived deferred messages are counted into the same epoch's `submitted` - -## 9. Integration Checklist - -1. Assemble `RuntimeComponents` (`Executor` / `DeferredStore` / `CommitSink` / `RuntimeMetrics` / `PipelineBus`). -2. Start `IngestRuntime::run_forever()`. -3. Submit batches through `IntakeHandle` and wait with epoch APIs. diff --git a/crates/ingest/src/error.rs b/crates/ingest/src/error.rs deleted file mode 100644 index 577e2fc..0000000 --- a/crates/ingest/src/error.rs +++ /dev/null @@ -1,15 +0,0 @@ -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum IngestError { - #[error("invalid message: {0}")] - InvalidMessage(String), - #[error("dependency resolution failed: {0}")] - Dependency(String), - #[error("execution failed: {0}")] - Execution(String), - #[error("commit failed: {0}")] - Commit(String), - #[error("storage failed: {0}")] - Storage(String), -} diff --git a/crates/ingest/src/lib.rs b/crates/ingest/src/lib.rs deleted file mode 100644 index f6625ed..0000000 --- a/crates/ingest/src/lib.rs +++ /dev/null @@ -1,15 +0,0 @@ -pub mod error; -pub mod runtime; -pub mod traits; -pub mod types; - -pub use error::IngestError; -pub use runtime::{ - DynCommitSink, DynDeferredStore, DynExecutor, DynPipelineBus, DynRuntimeMetrics, - FlowControlConfig, IngestRuntime, IntakeHandle, PipelineBus, RuntimeComponents, TokioPipelineBus, -}; -pub use traits::{CommitSink, DeferredStore, Executor, RuntimeMetrics}; -pub use types::{ - DependencyKind, DependencyReadyEvent, DependencyRef, ExecutionResult, ExecutionStatus, Message, - MessageGroup, MessageId, OperationBatch, PipelineEvent, ResourceKey, RuntimeConfig, Topic, -}; diff --git a/crates/ingest/src/runtime/flow_control.rs b/crates/ingest/src/runtime/flow_control.rs deleted file mode 100644 index 57362e0..0000000 --- a/crates/ingest/src/runtime/flow_control.rs +++ /dev/null @@ -1,70 +0,0 @@ -use std::sync::Arc; -use std::time::Duration; - -use tokio::sync::{OwnedSemaphorePermit, Semaphore}; - -use crate::error::IngestError; -use crate::types::RuntimeConfig; - -#[derive(Clone)] -pub struct FlowControlConfig { - pub channel_capacity: usize, - pub max_in_flight: usize, - pub deferred_poll_limit: usize, - pub idle_sleep_ms: u64, -} - -impl Default for FlowControlConfig { - fn default() -> Self { - Self { - channel_capacity: 256, - max_in_flight: 256, - deferred_poll_limit: 256, - idle_sleep_ms: 10, - } - } -} - -impl From<&RuntimeConfig> for FlowControlConfig { - fn from(value: &RuntimeConfig) -> Self { - Self { - channel_capacity: value.kernel_channel_capacity, - max_in_flight: value.max_in_flight, - deferred_poll_limit: value.deferred_poll_limit, - idle_sleep_ms: value.idle_sleep_ms, - } - } -} - -#[derive(Clone)] -pub struct FlowController { - in_flight: Arc, - deferred_poll_limit: usize, - idle_sleep: Duration, -} - -impl FlowController { - pub fn new(config: &FlowControlConfig) -> Self { - Self { - in_flight: Arc::new(Semaphore::new(config.max_in_flight.max(1))), - deferred_poll_limit: config.deferred_poll_limit.max(1), - idle_sleep: Duration::from_millis(config.idle_sleep_ms.max(1)), - } - } - - pub async fn acquire_in_flight(&self) -> Result { - self.in_flight - .clone() - .acquire_owned() - .await - .map_err(|_| IngestError::Execution("in-flight flow controller closed".to_string())) - } - - pub fn deferred_poll_limit(&self) -> usize { - self.deferred_poll_limit - } - - pub fn idle_sleep(&self) -> Duration { - self.idle_sleep - } -} diff --git a/crates/ingest/src/runtime/kernel.rs b/crates/ingest/src/runtime/kernel.rs deleted file mode 100644 index e220459..0000000 --- a/crates/ingest/src/runtime/kernel.rs +++ /dev/null @@ -1,402 +0,0 @@ -use std::collections::BTreeMap; -use std::sync::Arc; - -use tokio::sync::mpsc; -use tokio::task::JoinSet; -use tracing::warn; - -use crate::error::IngestError; -use crate::runtime::flow_control::{FlowControlConfig, FlowController}; -use crate::runtime::{DynCommitSink, DynDeferredStore, DynExecutor, DynRuntimeMetrics}; -use crate::types::{DependencyRef, ExecutionResult, Message, PipelineEvent}; - -pub struct BusChannels

-where - P: Clone + Send + Sync + 'static, -{ - pub intake_tx: mpsc::Sender>, - pub intake_rx: mpsc::Receiver>, - pub deferred_tx: mpsc::Sender>, - pub deferred_rx: mpsc::Receiver>, -} - -pub trait PipelineBus: Send + Sync -where - P: Clone + Send + Sync + 'static, - Op: Send + Sync + 'static, -{ - fn open_channels(&self, capacity: usize) -> BusChannels

; -} - -#[derive(Default)] -pub struct TokioPipelineBus; - -impl PipelineBus for TokioPipelineBus -where - P: Clone + Send + Sync + 'static, - Op: Send + Sync + 'static, -{ - fn open_channels(&self, capacity: usize) -> BusChannels

{ - let cap = capacity.max(1); - let (intake_tx, intake_rx) = mpsc::channel::>(cap); - let (deferred_tx, deferred_rx) = mpsc::channel::>(cap); - - BusChannels { - intake_tx, - intake_rx, - deferred_tx, - deferred_rx, - } - } -} - -#[derive(Debug, Default)] -pub struct KernelRunStats { - pub runnable_messages: usize, - pub deferred_from_schedule: usize, - pub deferred_from_execute: usize, - pub deferred_persisted: usize, - pub committed_batches: usize, -} - -#[derive(Default)] -struct MessageRunStats { - runnable_messages: usize, - deferred_from_schedule: usize, - deferred_from_execute: usize, - committed_batches: usize, -} - -impl KernelRunStats { - fn merge_message_stats(&mut self, msg: MessageRunStats) { - self.runnable_messages += msg.runnable_messages; - self.deferred_from_schedule += msg.deferred_from_schedule; - self.deferred_from_execute += msg.deferred_from_execute; - self.committed_batches += msg.committed_batches; - } -} - -pub async fn run_pipeline( - channels: BusChannels

, - executor: DynExecutor, - deferred_store: DynDeferredStore

, - commit_sink: DynCommitSink, - metrics: DynRuntimeMetrics, - config: &FlowControlConfig, -) -> Result -where - P: Clone + Send + Sync + 'static, - Op: Send + Sync + 'static, -{ - run_pipeline_with_epoch_tracker( - channels, - executor, - deferred_store, - commit_sink, - metrics, - None, - config, - ) - .await -} - -pub(crate) async fn run_pipeline_with_epoch_tracker( - channels: BusChannels

, - executor: DynExecutor, - deferred_store: DynDeferredStore

, - commit_sink: DynCommitSink, - metrics: DynRuntimeMetrics, - epoch_tracker: Option>, - config: &FlowControlConfig, -) -> Result -where - P: Clone + Send + Sync + 'static, - Op: Send + Sync + 'static, -{ - let BusChannels { - intake_tx, - mut intake_rx, - deferred_tx, - mut deferred_rx, - } = channels; - drop(intake_tx); - - let flow = FlowController::new(config); - let mut replay_tick = tokio::time::interval(flow.idle_sleep()); - - let mut stats = KernelRunStats::default(); - let mut workers = JoinSet::new(); - let mut intake_closed = false; - - loop { - // Central event loop: - // - waits on worker completions, deferred persistence, deferred replay ticks, and intake; - // - picks exactly one ready branch per iteration, then loops again; - // - `biased;` gives priority in source order to reduce completed-worker lag. - tokio::select! { - biased; - - joined = workers.join_next(), if !workers.is_empty() => { - match joined { - Some(joined) => { - let msg_stats = joined - .map_err(|e| IngestError::Execution(format!("worker join failure: {e}")))??; - stats.merge_message_stats(msg_stats); - } - None => {} - } - } - - maybe_msg = deferred_rx.recv() => { - match maybe_msg { - Some(msg) => { - persist_deferred(Arc::clone(&deferred_store), msg).await?; - stats.deferred_persisted += 1; - } - None => { - if intake_closed && workers.is_empty() { - break; - } - } - } - } - - _ = replay_tick.tick(), if !intake_closed => { - let ready = pop_ready_messages( - Arc::clone(&deferred_store), - flow.deferred_poll_limit(), - ).await?; - if !ready.is_empty() { - metrics.observe_replay_result(true); - } - - for msg in ready { - let permit = flow.acquire_in_flight().await?; - let executor_cloned = Arc::clone(&executor); - let commit_sink_cloned = Arc::clone(&commit_sink); - let deferred_store_cloned = Arc::clone(&deferred_store); - let deferred_tx_cloned = deferred_tx.clone(); - let metrics_cloned = Arc::clone(&metrics); - let epoch_tracker_cloned = epoch_tracker.clone(); - workers.spawn(async move { - let _permit = permit; - process_message( - msg, - executor_cloned, - commit_sink_cloned, - deferred_store_cloned, - deferred_tx_cloned, - metrics_cloned, - epoch_tracker_cloned, - ) - .await - }); - } - } - - maybe_msg = intake_rx.recv(), if !intake_closed => { - match maybe_msg { - Some(msg) => { - let permit = flow.acquire_in_flight().await?; - let executor_cloned = Arc::clone(&executor); - let commit_sink_cloned = Arc::clone(&commit_sink); - let deferred_store_cloned = Arc::clone(&deferred_store); - let deferred_tx_cloned = deferred_tx.clone(); - let metrics_cloned = Arc::clone(&metrics); - let epoch_tracker_cloned = epoch_tracker.clone(); - workers.spawn(async move { - let _permit = permit; - process_message( - msg, - executor_cloned, - commit_sink_cloned, - deferred_store_cloned, - deferred_tx_cloned, - metrics_cloned, - epoch_tracker_cloned, - ) - .await - }); - } - None => intake_closed = true, - } - } - } - - if intake_closed && workers.is_empty() { - while let Ok(msg) = deferred_rx.try_recv() { - persist_deferred(Arc::clone(&deferred_store), msg).await?; - stats.deferred_persisted += 1; - } - - drop(deferred_tx); - while let Some(msg) = deferred_rx.recv().await { - persist_deferred(Arc::clone(&deferred_store), msg).await?; - stats.deferred_persisted += 1; - } - break; - } - } - - Ok(stats) -} - -async fn process_message( - message: Message

, - executor: DynExecutor, - commit_sink: DynCommitSink, - deferred_store: DynDeferredStore

, - deferred_tx: mpsc::Sender>, - metrics: DynRuntimeMetrics, - epoch_tracker: Option>, -) -> Result -where - P: Clone + Send + Sync + 'static, - Op: Send + Sync + 'static, -{ - let mut stats = MessageRunStats::default(); - if !message.depends_on.is_empty() { - stats.deferred_from_schedule += 1; - deferred_tx.send(message).await.map_err(|_| { - IngestError::Execution("kernel deferred channel closed".to_string()) - })?; - } else { - stats.runnable_messages += 1; - let msg_stats = execute_runnable( - message, - Arc::clone(&executor), - Arc::clone(&commit_sink), - Arc::clone(&deferred_store), - deferred_tx.clone(), - epoch_tracker.clone(), - ) - .await?; - stats.deferred_from_execute += msg_stats.deferred_from_execute; - stats.committed_batches += msg_stats.committed_batches; - } - - metrics.observe_throughput("kernel_message", 1); - Ok(stats) -} - -#[derive(Default)] -struct RunnableRunStats { - deferred_from_execute: usize, - committed_batches: usize, -} - -async fn execute_runnable( - message: Message

, - executor: DynExecutor, - commit_sink: DynCommitSink, - deferred_store: DynDeferredStore

, - deferred_tx: mpsc::Sender>, - epoch_tracker: Option>, -) -> Result -where - P: Clone + Send + Sync + 'static, - Op: Send + Sync + 'static, -{ - let mut stats = RunnableRunStats::default(); - - let executor_cloned = Arc::clone(&executor); - let execute_events = tokio::task::spawn_blocking(move || executor_cloned.execute(message)) - .await - .map_err(|e| IngestError::Execution(format!("execute join failure: {e}")))??; - - let mut by_epoch: BTreeMap>> = BTreeMap::new(); - for event in execute_events { - match event { - PipelineEvent::Executed { epoch, result } => { - by_epoch.entry(epoch).or_default().push(result); - } - PipelineEvent::Deferred(msg) => { - if let Some(tracker) = epoch_tracker.as_ref() { - tracker.record_internal_submit(msg.epoch)?; - } - stats.deferred_from_execute += 1; - deferred_tx.send(msg).await.map_err(|_| { - IngestError::Execution("kernel deferred channel closed".to_string()) - })?; - } - PipelineEvent::Fatal { msg_id, error } => { - let emsg = error.unwrap_or_else(|| "unknown fatal error".to_string()); - warn!("fatal execute event for {msg_id}: {emsg}"); - return Err(IngestError::Execution(format!( - "fatal execute event for {msg_id}: {emsg}" - ))); - } - PipelineEvent::Runnable(_) => { - return Err(IngestError::Execution( - "executor emitted invalid event".to_string(), - )); - } - } - } - - for (epoch, results) in by_epoch { - let completed_results: Vec<(String, Vec)> = results - .iter() - .map(|r| (r.msg_id.clone(), r.next_dependencies.clone())) - .collect(); - let sink = Arc::clone(&commit_sink); - let committed = tokio::task::spawn_blocking(move || sink.commit_epoch(epoch, results)) - .await - .map_err(|e| IngestError::Execution(format!("commit join failure: {e}")))??; - stats.committed_batches += committed; - - for (msg_id, next_dependencies) in completed_results { - notify_dependency_ready( - Arc::clone(&deferred_store), - DependencyRef::message(msg_id), - ) - .await?; - for dep in next_dependencies { - notify_dependency_ready(Arc::clone(&deferred_store), dep).await?; - } - if let Some(tracker) = epoch_tracker.as_ref() { - tracker.mark_committed(epoch)?; - } - } - } - - Ok(stats) -} - -async fn persist_deferred

( - deferred_store: DynDeferredStore

, - message: Message

, -) -> Result<(), IngestError> -where - P: Clone + Send + Sync + 'static, -{ - tokio::task::spawn_blocking(move || deferred_store.push(message)) - .await - .map_err(|e| IngestError::Execution(format!("deferred sink join failure: {e}")))? -} - -async fn pop_ready_messages

( - deferred_store: DynDeferredStore

, - limit: usize, -) -> Result>, IngestError> -where - P: Clone + Send + Sync + 'static, -{ - tokio::task::spawn_blocking(move || deferred_store.pop_ready(limit.max(1))) - .await - .map_err(|e| IngestError::Execution(format!("deferred replay join failure: {e}")))? -} - -async fn notify_dependency_ready

( - deferred_store: DynDeferredStore

, - dependency: DependencyRef, -) -> Result<(), IngestError> -where - P: Clone + Send + Sync + 'static, -{ - tokio::task::spawn_blocking(move || { - deferred_store.notify_ready(crate::types::DependencyReadyEvent { dependency }) - }) - .await - .map_err(|e| IngestError::Execution(format!("deferred notify join failure: {e}")))? -} diff --git a/crates/ingest/src/runtime/mod.rs b/crates/ingest/src/runtime/mod.rs deleted file mode 100644 index ba07ae8..0000000 --- a/crates/ingest/src/runtime/mod.rs +++ /dev/null @@ -1,354 +0,0 @@ -use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; - -use tokio::sync::{Mutex, Notify, mpsc}; - -use crate::error::IngestError; -use crate::traits::{CommitSink, DeferredStore, Executor, RuntimeMetrics}; -use crate::types::{DependencyReadyEvent, Message, RuntimeConfig}; - -pub mod flow_control; -pub mod kernel; - -pub use flow_control::FlowControlConfig; -pub use kernel::{PipelineBus, TokioPipelineBus}; - -pub type DynExecutor = Arc + Send + Sync>; -pub type DynDeferredStore

= Arc + Send + Sync>; -pub type DynCommitSink = Arc + Send + Sync>; -pub type DynPipelineBus = Arc + Send + Sync>; -pub type DynRuntimeMetrics = Arc; - -#[derive(Default)] -pub(super) struct EpochTracker { - inner: std::sync::Mutex>, -} - -struct EpochState { - submitted: usize, - committed: usize, - sealed: bool, - notify: Arc, -} - -impl EpochTracker { - fn record_submit(&self, epoch: u64) -> Result<(), IngestError> { - let mut guard = self - .inner - .lock() - .map_err(|_| IngestError::Execution("epoch tracker poisoned".to_string()))?; - let state = guard.entry(epoch).or_insert_with(|| EpochState { - submitted: 0, - committed: 0, - sealed: false, - notify: Arc::new(Notify::new()), - }); - if state.sealed { - return Err(IngestError::Execution(format!( - "epoch {epoch} is sealed; no more submissions allowed" - ))); - } - state.submitted += 1; - Ok(()) - } - - pub(super) fn record_internal_submit(&self, epoch: u64) -> Result<(), IngestError> { - let mut guard = self - .inner - .lock() - .map_err(|_| IngestError::Execution("epoch tracker poisoned".to_string()))?; - let state = guard.entry(epoch).or_insert_with(|| EpochState { - submitted: 0, - committed: 0, - sealed: false, - notify: Arc::new(Notify::new()), - }); - state.submitted += 1; - Ok(()) - } - - fn seal(&self, epoch: u64) -> Result<(), IngestError> { - let notify = { - let mut guard = self - .inner - .lock() - .map_err(|_| IngestError::Execution("epoch tracker poisoned".to_string()))?; - let state = guard.entry(epoch).or_insert_with(|| EpochState { - submitted: 0, - committed: 0, - sealed: false, - notify: Arc::new(Notify::new()), - }); - state.sealed = true; - if state.committed >= state.submitted { - Some(Arc::clone(&state.notify)) - } else { - None - } - }; - if let Some(n) = notify { - n.notify_waiters(); - } - Ok(()) - } - - fn rollback_submit(&self, epoch: u64) -> Result<(), IngestError> { - let mut guard = self - .inner - .lock() - .map_err(|_| IngestError::Execution("epoch tracker poisoned".to_string()))?; - let mut should_remove = false; - if let Some(state) = guard.get_mut(&epoch) { - if state.submitted > state.committed { - state.submitted -= 1; - } - if !state.sealed && state.submitted == 0 && state.committed == 0 { - should_remove = true; - } - } - if should_remove { - guard.remove(&epoch); - } - Ok(()) - } - - pub(super) fn mark_committed(&self, epoch: u64) -> Result<(), IngestError> { - let notify = { - let mut guard = self - .inner - .lock() - .map_err(|_| IngestError::Execution("epoch tracker poisoned".to_string()))?; - let state = guard.entry(epoch).or_insert_with(|| EpochState { - submitted: 0, - committed: 0, - sealed: false, - notify: Arc::new(Notify::new()), - }); - state.committed += 1; - if state.sealed && state.committed >= state.submitted { - Some(Arc::clone(&state.notify)) - } else { - None - } - }; - if let Some(n) = notify { - n.notify_waiters(); - } - Ok(()) - } - - async fn wait_epoch(&self, epoch: u64) -> Result<(), IngestError> { - loop { - let maybe_notify = { - let mut guard = self - .inner - .lock() - .map_err(|_| IngestError::Execution("epoch tracker poisoned".to_string()))?; - let state = guard.entry(epoch).or_insert_with(|| EpochState { - submitted: 0, - committed: 0, - sealed: false, - notify: Arc::new(Notify::new()), - }); - if state.sealed && state.committed >= state.submitted { - guard.remove(&epoch); - None - } else { - Some(Arc::clone(&state.notify)) - } - }; - match maybe_notify { - None => return Ok(()), - Some(n) => n.notified().await, - } - } - } -} - -pub struct RuntimeComponents -where - P: Clone + Send + Sync + 'static, - Op: Send + Sync + 'static, -{ - pub executor: DynExecutor, - pub deferred_store: DynDeferredStore

, - pub commit_sink: DynCommitSink, - pub bus: DynPipelineBus, - pub metrics: DynRuntimeMetrics, -} - -impl RuntimeComponents -where - P: Clone + Send + Sync + 'static, - Op: Send + Sync + 'static, -{ - pub fn with_tokio_bus( - executor: DynExecutor, - deferred_store: DynDeferredStore

, - commit_sink: DynCommitSink, - metrics: DynRuntimeMetrics, - ) -> Self { - Self { - executor, - deferred_store, - commit_sink, - bus: Arc::new(TokioPipelineBus), - metrics, - } - } -} - -#[derive(Clone)] -pub struct IntakeHandle

{ - tx: mpsc::Sender>, - next_epoch: Arc, - epoch_tracker: Arc, -} - -impl

IntakeHandle

-where - P: Clone + Send + Sync + 'static, -{ - pub fn try_submit(&self, message: Message

) -> Result<(), IngestError> { - let epoch = message.epoch; - self.epoch_tracker.record_submit(epoch)?; - self.tx.try_send(message).map_err(|err| { - let _ = self.epoch_tracker.rollback_submit(epoch); - match err { - tokio::sync::mpsc::error::TrySendError::Full(_) => { - IngestError::Execution("ingest intake queue full".to_string()) - } - tokio::sync::mpsc::error::TrySendError::Closed(_) => { - IngestError::Execution("ingest intake handle closed".to_string()) - } - }}) - } - - pub async fn submit(&self, message: Message

) -> Result<(), IngestError> { - let epoch = message.epoch; - self.epoch_tracker.record_submit(epoch)?; - self.tx - .send(message) - .await - .map_err(|_| { - let _ = self.epoch_tracker.rollback_submit(epoch); - IngestError::Execution("ingest intake handle closed".to_string()) - }) - } - - pub fn new_epoch(&self) -> u64 { - self.next_epoch.fetch_add(1, Ordering::Relaxed) - } - - pub fn seal_epoch(&self, epoch: u64) -> Result<(), IngestError> { - self.epoch_tracker.seal(epoch) - } - - pub async fn wait_epoch(&self, epoch: u64) -> Result<(), IngestError> { - self.epoch_tracker.wait_epoch(epoch).await - } -} - -pub struct IngestRuntime -where - P: Clone + Send + Sync + 'static, - Op: Send + Sync + 'static, -{ - pub executor: DynExecutor, - pub deferred_store: DynDeferredStore

, - pub commit_sink: DynCommitSink, - pub bus: DynPipelineBus, - pub metrics: DynRuntimeMetrics, - pub flow_control: FlowControlConfig, - intake_tx: mpsc::Sender>, - next_epoch: Arc, - epoch_tracker: Arc, - pipeline_channels: Mutex>>, - _marker: std::marker::PhantomData, -} - -impl IngestRuntime -where - P: Clone + Send + Sync + 'static, - Op: Send + Sync + 'static, -{ - pub fn new(config: RuntimeConfig, components: RuntimeComponents) -> Self { - let flow_control = FlowControlConfig::from(&config); - let channels = components.bus.open_channels(flow_control.channel_capacity); - let intake_tx = channels.intake_tx.clone(); - let epoch_tracker = Arc::new(EpochTracker::default()); - - Self { - executor: components.executor, - deferred_store: components.deferred_store, - commit_sink: components.commit_sink, - bus: components.bus, - metrics: components.metrics, - flow_control, - intake_tx, - next_epoch: Arc::new(AtomicU64::new(1)), - epoch_tracker, - pipeline_channels: Mutex::new(Some(channels)), - _marker: std::marker::PhantomData, - } - } - - pub fn intake_handle(&self) -> IntakeHandle

{ - IntakeHandle { - tx: self.intake_tx.clone(), - next_epoch: Arc::clone(&self.next_epoch), - epoch_tracker: Arc::clone(&self.epoch_tracker), - } - } - - pub async fn notify_dependency_ready( - &self, - event: DependencyReadyEvent, - ) -> Result<(), IngestError> { - let store = Arc::clone(&self.deferred_store); - tokio::task::spawn_blocking(move || store.notify_ready(event)) - .await - .map_err(|e| IngestError::Execution(format!("notify task join failure: {e}")))? - } - - pub async fn run_forever(&self) -> Result<(), IngestError> { - let channels = self - .pipeline_channels - .lock() - .await - .take() - .ok_or_else(|| IngestError::Execution("runtime already started".to_string()))?; - - let flow_control = self.flow_control.clone(); - let executor = Arc::clone(&self.executor); - let deferred_store = Arc::clone(&self.deferred_store); - let commit_sink = Arc::clone(&self.commit_sink); - let metrics = Arc::clone(&self.metrics); - let epoch_tracker = Arc::clone(&self.epoch_tracker); - let kernel_task = tokio::spawn(async move { - kernel::run_pipeline_with_epoch_tracker( - channels, - executor, - deferred_store, - commit_sink, - metrics, - Some(epoch_tracker.clone()), - &flow_control, - ) - .await - }); - - let stats = kernel_task - .await - .map_err(|e| IngestError::Execution(format!("kernel task join failure: {e}")))??; - - Err(IngestError::Execution(format!( - "kernel task exited unexpectedly (runnable={}, deferred_sched={}, deferred_exec={}, deferred_persisted={}, committed={})", - stats.runnable_messages, - stats.deferred_from_schedule, - stats.deferred_from_execute, - stats.deferred_persisted, - stats.committed_batches - ))) - } -} diff --git a/crates/ingest/src/traits.rs b/crates/ingest/src/traits.rs deleted file mode 100644 index 9921d7e..0000000 --- a/crates/ingest/src/traits.rs +++ /dev/null @@ -1,27 +0,0 @@ -use crate::error::IngestError; -use crate::types::{DependencyReadyEvent, ExecutionResult, Message, PipelineEvent}; - -pub trait Executor: Send + Sync { - fn execute(&self, message: Message

) -> Result>, IngestError>; -} - -pub trait DeferredStore

: Send + Sync { - fn push(&self, message: Message

) -> Result<(), IngestError>; - fn pop_ready(&self, limit: usize) -> Result>, IngestError>; - fn notify_ready(&self, event: DependencyReadyEvent) -> Result<(), IngestError>; -} - -pub trait CommitSink: Send + Sync { - fn commit_epoch( - &self, - epoch: u64, - results: Vec>, - ) -> Result; -} - -pub trait RuntimeMetrics: Send + Sync { - fn observe_queue_depth(&self, queue: &'static str, depth: usize); - fn observe_throughput(&self, stage: &'static str, count: usize); - fn observe_latency_ms(&self, stage: &'static str, p95_ms: u64, p99_ms: u64); - fn observe_replay_result(&self, ok: bool); -} diff --git a/crates/ingest/src/types.rs b/crates/ingest/src/types.rs deleted file mode 100644 index 9ee51f3..0000000 --- a/crates/ingest/src/types.rs +++ /dev/null @@ -1,112 +0,0 @@ -use std::collections::BTreeMap; - -pub type MessageId = String; -pub type Topic = String; -pub type MessageGroup = String; -pub type ResourceKey = String; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum DependencyKind { - Message, - Resource, -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct DependencyRef { - pub kind: DependencyKind, - pub target: String, - pub min_version: Option, -} - -impl DependencyRef { - pub fn message(msg_id: impl Into) -> Self { - Self { - kind: DependencyKind::Message, - target: msg_id.into(), - min_version: None, - } - } - - pub fn resource(resource_key: impl Into, min_version: Option) -> Self { - Self { - kind: DependencyKind::Resource, - target: resource_key.into(), - min_version, - } - } -} - -#[derive(Debug, Clone)] -pub struct Message

> { - pub msg_id: MessageId, - pub topic: Topic, - pub message_group: MessageGroup, - pub version: u64, - pub depends_on: Vec, - pub epoch: u64, - pub payload: P, - pub metadata: BTreeMap, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ExecutionStatus { - Done, - Deferred, - RetryableError, - FatalError, -} - -#[derive(Debug, Clone)] -pub struct ExecutionResult { - pub msg_id: MessageId, - pub status: ExecutionStatus, - pub operations: Vec, - pub next_dependencies: Vec, - pub error: Option, -} - -#[derive(Debug, Clone)] -pub struct OperationBatch { - pub batch_id: String, - pub epoch: u64, - pub operations: Vec, - pub source_msgs: Vec, -} - -#[derive(Debug, Clone)] -pub struct DependencyReadyEvent { - pub dependency: DependencyRef, -} - -#[derive(Debug, Clone)] -pub enum PipelineEvent { - Runnable(Message

), - Deferred(Message

), - Executed { - epoch: u64, - result: ExecutionResult, - }, - Fatal { - msg_id: MessageId, - error: Option, - }, -} - -#[derive(Debug, Clone)] -pub struct RuntimeConfig { - pub deferred_poll_limit: usize, - pub kernel_channel_capacity: usize, - pub max_in_flight: usize, - pub idle_sleep_ms: u64, -} - -impl Default for RuntimeConfig { - fn default() -> Self { - Self { - deferred_poll_limit: 256, - kernel_channel_capacity: 256, - max_in_flight: 256, - idle_sleep_ms: 10, - } - } -} diff --git a/crates/ingest/tests/runtime_kernel.rs b/crates/ingest/tests/runtime_kernel.rs deleted file mode 100644 index 3daca48..0000000 --- a/crates/ingest/tests/runtime_kernel.rs +++ /dev/null @@ -1,332 +0,0 @@ -use std::collections::BTreeMap; -use std::sync::{Arc, Mutex}; - -use naviscope_ingest::runtime::kernel; -use naviscope_ingest::{ - CommitSink, DeferredStore, Executor, FlowControlConfig, IngestError, IngestRuntime, - PipelineBus, PipelineEvent, RuntimeComponents, RuntimeConfig, RuntimeMetrics, TokioPipelineBus, -}; -use naviscope_ingest::{ - DependencyReadyEvent, DependencyRef, ExecutionResult, ExecutionStatus, Message, -}; - -fn message(id: &str, epoch: u64, payload: u8) -> Message { - Message { - msg_id: id.to_string(), - topic: "t".to_string(), - message_group: "g".to_string(), - version: 1, - depends_on: vec![], - epoch, - payload, - metadata: BTreeMap::new(), - } -} - -struct TestExecutor; -impl Executor for TestExecutor { - fn execute(&self, message: Message) -> Result>, IngestError> { - let event = match message.payload { - 2 => PipelineEvent::Deferred(message), - 3 => PipelineEvent::Fatal { - msg_id: message.msg_id, - error: Some("fatal".to_string()), - }, - _ => PipelineEvent::Executed { - epoch: message.epoch, - result: ExecutionResult { - msg_id: message.msg_id, - status: ExecutionStatus::Done, - operations: vec!["op".to_string()], - next_dependencies: vec![], - error: None, - }, - }, - }; - Ok(vec![event]) - } -} - -#[derive(Default)] -struct TestDeferredStore { - pushed: Mutex>, - notified: Mutex>, - ready: Mutex>>, -} -impl DeferredStore for TestDeferredStore { - fn push(&self, message: Message) -> Result<(), IngestError> { - self.pushed - .lock() - .expect("lock poisoned") - .push(message.msg_id); - Ok(()) - } - - fn pop_ready(&self, limit: usize) -> Result>, IngestError> { - let mut guard = self.ready.lock().expect("lock poisoned"); - let n = limit.min(guard.len()); - Ok(guard.drain(0..n).collect()) - } - - fn notify_ready(&self, event: DependencyReadyEvent) -> Result<(), IngestError> { - self.notified - .lock() - .expect("lock poisoned") - .push(event.dependency.target); - Ok(()) - } -} - -#[derive(Default)] -struct TestCommitSink { - commits: Mutex>, -} -impl CommitSink for TestCommitSink { - fn commit_epoch( - &self, - epoch: u64, - results: Vec>, - ) -> Result { - let size = results.len(); - self.commits - .lock() - .expect("lock poisoned") - .push((epoch, size)); - Ok(usize::from(size > 0)) - } -} - -struct TestMetrics; -impl RuntimeMetrics for TestMetrics { - fn observe_queue_depth(&self, _queue: &'static str, _depth: usize) {} - fn observe_throughput(&self, _stage: &'static str, _count: usize) {} - fn observe_latency_ms(&self, _stage: &'static str, _p95_ms: u64, _p99_ms: u64) {} - fn observe_replay_result(&self, _ok: bool) {} -} - -struct InvalidEventExecutor; -impl Executor for InvalidEventExecutor { - fn execute(&self, message: Message) -> Result>, IngestError> { - Ok(vec![PipelineEvent::Runnable(message)]) - } -} - -#[tokio::test] -async fn kernel_commits_runnable_messages() { - let executor = Arc::new(TestExecutor); - let store = Arc::new(TestDeferredStore::default()); - let sink = Arc::new(TestCommitSink::default()); - let metrics = Arc::new(TestMetrics); - let bus = TokioPipelineBus; - let channels = >::open_channels(&bus, 8); - let tx = channels.intake_tx.clone(); - - tx.send(message("m1", 7, 1)) - .await - .expect("send should work"); - drop(tx); - - let stats = kernel::run_pipeline( - channels, - executor, - store, - sink.clone(), - metrics, - &FlowControlConfig { - channel_capacity: 8, - max_in_flight: 8, - deferred_poll_limit: 8, - idle_sleep_ms: 1, - }, - ) - .await - .expect("pipeline should complete"); - - assert_eq!(stats.runnable_messages, 1); - assert_eq!(stats.committed_batches, 1); - assert_eq!( - sink.commits.lock().expect("lock poisoned").as_slice(), - &[(7, 1)] - ); -} - -#[tokio::test] -async fn kernel_persists_deferred_from_both_paths() { - let executor = Arc::new(TestExecutor); - let store = Arc::new(TestDeferredStore::default()); - let sink = Arc::new(TestCommitSink::default()); - let metrics = Arc::new(TestMetrics); - let bus = TokioPipelineBus; - let channels = >::open_channels(&bus, 8); - let tx = channels.intake_tx.clone(); - - let mut sched_deferred = message("m_sched_deferred", 1, 1); - sched_deferred - .depends_on - .push(DependencyRef::message("dep_not_ready")); - tx.send(sched_deferred) - .await - .expect("send should work"); - tx.send(message("m_exec_deferred", 1, 2)) - .await - .expect("send should work"); - drop(tx); - - let stats = kernel::run_pipeline( - channels, - executor, - store.clone(), - sink, - metrics, - &FlowControlConfig { - channel_capacity: 8, - max_in_flight: 8, - deferred_poll_limit: 8, - idle_sleep_ms: 1, - }, - ) - .await - .expect("pipeline should complete"); - - assert_eq!(stats.deferred_from_schedule, 1); - assert_eq!(stats.deferred_from_execute, 1); - assert_eq!(stats.deferred_persisted, 2); - let pushed = store.pushed.lock().expect("lock poisoned").clone(); - assert!(pushed.contains(&"m_sched_deferred".to_string())); - assert!(pushed.contains(&"m_exec_deferred".to_string())); -} - -#[tokio::test] -async fn runtime_notify_dependency_ready_delegates_to_store() { - let store = Arc::new(TestDeferredStore::default()); - let runtime = IngestRuntime::new( - RuntimeConfig::default(), - RuntimeComponents::with_tokio_bus( - Arc::new(TestExecutor), - store.clone(), - Arc::new(TestCommitSink::default()), - Arc::new(TestMetrics), - ), - ); - - runtime - .notify_dependency_ready(DependencyReadyEvent { - dependency: DependencyRef::message("dep-1"), - }) - .await - .expect("notify should work"); - - assert_eq!( - store.notified.lock().expect("lock poisoned").as_slice(), - &["dep-1".to_string()] - ); -} - -#[tokio::test] -async fn kernel_flushes_partial_batches_on_channel_close() { - let executor = Arc::new(TestExecutor); - let store = Arc::new(TestDeferredStore::default()); - let sink = Arc::new(TestCommitSink::default()); - let metrics = Arc::new(TestMetrics); - let bus = TokioPipelineBus; - let channels = >::open_channels(&bus, 8); - let tx = channels.intake_tx.clone(); - - tx.send(message("m_tail", 9, 1)) - .await - .expect("send should work"); - drop(tx); - - let stats = kernel::run_pipeline( - channels, - executor, - store, - sink.clone(), - metrics, - &FlowControlConfig { - channel_capacity: 8, - max_in_flight: 8, - deferred_poll_limit: 8, - idle_sleep_ms: 1, - }, - ) - .await - .expect("pipeline should flush tail batch"); - - assert_eq!(stats.runnable_messages, 1); - assert_eq!(stats.committed_batches, 1); - assert_eq!( - sink.commits.lock().expect("lock poisoned").as_slice(), - &[(9, 1)] - ); -} - -#[tokio::test] -async fn kernel_errors_on_executor_fatal_event() { - let executor = Arc::new(TestExecutor); - let store = Arc::new(TestDeferredStore::default()); - let sink = Arc::new(TestCommitSink::default()); - let metrics = Arc::new(TestMetrics); - let bus = TokioPipelineBus; - let channels = >::open_channels(&bus, 8); - let tx = channels.intake_tx.clone(); - - tx.send(message("m_fatal", 1, 3)) - .await - .expect("send should work"); - drop(tx); - - let err = kernel::run_pipeline( - channels, - executor, - store, - sink, - metrics, - &FlowControlConfig { - channel_capacity: 8, - max_in_flight: 8, - deferred_poll_limit: 8, - idle_sleep_ms: 1, - }, - ) - .await - .expect_err("fatal event should fail pipeline"); - - let msg = err.to_string(); - assert!(msg.contains("fatal execute event")); - assert!(msg.contains("m_fatal")); -} - -#[tokio::test] -async fn kernel_errors_on_invalid_executor_event() { - let executor = Arc::new(InvalidEventExecutor); - let store = Arc::new(TestDeferredStore::default()); - let sink = Arc::new(TestCommitSink::default()); - let metrics = Arc::new(TestMetrics); - let bus = TokioPipelineBus; - let channels = >::open_channels(&bus, 8); - let tx = channels.intake_tx.clone(); - - tx.send(message("m_bad_exec", 1, 1)) - .await - .expect("send should work"); - drop(tx); - - let err = kernel::run_pipeline( - channels, - executor, - store, - sink, - metrics, - &FlowControlConfig { - channel_capacity: 8, - max_in_flight: 8, - deferred_poll_limit: 8, - idle_sleep_ms: 1, - }, - ) - .await - .expect_err("invalid executor event should fail pipeline"); - - assert!(err.to_string().contains("executor emitted invalid event")); -} From 6486f7482d0b80ca9769e212352a1557c0567a39 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Fri, 20 Feb 2026 11:43:04 +0800 Subject: [PATCH 49/49] chore: update package versions to 0.7.0 across all crates --- Cargo.lock | 18 +++++++++--------- crates/api/Cargo.toml | 2 +- crates/cli/Cargo.toml | 2 +- crates/core/Cargo.toml | 2 +- crates/lang-gradle/Cargo.toml | 2 +- crates/lang-java/Cargo.toml | 2 +- crates/lsp/Cargo.toml | 2 +- crates/mcp/Cargo.toml | 2 +- crates/plugin/Cargo.toml | 2 +- crates/runtime/Cargo.toml | 2 +- 10 files changed, 18 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4f1ad96..82653dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1471,7 +1471,7 @@ dependencies = [ [[package]] name = "naviscope-api" -version = "0.5.5" +version = "0.7.0" dependencies = [ "async-trait", "lasso", @@ -1483,7 +1483,7 @@ dependencies = [ [[package]] name = "naviscope-cli" -version = "0.5.5" +version = "0.7.0" dependencies = [ "clap", "dirs", @@ -1507,7 +1507,7 @@ dependencies = [ [[package]] name = "naviscope-core" -version = "0.5.5" +version = "0.7.0" dependencies = [ "async-trait", "dashmap 6.1.0", @@ -1546,7 +1546,7 @@ dependencies = [ [[package]] name = "naviscope-gradle" -version = "0.5.5" +version = "0.7.0" dependencies = [ "dirs", "lasso", @@ -1568,7 +1568,7 @@ dependencies = [ [[package]] name = "naviscope-java" -version = "0.5.5" +version = "0.7.0" dependencies = [ "dirs", "lasso", @@ -1593,7 +1593,7 @@ dependencies = [ [[package]] name = "naviscope-lsp" -version = "0.5.5" +version = "0.7.0" dependencies = [ "dashmap 6.1.0", "naviscope-api", @@ -1610,7 +1610,7 @@ dependencies = [ [[package]] name = "naviscope-mcp" -version = "0.5.5" +version = "0.7.0" dependencies = [ "anyhow", "axum", @@ -1630,7 +1630,7 @@ dependencies = [ [[package]] name = "naviscope-plugin" -version = "0.5.5" +version = "0.7.0" dependencies = [ "async-trait", "lsp-types 0.97.0", @@ -1644,7 +1644,7 @@ dependencies = [ [[package]] name = "naviscope-runtime" -version = "0.5.5" +version = "0.7.0" dependencies = [ "naviscope-api", "naviscope-core", diff --git a/crates/api/Cargo.toml b/crates/api/Cargo.toml index b5df7de..58e062d 100644 --- a/crates/api/Cargo.toml +++ b/crates/api/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "naviscope-api" -version = "0.5.5" +version = "0.7.0" edition = "2024" [dependencies] diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 3af40a8..cca1bc6 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "naviscope-cli" -version = "0.5.5" +version = "0.7.0" edition = "2024" [[bin]] diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index f926c3b..efad719 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "naviscope-core" -version = "0.5.5" +version = "0.7.0" edition = "2024" [dependencies] diff --git a/crates/lang-gradle/Cargo.toml b/crates/lang-gradle/Cargo.toml index 29d9f89..9b781e0 100644 --- a/crates/lang-gradle/Cargo.toml +++ b/crates/lang-gradle/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "naviscope-gradle" -version = "0.5.5" +version = "0.7.0" edition = "2024" [dependencies] diff --git a/crates/lang-java/Cargo.toml b/crates/lang-java/Cargo.toml index 3a3e997..1ad88bc 100644 --- a/crates/lang-java/Cargo.toml +++ b/crates/lang-java/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "naviscope-java" -version = "0.5.5" +version = "0.7.0" edition = "2024" [dependencies] diff --git a/crates/lsp/Cargo.toml b/crates/lsp/Cargo.toml index f81e87e..7a1458e 100644 --- a/crates/lsp/Cargo.toml +++ b/crates/lsp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "naviscope-lsp" -version = "0.5.5" +version = "0.7.0" edition = "2024" [dependencies] diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml index a77a38d..d15eb40 100644 --- a/crates/mcp/Cargo.toml +++ b/crates/mcp/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "naviscope-mcp" -version = "0.5.5" +version = "0.7.0" edition = "2024" [dependencies] diff --git a/crates/plugin/Cargo.toml b/crates/plugin/Cargo.toml index 518b442..3c33a1b 100644 --- a/crates/plugin/Cargo.toml +++ b/crates/plugin/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "naviscope-plugin" -version = "0.5.5" +version = "0.7.0" edition = "2024" [dependencies] diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml index 4a14c04..f41e0a2 100644 --- a/crates/runtime/Cargo.toml +++ b/crates/runtime/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "naviscope-runtime" -version = "0.5.5" +version = "0.7.0" edition = "2024" [dependencies]