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/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); + } } 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 ```