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
66 changes: 28 additions & 38 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,24 @@ The project is split across:

## ✨ 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.
CCLS provides **Go to Definition** (cross-file via `include`), **Hover**, **Completion**
(including `component.<signal>` member completion), semantic **Find References**, scope-aware
**Rename**, an **error-recovering parser**, lazy/cached analysis, and **sandboxed includes**
(path-traversal safe).

See [`docs/features.md`](./docs/features.md) for the full list with per-feature details, and
[`docs/roadmap.md`](./docs/roadmap.md) for what is not yet implemented.

---

## 📚 Documentation

In-depth docs live in [`docs/`](./docs/README.md):

- [Features](./docs/features.md) — what CCLS can do, and how each feature resolves symbols.
- [Architecture](./docs/architecture.md) — workspace layout, resolution core, source DB, VFS.
- [Roadmap](./docs/roadmap.md) — capabilities not yet implemented.
- [Per-crate deep dives](./docs/crates/) — `parser`, `syntax`, `lsp`, `vfs`.

---

Expand Down Expand Up @@ -100,24 +98,16 @@ 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.
A multi-crate Rust workspace: `parser` (lexer + event-driven parser), `syntax` (`rowan`
lossless tree + typed AST), `vfs` (in-memory virtual file system), and `lsp` (the language
server), plus the TypeScript VS Code extension in `editors/code/` and build tasks in `xtask/`.

The resolution core (`resolver.rs` + `symbol_table.rs`) is name-based and shared by
goto-definition, hover, references, and rename, over a salsa-shaped source DB that is
invalidated by draining the VFS change log.

See [`docs/architecture.md`](./docs/architecture.md) for the full design, and
[`docs/crates/`](./docs/crates/) for per-crate deep dives.

---

Expand Down
24 changes: 24 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# CCLS Documentation

This folder holds the in-depth documentation for **CCLS**, the Circom language server.

For the quick-start (clone, install Rust, build, run tests), see the root
[`README.md`](../README.md). For the VS Code extension package, see
[`editors/code/README.md`](../editors/code/README.md).

## Contents

| Document | Description |
| --- | --- |
| [Features](./features.md) | What CCLS can do today, and how each feature resolves symbols. |
| [Architecture](./architecture.md) | Workspace layout, the resolution core, the source DB, and the VFS. |
| [Roadmap](./roadmap.md) | Capabilities not yet implemented and follow-ups on existing features. |

### Per-crate deep dives

| Crate | Document | Responsibility |
| --- | --- | --- |
| `parser` | [crates/parser.md](./crates/parser.md) | `logos` lexer + event-driven parser with markers and grammar modules. |
| `syntax` | [crates/syntax.md](./crates/syntax.md) | `rowan` lossless syntax tree and typed AST. |
| `lsp` | [crates/lsp.md](./crates/lsp.md) | LSP server: handlers, global state, resolver, source DB, symbol table. |
| `vfs` | [crates/vfs.md](./crates/vfs.md) | In-memory, path-interned virtual file system with a change log. |
111 changes: 111 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Architecture

CCLS is a multi-crate Rust workspace plus a TypeScript VS Code extension. This page describes
the workspace layout and the design of the three load-bearing subsystems: the **resolution
core**, the **source database**, and the **virtual file system** (including sandboxed
includes).

## Workspace layout

