diff --git a/crates/lsp/src/global_state.rs b/crates/lsp/src/global_state.rs index d8033a1..4212e60 100644 --- a/crates/lsp/src/global_state.rs +++ b/crates/lsp/src/global_state.rs @@ -146,7 +146,14 @@ impl GlobalState { if token.kind() == TokenKind::CircomString { return jump_to_lib(file_db, token, self.source_db.vfs()); } - self.resolve_use(file_db, token) + // A component member-access field (`c.x` / `T()(...).x`) resolves via type inference + // (`resolve_member`), not the flat name resolver (which returns empty for fields by design). + let resolved = if resolver::component_field(token).is_some() { + self.resolve_member(file_db, token) + } else { + self.resolve_use(file_db, token) + }; + resolved .into_iter() .map(|(id, s)| Location::new(self.source_db.file_db(id).file_path.clone(), s.def_range)) .collect() @@ -212,6 +219,62 @@ impl GlobalState { out } + /// Resolve a component member-access **field** token (`c.x` / `T()(...).x`) to its signal + /// declaration in the receiver's template — the type-inference path the flat [`resolve`] + /// deliberately omits. Returns the file-tagged signal declaration(s), or empty if the receiver + /// isn't a (instantiated) component, its template isn't found, or the field isn't one of its + /// signals. Shared by goto-definition and hover. References/rename of fields are intentionally + /// NOT handled here (they need per-template occurrence search) and remain no-ops. + pub(crate) fn resolve_member( + &self, + origin: &FileDB, + field: &SyntaxToken, + ) -> Vec<(FileId, ResolvedSymbol)> { + let Some(call) = resolver::component_field(field) else { + return Vec::new(); + }; + let Some(receiver) = resolver::receiver_of(&call) else { + return Vec::new(); + }; + let Some(recv_tok) = resolver::first_identifier(&receiver) else { + return Vec::new(); + }; + + // Template name: for an anonymous instantiation the receiver's callee IS the template; for + // a named component, look the receiver up as a component to get its instantiated template. + let table = self.source_db.symbol_table(origin.file_id); + let template_name = if resolver::contains_call(&receiver) { + recv_tok.text().to_string() + } else { + match table.component_type_at(recv_tok.text_range().start(), recv_tok.text()) { + Some(t) => t.to_string(), + None => return Vec::new(), + } + }; + + let Some(template_file) = self.resolve_template_file(origin, &template_name) else { + return Vec::new(); + }; + let field_name = field.text(); + self.source_db + .symbol_table(template_file) + .members_of(&template_name) + .into_iter() + .filter(|s| s.name == field_name) + .map(|s| { + ( + template_file, + ResolvedSymbol { + kind: s.kind, + name: s.name.clone(), + def_range: s.def_range, + decl_range: s.decl_range, + }, + ) + }) + .collect() + } + /// The file defining top-level `name`: `origin` if it declares it, else the first loaded /// include that does. Used by member completion to find a component's template (may live in a /// lib). diff --git a/crates/lsp/src/handler/goto_definition.rs b/crates/lsp/src/handler/goto_definition.rs index f18ba8d..556ab29 100644 --- a/crates/lsp/src/handler/goto_definition.rs +++ b/crates/lsp/src/handler/goto_definition.rs @@ -244,4 +244,134 @@ mod tests { let _ = fs::remove_dir_all(&base); } + + /// Goto-definition on a **named** component member field (`c.out`) jumps to the `out` signal + /// declaration in the receiver's template — the type-inference path the flat resolver omits. + #[test] + fn member_field_named_jump_test() { + let source = "pragma circom 2.0.0;\ntemplate Multiplier2() {\n signal input in[2];\n signal output out;\n out <== in[0] * in[1];\n}\ntemplate Main() {\n component c = Multiplier2();\n signal output res;\n res <== c.out;\n}\n"; + let url = Url::from_file_path("/tmp/mf_named.circom").unwrap(); + let state = state_with(&url, source); + + // `out` occurrences: [0]=decl, [1]=usage in Multiplier2, [2]=the `c.out` field. + let locs = jump(&state, &url, source, "out", 2); + + assert_eq!(locs.len(), 1, "named member-field jump: {locs:?}"); + assert_eq!(locs[0].uri, url, "jumps within the same file"); + // The `signal output out;` declaration is on line 4 (0-indexed 3). + assert_eq!( + locs[0].range.start.line, 3, + "lands on the out signal: {locs:?}" + ); + } + + /// Goto-definition on an **anonymous** component member field (`Multiplier2()([a, b]).out`) + /// jumps to the `out` signal — the reported case. The receiver is a `Call` (no named component + /// variable), so the template name comes from the callee. + #[test] + fn member_field_anonymous_jump_test() { + let source = "pragma circom 2.0.0;\ntemplate Multiplier2() {\n signal input in[2];\n signal output out;\n out <== in[0] * in[1];\n}\ntemplate Main() {\n signal input a;\n signal input b;\n signal output c;\n c <== Multiplier2()([a, b]).out;\n}\n"; + let url = Url::from_file_path("/tmp/mf_anon.circom").unwrap(); + let state = state_with(&url, source); + + // `out` occurrences: [0]=decl, [1]=usage in Multiplier2, [2]=the anonymous `.out` field. + let locs = jump(&state, &url, source, "out", 2); + + assert_eq!(locs.len(), 1, "anonymous member-field jump: {locs:?}"); + assert_eq!(locs[0].uri, url, "jumps within the same file"); + assert_eq!( + locs[0].range.start.line, 3, + "lands on the out signal: {locs:?}" + ); + } + + /// Goto-definition on a **named** member field where the template lives in an `include`d file + /// jumps across the include to the signal declaration. + #[test] + fn member_field_cross_file_jump_test() { + use std::fs; + + let base = std::env::temp_dir().join(format!("ccls_mf_xfile_{}", std::process::id())); + let ws = base.join("ws"); + fs::create_dir_all(&ws).unwrap(); + fs::write( + ws.join("lib.circom"), + "pragma circom 2.0.0;\ntemplate Lib() {\n signal output o;\n}\n", + ) + .unwrap(); + let main_src = "pragma circom 2.0.0;\ninclude \"lib.circom\";\ntemplate Main() {\n component c = Lib();\n signal output res;\n res <== c.o;\n}\n"; + let main_path = ws.join("main.circom"); + fs::write(&main_path, main_src).unwrap(); + + let main_url = Url::from_file_path(&main_path).unwrap(); + let mut state = GlobalState::new(vec![ws.canonicalize().unwrap()]); + state + .source_db + .set_document(&main_url, main_src.to_string()); + state.source_db.load_include(&main_url, "lib.circom"); + + // `o` occurrences in main: [0]=the `c.o` field (lib's `o` is in another file). + let locs = jump(&state, &main_url, main_src, "o", 0); + + assert_eq!(locs.len(), 1, "cross-file member-field jump: {locs:?}"); + assert!( + locs[0].uri.to_file_path().unwrap().ends_with("lib.circom"), + "jumps into the included lib: {}", + locs[0].uri + ); + // lib's `signal output o;` is on line 3 (0-indexed 2). + assert_eq!( + locs[0].range.start.line, 2, + "lands on the lib signal: {locs:?}" + ); + + let _ = fs::remove_dir_all(&base); + } + + /// Goto-definition on an **anonymous** member field whose template lives in an `include`d file + /// (`Lib()().o`) jumps across the include to the signal. Combines the anonymous callee + /// extraction with cross-file template resolution — both exercised only separately above. + #[test] + fn member_field_anonymous_cross_file_jump_test() { + use std::fs; + + let base = std::env::temp_dir().join(format!("ccls_mfax_{}", std::process::id())); + let ws = base.join("ws"); + fs::create_dir_all(&ws).unwrap(); + fs::write( + ws.join("lib.circom"), + "pragma circom 2.0.0;\ntemplate Lib() {\n signal output o;\n}\n", + ) + .unwrap(); + let main_src = "pragma circom 2.0.0;\ninclude \"lib.circom\";\ntemplate Main() {\n signal output res;\n res <== Lib()().o;\n}\n"; + let main_path = ws.join("main.circom"); + fs::write(&main_path, main_src).unwrap(); + + let main_url = Url::from_file_path(&main_path).unwrap(); + let mut state = GlobalState::new(vec![ws.canonicalize().unwrap()]); + state + .source_db + .set_document(&main_url, main_src.to_string()); + state.source_db.load_include(&main_url, "lib.circom"); + + // `o` occurrences in main: [0]=the `Lib()().o` field. + let locs = jump(&state, &main_url, main_src, "o", 0); + + assert_eq!( + locs.len(), + 1, + "cross-file anonymous member-field jump: {locs:?}" + ); + assert!( + locs[0].uri.to_file_path().unwrap().ends_with("lib.circom"), + "jumps into the included lib: {}", + locs[0].uri + ); + assert_eq!( + locs[0].range.start.line, 2, + "lands on the lib signal: {locs:?}" + ); + + let _ = fs::remove_dir_all(&base); + } } diff --git a/crates/lsp/src/handler/hover.rs b/crates/lsp/src/handler/hover.rs index 78c7085..8afa89e 100644 --- a/crates/lsp/src/handler/hover.rs +++ b/crates/lsp/src/handler/hover.rs @@ -10,7 +10,7 @@ use anyhow::Result; use lsp_types::{Hover, HoverContents, HoverParams, MarkupContent, MarkupKind}; use crate::global_state::GlobalState; -use crate::resolver::{identifier_at, SymbolKind}; +use crate::resolver::{component_field, identifier_at, SymbolKind}; use crate::source_db::SourceDatabase; /// Entry point for the `textDocument/hover` request. Returns `None` (no hover) when the cursor @@ -25,7 +25,14 @@ pub fn handle(state: &GlobalState, params: HoverParams) -> Result> let Some(token) = identifier_at(&ctx.ast, ctx.offset) else { return Ok(None); }; - let Some((def_id, sym)) = state.resolve_use(&ctx.file_db, &token).into_iter().next() else { + // A component member-access field (`c.x` / `T()(...).x`) resolves via type inference; everything + // else uses the flat name resolver. + let resolved = if component_field(&token).is_some() { + state.resolve_member(&ctx.file_db, &token) + } else { + state.resolve_use(&ctx.file_db, &token) + }; + let Some((def_id, sym)) = resolved.into_iter().next() else { return Ok(None); }; @@ -74,7 +81,10 @@ fn kind_label(kind: SymbolKind) -> &'static str { mod tests { use lsp_types::{Position, Url}; + use crate::file_db::{FileDB, FileId}; use crate::global_state::GlobalState; + use parser::token_kind::TokenKind; + use syntax::syntax::syntax_tree; use super::handle; use lsp_types::{ @@ -87,6 +97,25 @@ mod tests { state } + /// LSP `Position` of the `occurrence`-th (0-indexed) `Identifier` token whose text is `name`. + fn position_of(source: &str, name: &str, occurrence: usize) -> Position { + let file = FileDB::new(FileId(0), source, Url::from_file_path("/tmp/x").unwrap()); + let node = syntax_tree(source); + let mut count = 0; + for t in node + .descendants_with_tokens() + .filter_map(|e| e.into_token()) + { + if t.kind() == TokenKind::Identifier && t.text() == name { + if count == occurrence { + return file.position(t.text_range().start()); + } + count += 1; + } + } + panic!("token {name}#{occurrence} not found"); + } + /// The hover markdown value for the cursor's position, or `None`. fn hover_value(state: &GlobalState, url: &Url, position: Position) -> Option { handle( @@ -148,4 +177,19 @@ mod tests { // Cursor on `template` (a keyword — identifier_at returns None). assert!(hover_value(&state, &url, Position::new(1, 1)).is_none()); } + + /// Hover on an anonymous component's member field (`T()().out`) shows the signal declaration + /// from the template (the member-resolution path), not `None`. + #[test] + fn hover_member_field_anonymous_test() { + let source = "pragma circom 2.0.0;\ntemplate T() {\n signal output out;\n out <== 0;\n}\ntemplate Main() {\n signal output c;\n c <== T()().out;\n}\n"; + let url = Url::from_file_path("/tmp/hmem.circom").unwrap(); + let state = state_with(&url, source); + + // `out` occurrences: [0]=decl, [1]=usage in T, [2]=the `.out` field. + let v = hover_value(&state, &url, position_of(source, "out", 2)).expect("hover present"); + assert!(v.contains("**signal**"), "kind shown: {v}"); + assert!(v.contains("`out`"), "name shown: {v}"); + assert!(v.contains("signal output out"), "declaration shown: {v}"); + } } diff --git a/crates/lsp/src/resolver.rs b/crates/lsp/src/resolver.rs index 1e25209..21656bd 100644 --- a/crates/lsp/src/resolver.rs +++ b/crates/lsp/src/resolver.rs @@ -15,7 +15,7 @@ use parser::token_kind::TokenKind; use rowan::ast::AstNode; use rowan::TextSize; -use syntax::abstract_syntax_tree::AstCircomProgram; +use syntax::abstract_syntax_tree::{AstCall, AstCircomProgram, AstComponentCall}; use syntax::syntax_node::{SyntaxNode, SyntaxToken}; use crate::semantic::{Symbol, SymbolTable}; @@ -55,6 +55,46 @@ pub fn identifier_at(ast: &AstCircomProgram, offset: TextSize) -> Option Option { + if token.kind() != TokenKind::Identifier { + return None; + } + let parent = token.parent()?; + let wrapping = if parent.kind() == TokenKind::Identifier { + parent.parent()? + } else { + parent + }; + AstComponentCall::cast(wrapping) +} + +/// The receiver expression node of a `ComponentCall` (`obj` in `obj.field`): its first child node +/// (document order puts the receiver before the field). For a named receiver this is an +/// `ExpressionAtom`/`ArrayQuery`; for an anonymous `T()(...)` it is the outer `Call`. +pub(crate) fn receiver_of(call: &AstComponentCall) -> Option { + call.syntax().children().next() +} + +/// The leftmost `Identifier` token in `node` (document order): the callee of an anonymous +/// instantiation (`T` in `T()(...)`), or the receiver name (`c` in `c.x`). +pub(crate) fn first_identifier(node: &SyntaxNode) -> Option { + node.descendants_with_tokens() + .filter_map(|e| e.into_token()) + .find(|t| t.kind() == TokenKind::Identifier) +} + +/// `true` if `node` is, or contains, a `Call` node — i.e. the receiver is an anonymous component +/// instantiation `T()(...)` rather than a named component. +pub(crate) fn contains_call(node: &SyntaxNode) -> bool { + AstCall::can_cast(node.kind()) || node.descendants().any(|n| AstCall::can_cast(n.kind())) +} + /// A resolved reference — what the token means and where defined (one declaration per symbol; /// URL-agnostic, the caller tags each range with its file). #[derive(Debug, Clone)]