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
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<signal>`) 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:**
Expand Down Expand Up @@ -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
36 changes: 36 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 15 additions & 6 deletions crates/lsp/src/global_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand Down
102 changes: 101 additions & 1 deletion crates/lsp/src/handler/goto_definition.rs
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,17 @@ pub fn jump_to_lib(file_db: &FileDB, token: &SyntaxToken, vfs: &Vfs) -> Vec<Loca
mod tests {
use std::path::Path;

use lsp_types::Url;
use lsp_types::{Location, Url};
use rowan::ast::AstNode;
use syntax::{
abstract_syntax_tree::{AstCircomProgram, AstInputSignalDecl, AstTemplateDef},
syntax::syntax_tree,
};

use crate::file_db::FileDB;
use crate::global_state::GlobalState;
use crate::source_db::SourceDatabase;
use parser::token_kind::TokenKind;

use super::token_at_offset;

Expand Down Expand Up @@ -144,4 +147,101 @@ mod tests {

assert_eq!("/hello", parent);
}

/// A `GlobalState` seeded with one open document (no workspace roots — in-file only).
fn state_with(url: &Url, source: &str) -> 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<Location> {
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);
}
}
4 changes: 2 additions & 2 deletions editors/code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down
Loading