Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 64 additions & 1 deletion crates/lsp/src/global_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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).
Expand Down
130 changes: 130 additions & 0 deletions crates/lsp/src/handler/goto_definition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
48 changes: 46 additions & 2 deletions crates/lsp/src/handler/hover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,7 +25,14 @@ pub fn handle(state: &GlobalState, params: HoverParams) -> Result<Option<Hover>>
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);
};

Expand Down Expand Up @@ -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::{
Expand All @@ -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<String> {
handle(
Expand Down Expand Up @@ -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}");
}
}
42 changes: 41 additions & 1 deletion crates/lsp/src/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -55,6 +55,46 @@ pub fn identifier_at(ast: &AstCircomProgram, offset: TextSize) -> Option<SyntaxT
(token.kind() == TokenKind::Identifier).then_some(token)
}

/// The enclosing `ComponentCall` iff `token` is its **field** — the `Identifier` *node* directly
/// under the `ComponentCall` (e.g. `out` in `c.out` or `T()(...).out`). An identifier token is
/// wrapped in an `Identifier` node, so the field is the one whose wrapping node's parent is the
/// `ComponentCall`; the receiver/arg identifiers sit deeper (under `ExpressionAtom`/`ArrayQuery`/
/// `Call`/`InlineArray`) and are rejected. Shared by goto-definition and hover to route
/// member-access fields to `resolve_member`.
pub(crate) fn component_field(token: &SyntaxToken) -> Option<AstComponentCall> {
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<SyntaxNode> {
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<SyntaxToken> {
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)]
Expand Down
Loading