From 7b7bca3610e3a544d2a25cd0a620fa658e65225b Mon Sep 17 00:00:00 2001 From: Vu Vo Date: Sun, 2 Aug 2026 22:37:21 +0700 Subject: [PATCH 1/4] update readme Signed-off-by: Vu Vo --- README.md | 61 ++++++++++++++++++++++++++++++++++++++++++ TODO.md | 36 +++++++++++++++++++++++++ editors/code/README.md | 4 +-- 3 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 TODO.md diff --git a/README.md b/README.md index 70fe09c..abeab9c 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,41 @@ A language server for [Circom](https://docs.circom.io/), built with Rust and TypeScript. +CCLS provides rich editor support for [Circom](https://docs.circom.io/) — the DSL for writing +zero-knowledge proof circuits — with an **error-recovering parser** that stays useful even on +partially-typed or invalid files. + +The project is split across: + +- **Rust backend** — a multi-crate workspace: `parser` (lexer + event-driven parser), + `syntax` (lossless `rowan` AST), `vfs` (virtual file system), and `lsp` (the language server). +- **VS Code extension** — TypeScript client (`circom-plus`) published to the marketplace. + +## ✨ Features + +### Implemented + +- [x] **Go to Definition** — resolves signals, variables, parameters, templates, functions, and + components, including **cross-file** jumps through `include` statements, and jumping straight + into an included library file from its `"path.circom"` string. +- [x] **Hover** — shows the symbol kind and its declaration signature (header only for block-bodied + defs like `template`/`function`/`bus`). +- [x] **Completion** — in-scope body symbols, file top-level names, reserved keywords, and **member + completion** (`component.`) that resolves a component's template across files. +- [x] **Find References** — every occurrence of a symbol, resolved *semantically* (not text-matched), + so shadowing is respected. +- [x] **Rename** — scope-aware rename with `prepareRename` support; refuses keywords, include-path + strings, illegal names, and unresolved member-access fields. +- [x] **Error-recovering parser** — keeps working on invalid/partial circom files. +- [x] **Lazy, cached analysis** — parsing and symbol tables are memoized and invalidated only on real + edits; includes are read from disk once. +- [x] **Sandboxed includes** — `include` resolution is confined to workspace roots + (path-traversal / symlink-safe). + +> See [`TODO.md`](./TODO.md) for the roadmap of features not yet implemented. + +--- + ## 🚀 Installation 1. **Clone the repository:** @@ -63,3 +98,29 @@ Optional, but recommended for snapshot testing. --- +## 🏗️ Architecture + +``` +circom-language-server/ +├── crates/ +│ ├── parser/ # `logos` lexer + event-driven parser with markers +│ ├── syntax/ # `rowan` lossless syntax tree + typed AST +│ ├── vfs/ # Virtual file system (existence, text, change log) +│ └── lsp/ # LSP server: handlers, global state, resolver, semantic index +├── editors/code/ # VS Code extension (TypeScript, `circom-plus`) +└── xtask/ # Build & install tasks (`cargo xtask install …`) +``` + +Key design notes: + +- **Resolution core** (`resolver.rs` + `semantic.rs`) is name-based and shared by goto-definition, + hover, references, and rename — so the same symbol resolves consistently across features. +- **Source database** (`source_db.rs`) memoizes parse / file DB / symbol table per file, drained from + the VFS change log so editing file A never recomputes file B. +- Includes are loaded once from disk, cached, and confined to workspace roots. + +--- + +## 🐛 Bugs & Feature Requests + +Please open an issue on the repository: https://github.com/vuvoth/ccls/issues diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..0638d11 --- /dev/null +++ b/TODO.md @@ -0,0 +1,36 @@ +# Roadmap + +Features not yet implemented in CCLS. Implemented features are listed in the +[README](./README.md#-features). + +## Placeholders (capability registered, returns empty) + +These handlers exist but currently return `None`: + +- [ ] **Document Symbol / Outline** — `textDocument/documentSymbol`. Walk the program AST and emit one + symbol per template / function / signal / variable / component with its location and kind. + See `crates/lsp/src/handler/document_symbol.rs`. +- [ ] **Formatting** — `textDocument/formatting`. Re-tokenize the document and normalize + whitespace/indentation into `TextEdit`s. See `crates/lsp/src/handler/formatting.rs`. + +## Not yet implemented + +- [ ] **Diagnostics** — syntax/semantic error reporting (`textDocument/publishDiagnostics`). The + parser already produces errors via `error_report()`; surface them to the client. +- [ ] **Semantic Highlighting** — `textDocument/semanticTokens`. +- [ ] **Signature Help** — `textDocument/signatureHelp`. +- [ ] **Code Actions / Quick Fixes** — `textDocument/codeAction`. +- [ ] **Folding Ranges** — `textDocument/foldingRange`. +- [ ] **Document Highlight** — `textDocument/documentHighlight`. +- [ ] **Selection Range** — `textDocument/selectionRange`. +- [ ] **Inlay Hints** — `textDocument/inlayHint`. + +## Existing features — follow-ups + +- [ ] **Cross-file Rename & References** — both are currently in-file only. A workspace-wide symbol + graph is needed instead of name/`def_range` matching across files (which both misses real + cross-file usages and can collide when two files define a same-named symbol at the same + line:column). +- [ ] **Doc-comment parsing** — richer hover derived from circom comments. +- [ ] **Incremental sync** — document sync is currently `Full`; switch to incremental `didChange` + ranges. diff --git a/editors/code/README.md b/editors/code/README.md index 9fc1a7a..9047fb4 100644 --- a/editors/code/README.md +++ b/editors/code/README.md @@ -39,8 +39,8 @@ template Another() { I recommend installing via these commands: ```bash -git clone https://github.com/vuvoth/circom-plus -cd circom-plus +git clone https://github.com/vuvoth/ccls +cd ccls cargo xtask install --server cargo xtask install --client ``` From ef1fb7c1c431d78a9e5d796b7a52798d5493ceac Mon Sep 17 00:00:00 2001 From: Vu Vo Date: Sun, 2 Aug 2026 22:49:46 +0700 Subject: [PATCH 2/4] fix go to definition in main component Signed-off-by: Vu Vo --- crates/lsp/src/global_state.rs | 21 +++-- crates/lsp/src/handler/goto_definition.rs | 102 +++++++++++++++++++++- 2 files changed, 116 insertions(+), 7 deletions(-) diff --git a/crates/lsp/src/global_state.rs b/crates/lsp/src/global_state.rs index b19b08d..d8033a1 100644 --- a/crates/lsp/src/global_state.rs +++ b/crates/lsp/src/global_state.rs @@ -9,7 +9,9 @@ use lsp_types::{DidChangeTextDocumentParams, DidOpenTextDocumentParams, Location use parser::token_kind::TokenKind; use rowan::ast::AstNode; use rowan::TextSize; -use syntax::abstract_syntax_tree::{AstCircomProgram, AstComponentCall, AstComponentDecl}; +use syntax::abstract_syntax_tree::{ + AstCircomProgram, AstComponentCall, AstComponentDecl, AstMainComponent, +}; use syntax::syntax_node::SyntaxToken; use std::path::PathBuf; @@ -167,8 +169,9 @@ impl GlobalState { } /// Resolve `token` to its file-tagged declaration(s): in-file first; then, for a component - /// decl/call, each loaded include's top-level by name. Cross-file is file-scope only - /// (template/function names) — the shared core for goto-def, rename, references. + /// decl/call — or the top-level `component main = X()` instantiation — each loaded include's + /// top-level by name. Cross-file is file-scope only (template/function names) — the shared + /// core for goto-def, rename, references. pub(crate) fn resolve_use( &self, origin: &FileDB, @@ -180,9 +183,15 @@ impl GlobalState { .map(|s| (origin.file_id, s)) .collect(); - // A component declaration/call also resolves to template/function defs in loaded includes. - let is_component_use = token_ancestors(token) - .any(|n| AstComponentDecl::can_cast(n.kind()) || AstComponentCall::can_cast(n.kind())); + // A component declaration/call — or the top-level `component main = X()` instantiation — + // also resolves to template/function defs in loaded includes. `MainComponent` is a distinct + // node kind from `ComponentDecl`/`ComponentCall`, so it is listed explicitly (without it, + // `component main = Lib()` where `Lib` is in an include would never resolve). + let is_component_use = token_ancestors(token).any(|n| { + AstComponentDecl::can_cast(n.kind()) + || AstComponentCall::can_cast(n.kind()) + || AstMainComponent::can_cast(n.kind()) + }); if is_component_use { let name = token.text(); for lib_id in self.loaded_includes(origin) { diff --git a/crates/lsp/src/handler/goto_definition.rs b/crates/lsp/src/handler/goto_definition.rs index fe509bc..f18ba8d 100644 --- a/crates/lsp/src/handler/goto_definition.rs +++ b/crates/lsp/src/handler/goto_definition.rs @@ -76,7 +76,7 @@ pub fn jump_to_lib(file_db: &FileDB, token: &SyntaxToken, vfs: &Vfs) -> Vec GlobalState { + let mut state = GlobalState::new(Vec::new()); + state.source_db.set_document(url, source.to_string()); + state + } + + /// `lookup_definition` for the `occurrence`-th `Identifier` token named `name`, using the db's + /// own `FileDB` (so `origin.file_id` matches the interned id `resolve_use` queries). + fn jump( + state: &GlobalState, + url: &Url, + source: &str, + name: &str, + occurrence: usize, + ) -> Vec { + let id = state + .source_db + .id_for_url(url) + .expect("document registered"); + let file_db = state.source_db.file_db(id); + let ast = AstCircomProgram::cast(syntax_tree(source)).expect("parses to a program"); + let token = ast + .syntax() + .descendants_with_tokens() + .filter_map(|e| e.into_token()) + .filter(|t| t.kind() == TokenKind::Identifier && t.text() == name) + .nth(occurrence) + .unwrap_or_else(|| panic!("token {name}#{occurrence} not found")); + state.lookup_definition(&file_db, &token) + } + + /// Goto-definition from the template reference inside `component main = X()` resolves to `X`'s + /// definition **in the same file** (the in-file `lookup_top_level` path; unaffected by the gate). + #[test] + fn main_component_same_file_jump_test() { + let source = "pragma circom 2.0.0;\ntemplate X() { signal output o; o <== 0; }\ncomponent main = X();\n"; + let url = Url::from_file_path("/tmp/mc_same.circom").unwrap(); + let state = state_with(&url, source); + + // The `X` usage in `component main = X()` is the 2nd `X` token (0th = the definition). + let locs = jump(&state, &url, source, "X", 1); + + assert_eq!(locs.len(), 1, "same-file main-component jump: {locs:?}"); + assert_eq!(locs[0].uri, url, "jumps within the same file"); + // `def_range` is the template name token, on line 2 (0-indexed 1). + assert_eq!( + locs[0].range.start.line, 1, + "lands on the template definition" + ); + } + + /// Goto-definition from the canonical `component main = Lib()` entry point must jump across the + /// `include` to `Lib`'s definition — the case the old `is_component_use` gate missed because + /// `MainComponent` is a distinct node kind from `ComponentDecl`/`ComponentCall`. + #[test] + fn main_component_cross_file_jump_test() { + use std::fs; + + let base = std::env::temp_dir().join(format!("ccls_mc_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 o <== 0;\n}\n", + ) + .unwrap(); + let main_src = "pragma circom 2.0.0;\ninclude \"lib.circom\";\ncomponent main = Lib();\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(); + // Workspace root set so the `include` is loaded (path-traversal confinement). + 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"); + + // The only `Lib` token in main.circom is the reference inside `component main = Lib()`. + let locs = jump(&state, &main_url, main_src, "Lib", 0); + + assert_eq!(locs.len(), 1, "cross-file main-component jump: {locs:?}"); + assert!( + locs[0].uri.to_file_path().unwrap().ends_with("lib.circom"), + "jumps into the included lib: {}", + locs[0].uri + ); + // Lands on `template Lib()` — the template name line in lib.circom. + assert_eq!( + locs[0].range.start.line, 1, + "lands on the lib template: {locs:?}" + ); + + let _ = fs::remove_dir_all(&base); + } } From 6a967ba1863ba9d67f25a0fb37b537f28488eeff Mon Sep 17 00:00:00 2001 From: Vu Vo Date: Sun, 2 Aug 2026 23:09:38 +0700 Subject: [PATCH 3/4] fix anonymous signal jump to define Signed-off-by: Vu Vo --- crates/lsp/src/global_state.rs | 65 +++++++++++++++++- crates/lsp/src/handler/goto_definition.rs | 83 +++++++++++++++++++++++ crates/lsp/src/handler/hover.rs | 48 ++++++++++++- crates/lsp/src/resolver.rs | 42 +++++++++++- 4 files changed, 234 insertions(+), 4 deletions(-) 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..7768ab3 100644 --- a/crates/lsp/src/handler/goto_definition.rs +++ b/crates/lsp/src/handler/goto_definition.rs @@ -244,4 +244,87 @@ 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); + } } 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)] From 0780cde98d9ec73c83c4163091a668cca1c2e638 Mon Sep 17 00:00:00 2001 From: Vu Vo Date: Sun, 2 Aug 2026 23:11:34 +0700 Subject: [PATCH 4/4] fmt Signed-off-by: Vu Vo --- crates/lsp/src/handler/goto_definition.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/lsp/src/handler/goto_definition.rs b/crates/lsp/src/handler/goto_definition.rs index 3a09d60..556ab29 100644 --- a/crates/lsp/src/handler/goto_definition.rs +++ b/crates/lsp/src/handler/goto_definition.rs @@ -357,7 +357,11 @@ mod tests { // `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_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: {}",