```
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 …`)
```

See [crates/](./crates/) for per-crate deep dives.

## Server entry point

`crates/lsp/src/main.rs` opens a stdio LSP connection, advertises
[`server_capabilities`](https://microsoft.github.io/language-server-protocol/) (definition,
hover, completion, references, documentSymbol, formatting, rename with `prepareSupport`), and
runs a `main_loop` that routes each `Message::Request` to `GlobalState::handle_request` and
each `Message::Notification` to `GlobalState::handle_notification`. Workspace roots are
captured from the `initialize` handshake (modern `workspace_folders`, falling back to legacy
`root_uri`), canonicalized, and handed to the VFS for include confinement.

## Resolution core

The resolution core lives in `crates/lsp/src/resolver.rs` and
`crates/lsp/src/symbol_table.rs`. It is the single name-resolution engine shared by
goto-definition, hover, references, and rename — so a given symbol resolves identically across
all features.

- **`SymbolTable`** (`symbol_table.rs`) is a per-file, scope-aware index of declarations by
name: the file top-level (template/function/bus names) plus each body scope
(params/signals/vars/components). Each `Symbol` carries a `def_range` (the **name token** —
what goto-def/rename/references jump to) and a `decl_range` (the **whole declaration** —
what hover shows), plus a `type_name` (the instantiated template, for `component c = T();`).
- **`resolver`** (`resolver.rs`) is pure: given a token under the cursor and a file's
`SymbolTable`, it returns the `ResolvedSymbol`s the token refers to. It owns no I/O and no
LSP request types — callers map each `def_range` into a file-scoped `lsp_types::Location`.

Resolution is **purely name-based** against the table. This is what makes it sound across
files: the same name resolves in each file's own table without reusing a foreign token's
identity (the legacy `hash(text)` index was structurally unsound for cross-file lookups).

## Source database

`crates/lsp/src/source_db.rs` is a salsa-shaped source database layered over the in-memory
[`Vfs`](#virtual-file-system-vfs). Each `SourceDatabase` method maps 1:1 to a future salsa
query, so adopting salsa later is an implementation swap, not a redesign.

The split (rust-analyzer style):

- [`Vfs`](./crates/vfs.md) owns **file existence + text + change log**.
- `ContentCacheDb` owns **derived caches** — parse, AST, `FileDB` (offset/line bookkeeping),
and `SymbolTable` — keyed by `FileId`, behind a single `RefCell`.

### Change-log-driven invalidation

Invalidation is driven by **draining the VFS change log**, not by content hashing. When a query
runs, the DB drains `Vfs::take_changes()` and drops only the caches for changed ids. The
guarantees this gives:

- **Editing file A never recomputes file B** — only changed `FileId`s are invalidated.
- Includes are read from disk and parsed **once**, then cached.
- A client resending **identical text records no change** (pointer-equal then content-equal
short-circuit in the VFS) and so triggers no reparse.

## Virtual file system (VFS)

`crates/vfs/src/lib.rs` is a pure in-memory, path-interned VFS in the rust-analyzer style.
It deliberately does **no disk I/O** — path resolution and `fs::read_to_string` stay in the
LSP layer, which feeds text in via `Vfs::set_file_contents`. Keeping the VFS I/O-free makes it
unit-testable without touching the filesystem.

Responsibilities:

- **Path interning**: an absolutized `VfsPath` ↔ `FileId` (`u32`). Aliased paths
(`/a/../a/c` vs `/a/c`) absolutize to one `VfsPath` → one `FileId`.
- **Text storage**: a current `Arc<str>` per `FileId`; `None` text means absent/deleted.
- **Change log**: every mutation records a `ChangedFile` (`Create`/`Modify`/`Delete`), drained
via `Vfs::take_changes()` — the invalidation signal for the source DB and the future hook
for a file-watcher.

See [crates/vfs.md](./crates/vfs.md) for the full API.

## Sandboxed includes

`include "…"` resolution is confined to workspace roots as a path-traversal defense:

- `Vfs::set_workspace_roots` stores already-canonicalized root paths during the `initialize`
handshake.
- `Vfs::is_confined(canonical)` is a **pure** containment check — `canonical.starts_with(root)`
against the roots — with **no disk I/O**. The LSP layer canonicalizes the candidate
(resolving `..` and symlinks) before calling, which keeps the VFS I/O-free.
- The check is **fail-closed**: with no roots configured, nothing is confined, so the server
refuses to load any include rather than risk an arbitrary read.

## Extension

The TypeScript client in `editors/code/` (`circom-plus`) is a thin LSP client: it spawns the
server binary, forwards the standard text-document requests/notifications, and wires the
results into VS Code's goto/hover/completion/references/rename UI. See
[`editors/code/README.md`](../editors/code/README.md).
119 changes: 119 additions & 0 deletions docs/crates/lsp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# `lsp` crate

The Language Server Protocol implementation, built on
[`lsp-server`](https://docs.rs/lsp-server) and
[`lsp-types`](https://docs.rs/lsp-types). This is the crate that becomes the `ccls` server
binary; it owns document state, request dispatch, and the resolution/index machinery that all
features ride on.

> Also see [`architecture.md`](../architecture.md) for how the pieces fit together, and
> [`features.md`](../features.md) for the user-facing feature list.

## Layout

```
crates/lsp/src/
├── main.rs # binary entry: stdio connection, capabilities, main loop
├── global_state.rs # GlobalState: document state + request/notification dispatch
├── handler.rs # handler module registry (one module per feature)
├── handler/
│ ├── goto_definition.rs # textDocument/definition (implemented)
│ ├── hover.rs # textDocument/hover (implemented)
│ ├── completion.rs # textDocument/completion (implemented)
│ ├── references.rs # textDocument/references (implemented)
│ ├── rename.rs # textDocument/rename (+ prepare) (implemented)
│ ├── document_symbol.rs # textDocument/documentSymbol (placeholder: returns None)
│ └── formatting.rs # textDocument/formatting (placeholder: returns None)
├── resolver.rs # pure token -> definition resolver (shared resolution core)
├── symbol_table.rs # per-file, scope-aware declaration index
├── source_db.rs # salsa-shaped source DB over the Vfs (parse/ast/file_db/symbol_table)
└── file_db.rs # per-file offset/line bookkeeping + FileId re-export
```

## Entry point (`main.rs`)

`main()` opens a stdio `Connection`, serializes `server_capabilities()`, runs the
`initialize` handshake, and enters `main_loop`. **All logging goes to stderr** — stdout is
the LSP message channel.

`server_capabilities()` advertises:

- `text_document_sync: FULL` (full document on every change — incremental sync is a roadmap
item).
- `definition`, `hover`, `references`, `documentSymbol`, `documentFormatting` providers.
- `completion` with the `.` trigger character.
- `rename` with `prepareSupport` (the client consults the server before opening the rename
box, so keywords/strings never become renamable).

`workspace_roots(params)` extracts workspace folders (modern `workspace_folders`, falling
back to legacy `root_uri`), converts each from a `file:` URI to a path, and canonicalizes
them. These roots feed the VFS's include-confinement check.

## Main loop

`main_loop` reads `Message`s off the connection:

- `Message::Request` — if it isn't `shutdown` (handled by the transport), routed to
`GlobalState::handle_request`, which dispatches to the matching `handler::*::handle` by
request type and sends back a `Message::Response`.
- `Message::Notification` — routed to `GlobalState::handle_notification` (e.g.
`didOpen` / `didChange`, which update document text in the source DB).
- `Message::Response` — ignored (the server sends no requests of its own).

## Global state (`global_state.rs`)

`GlobalState` holds the `ContentCacheDb` (the source DB) and provides the dispatch surface.
It also exposes the shared cursor prologue every read-handler uses — `cursor_context(uri,
position)` resolves the URL to a `FileId`, fetches the AST and `FileDB`, and converts the LSP
position to a byte offset, returning a `CursorContext { id, ast, file_db, offset }`.

The handler contract (see `handler.rs`): each feature module exposes
`fn handle(state: &GlobalState, params: P) -> Result<Option<R>>`.

> Note: the `handler.rs` module doc-comment currently says "the rest are placeholders", but
> `hover`, `completion`, `references`, and `rename` are in fact fully implemented (only
> `document_symbol` and `formatting` remain placeholders). Trust the individual handler files.

## Handlers (`handler/`)

Each handler rides the shared resolution core. Highlights:

- **`goto_definition`** — resolves signals/vars/params/templates/functions/components, with
cross-file jumps via `include` and a `jump_to_lib` path for include-path strings.
- **`hover`** — kind + declaration signature (header only for block-bodied defs).
- **`completion`** — in-scope names, top-level names, keywords, and member completion
(`c.<signal>`) that resolves a component's template across files.
- **`references`** / **`rename`** — occurrences found by *resolving* candidates (not
text-matching), so shadowing is correct; both are in-file today (cross-file is a roadmap
item).

## Resolution core (`resolver.rs` + `symbol_table.rs`)

- `SymbolTable` (`symbol_table.rs`) — per-file index of declarations by name: the file
top-level plus each body scope. A `Symbol` carries `def_range` (name token), `decl_range`
(whole declaration), and `type_name` (the instantiated template for components).
- `resolver` (`resolver.rs`) — pure token → `ResolvedSymbol` lookup over a `SymbolTable`.
Shared helpers: `token_at_offset`, `identifier_at`, `token_ancestors`, `component_field`.

See [`architecture.md` § Resolution core](../architecture.md#resolution-core).

## Source DB (`source_db.rs`)

`SourceDatabase` is a salsa-shaped trait (`file_text` is the input; `parse`/`ast`/`file_db`/
`symbol_table` are derived, memoized via interior mutability). `ContentCacheDb` is the
`Vfs`-backed implementation. Invalidation is change-log-driven: draining
`Vfs::take_changes()` drops only changed ids, so editing file A never recomputes file B. See
[`architecture.md` § Source database](../architecture.md#source-database).

## File DB (`file_db.rs`)

`FileDB` is per-file offset/line bookkeeping: the `FileId` (re-exported from `vfs`), the
`file:` URL, the byte offset of every `\n`, and the source text. It converts between LSP
positions (UTF-16 code-unit counts) and byte offsets — the bridge between the protocol and
rowan's byte-based `TextSize` ranges.

## Tests

Handler and resolver tests use `insta` snapshots under `crates/lsp/src/handler/snapshots/`
and fixtures under `crates/lsp/src/test_files/` (including a `with_include/` cross-file
case). Run with `cargo test -p lsp`.
Loading
Loading