CSS Variable LSP (Rust)
A fast, Rust-based Language Server Protocol implementation focused on
CSS custom properties (--variables) and the var() function. It is a
ground-up rewrite of the original TypeScript css-variable-lsp, designed to
ship as a standalone native executable that any LSP-aware editor (Zed, VS Code,
Neovim, Helix, …) can launch with no Node.js runtime in sight.
Status: production-ready. Used by the Zed CSS Variables extension in the Zed editor. The server is published to crates.io and ships as a tagged GitHub release for Linux, macOS, and Windows (x86_64 + aarch64).
Table of contents
- Why a Rust rewrite?
- Features
- Quick start
- Editor integration
- Configuration
- LSP features in detail
- Architecture
- Performance
- Build, test, and ship
- Comparison with the TypeScript version
- License
Why a Rust rewrite?
TypeScript css-variable-lsp |
This crate | |
|---|---|---|
| Runtime | Node.js + npm dependencies | Native, no Node.js |
| Binary size | ~50–100 MB | ~8.2 MB (macOS aarch64) |
| Cold start | ~500 ms | ~10 ms |
| Baseline memory | 50–100 MB | 10–20 MB |
| Parse time (typ.) | Fast (css-tree) |
Very fast (regex) |
| Distribution | npm package | cargo install, GitHub Releases, crates.io |
A standalone executable with no Node.js or npm runtime dependency is dramatically easier to embed in editor extensions and CI sandboxes.
Features
Core language features
- CSS parsing of variable definitions,
var()usages, and literal color occurrences (hex,rgb(),rgba(),hsl(),hsla(), named colors, plus chain resolution throughvar()aliases). - HTML parsing of
<style>blocks,class="…"/classname="…"attributes, and inlinestyle="…"attributes with full DOM tree tracking. - JS / TS / JSX / TSX support via CSS-in-JS extraction from
string literals and tagged template literals (
styled-components,emotion, etc.), correctly handling template expressions. - Astro font variables are statically indexed from
astro.config.{js,mjs,cjs,ts,mts,cts}using Oxc. Configuration code is parsed but never executed.fonts[].cssVariableaccepts direct literals, bounded immutableconstaliases, static object and array aliases, and known spreads. Proven ESM and CommonJSdefineConfighelpers are supported. - Vite injected CSS variables are indexed from static
css.preprocessorOptions.scss.additionalDatastrings invite.config.{js,mjs,cjs,ts,mts,cts}. Direct literals, immutable aliases, static structures and spreads, and simple unconditional function returns are accepted. Dynamic values, unrelated Vite options, unknown computed properties, and SCSS control-flow directives are ignored. - Safe config lifecycle atomically updates definitions and usages. Oxc's recoverable AST replaces stale state, while catastrophic syntax errors and files over 1 MiB retain the last valid analysis. Completion, hover, and document symbols identify whether a definition came from Astro or Vite.
- Cascade sorting and CSS specificity calculation including
:is(),:not(),:where(), attribute selectors, pseudo-classes, and pseudo-elements. - DOM-aware matching so hover can tell the user which definition applies at the cursor's position, not just which one wins globally.
LSP features
- Completion for
var(--name)and bare--name(with trigger characters-,(,:). - Hover showing value, selector, specificity, and which definition wins under the current context.
- Go to definition and find references across the whole workspace.
- Rename preserving
!important, fallback arguments, and whitespace. - Code actions:
- Create variable in
:root(quickfix for undefinedvar()). - Add fallback to
var(--name)(configurable, default on). - Replace literal color with matching variable (configurable, default on).
- Create variable in
- Diagnostics: undefined
var()usage (warning / info / off) and "consider using a variable" hints for literal colors that match an existing variable. - Document symbols and workspace symbols (
Shift+Shift). - Document color and color presentation for color picker integration.
- File-system watching: workspace re-scans on file create / change / delete / rename events.
File-type coverage
| Kind | Extensions (default) |
|---|---|
| CSS | .css, .scss, .sass, .less |
| HTML-ish | .html, .vue, .svelte, .astro, .ripple |
| JS-ish | .js, .jsx, .ts, .tsx, .mjs, .cjs, .mts, .cts |
All of these are configurable via the --lookup-files flag.
Recognized astro.config.* and vite.config.* files are discovered
independently of --lookup-files so users do not need to scan every JavaScript
file eagerly.
Quick start
# Install from crates.io
# Building from source requires Rust 1.95 or newer.
# …or download a release binary for your platform
# (see "Release assets" further down)
# Print the version
# Print full CLI help
The binary is css-variable-lsp (or css-variable-lsp.exe on Windows). It
communicates over stdin/stdout using LSP JSON-RPC — launch it from your
editor's language-client configuration; no arguments are required for
sensible defaults.
Manual smoke test
# Start the server
# Send an `initialize` request, then an `initialized` notification,
# then a `shutdown` request and an `exit` notification. Use
# any LSP-aware client (or `nvim --headless`, `helix --health`,
# `zed --foreground`, …) to drive it.
Editor integration
Zed
Zed consumes the release binaries via the zed-css-variables extension. Once installed, no further configuration is required.
Neovim (with nvim-lspconfig)
require..
Helix
Add to ~/.config/helix/languages.toml:
[]
= "css-variable-lsp"
= []
[[]]
= "css"
= ["css-lsp", "css-variable-lsp"]
[[]]
= "scss"
= ["css-lsp", "css-variable-lsp"]
VS Code
Use a generic LSP client extension such as vscode-langservers-extracted:
// .vscode/settings.json
{
"css.variableLsp.command": "css-variable-lsp",
"scss.variableLsp.command": "css-variable-lsp"
}
Generic (stdio JSON-RPC)
# The server speaks LSP over stdio — point any LSP client at the binary
# with `css-variable-lsp` as the launch command.
Configuration
The server is configured via CLI flags and environment variables. CLI flags take precedence over environment variables, which take precedence over built-in defaults.
Feature flags
| Flag | Env var | Default | Description |
|---|---|---|---|
--no-color-preview |
CSS_LSP_COLOR_PREVIEW=0 |
enabled | Disable the LSP color provider |
--color-only-variables |
CSS_LSP_COLOR_ONLY_VARIABLES=1 |
disabled | Only highlight colors on var() calls |
--lookup-files <globs> |
CSS_LSP_LOOKUP_FILES |
*.css, *.html… |
File globs scanned on the workspace |
--ignore-globs <globs> |
CSS_LSP_IGNORE_GLOBS |
node_modules, dist… |
Globs excluded from the scan |
--path-display <mode[:N]> |
CSS_LSP_PATH_DISPLAY |
relative |
relative / absolute / abbreviated[:N] |
--path-display-length <N> |
CSS_LSP_PATH_DISPLAY_LENGTH |
1 |
Abbreviation length when mode is abbreviated |
--undefined-var-fallback <mode> |
CSS_LSP_UNDEFINED_VAR_FALLBACK |
warning |
warning / info / off |
--no-suggest-add-fallback |
CSS_LSP_SUGGEST_ADD_FALLBACK=0 |
enabled | Suppress the "Add fallback" quickfix |
--no-suggest-exact-color-variables |
CSS_LSP_SUGGEST_EXACT_COLOR_VARIABLES=0 |
enabled | Suppress "replace with var()" suggestions |
Singular forms --lookup-file and --ignore-glob (repeatable) are also
accepted. Path display modes accept aliases: abbr / fish for
abbreviated, and warn / information / omit / none /
disabled for the undefined-var-fallback mode.
Examples
# Disable color picker
# Limit scanning to SCSS and Svelte files
CSS_LSP_LOOKUP_FILES="**/*.scss,**/*.svelte"
# Use abbreviated paths of length 2, suppress the "Add fallback" quickfix
# Silent mode for undefined variables (still keep other diagnostics)
Editor configuration
Most clients can pass settings via workspace/didChangeConfiguration.
The server accepts the same camelCase keys, either flat or namespaced
under cssVariableLsp:
{
"cssVariableLsp": {
"lookupFiles": ["**/*.css", "**/*.scss"],
"ignoreGlobs": ["**/node_modules/**", "**/dist/**"],
"enableColorProvider": true,
"colorOnlyOnVariables": false
}
}
LSP features in detail
Autocomplete contexts
| File kind | Where completion triggers | Insert text |
|---|---|---|
| CSS | Inside a rule, after : and before ; |
var(--name) (or --name inside var() |
| SCSS/SASS | Same as CSS | Same as CSS |
| HTML | Inside <style>…</style> or style="…" attribute value |
Same as CSS |
| JS / TS | Inside string literals and template literal text (not in ${}) |
Same as CSS |
Completion is also triggered on -, (, and : per the upstream
TypeScript implementation, and respects the workspace's lookup_files
configuration to decide which file kinds are even parsed.
Hover
- For definitions: shows value,
!importantflag, selector, and computed specificity. - For usages: lists every definition in cascade order, marks the
applicable one (
✓ Wins/✓ Applies here/✓ Would apply (inline style)/✓ Applies (DOM match)), and explains why each non-winning definition lost (lower specificity, earlier source, no DOM match, !important overridden). - For literal colors: shows a colored swatch when
--no-color-previewis not set.
Rename
- Preserves
!important. - Preserves fallback arguments (
var(--old, red)→var(--new, red)). - Updates both definitions and usages across all open files and any file the workspace has indexed.
Diagnostics
| Code | Severity | Trigger | Can be disabled? |
|---|---|---|---|
css-variable-lsp.undefined-variable |
warning/info | var(--name) where --name has no definition in the workspace |
yes (--undefined-var-fallback) |
css-variable-lsp.literal-color-replaceable |
information | Literal color value that matches an existing variable exactly | yes (--no-suggest-exact-color-variables) |
Diagnostics for undefined-variable are not emitted when the
var() call has a fallback (severity drops to info or off
depending on configuration), since CSS spec says the fallback will
cover the gap.
Architecture
┌──────────────────────────────────┐
│ editor client │
└────────────┬──────────────▲────────┘
LSP/JSON-RPC │ │ diagnostics,
stdio ▼ │ hover, completion
┌──────────────────────────────────┐
│ src/lsp_server.rs │
│ (tower-lsp handler glue + IoC) │
└────────────┬──────────────▲────────┘
│ │
┌────────────▼──────────────┴────────┐
│ src/manager.rs │
│ (thread-safe workspace state: │
│ variables, usages, colors, DOM) │
└────────────┬──────────────▲────────┘
│ │
┌────────────────────────┼──────────────┼────────────────────────┐
│ │ │ │
▼ ▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌──────────────┐ ┌────────────────┐
│ parsers/css │ │ parsers/html │ │ parsers/js │ │ workspace.rs │
│ (CSS AST) │ │ (DOM + CSS) │ │ (CSS-in-JS) │ │ (walker) │
└───────────────┘ └───────────────┘ └──────────────┘ └────────────────┘
│ │ │ │
└────────────────────────┼──────────────┼────────────────────────┘
▼
┌──────────────────────────────────┐
│ specificity / color / path │
│ display / document_kind │
└──────────────────────────────────┘
Module dependency graph
text_utils (no deps)
↓
document_kind (uses types::Config)
↓
completion_context (uses document_kind + text_utils)
↓
lsp_server (uses every other module)
The library is also published as css_variable_lsp, so you can embed
the parser / manager in your own tools without the LSP plumbing.
Performance
The crate is built to be cheap to embed in editor extensions and large monorepos. Key numbers (release build, single-threaded, on an M1 Pro laptop, ~10k variable workspace):
| Operation | Median time |
|---|---|
| Binary startup | ~10 ms |
| Parse a typical CSS file (500 LoC) | <10 ms |
| Completion with 100 candidates | <5 ms |
| Hover with cascade calculation | <10 ms |
| Workspace re-scan (10k variables) | ~1 s |
| Memory usage at idle (10k variables) | ~18 MB |
Optimizations include:
- Memoized regex compilation (
std::sync::LazyLock). - Selective revalidation: only documents that reference a changed variable name get re-diagnosed.
- Line-bucketed literal color index for O(1) position lookups.
- Bounded document count (default 10 000) to prevent OOM on huge repos; the limit is logged, not silently dropped.
- Async I/O end-to-end (
tokio), no blockingfs::*calls on the LSP threads.
Build, test, and ship
Local development
RUST_LOG=debug CSS_LSP_ENABLE_LOGS=1
Running a single test
Release assets (local)
Build and package release assets into dist/ (tar.gz on Unix,
zip on Windows):
# Build a subset of targets:
Publish
The repo follows the standard Rust release flow:
- Bump
versioninCargo.toml. git tag vX.Y.Z && git push && git push origin vX.Y.Z..github/workflows/release.ymlbuilds 6 binaries (Linux, macOS, Windows × x86_64 + aarch64) and attaches them to the GitHub Release..github/workflows/publish.ymlpublishes the crate to crates.io.
See docs/release-publishing.md for the
detailed checklist and scripts/smoke-test-release.sh
for post-release validation.
Comparison with the TypeScript version
| Feature | TypeScript | Rust |
|---|---|---|
| Runtime | Node + npm packages | Native, no Node.js |
| Binary size | 50–100 MB | ~8.2 MB (macOS aarch64) |
| Cold start | ~500 ms | ~10 ms |
| Memory at idle | 50–100 MB | 10–20 MB |
| Parser | css-tree (full AST) |
Regex + state machine |
| Editor integrations | Manual per editor | Any stdio LSP client |
| SCSS / SASS / LESS | Best-effort regex | Best-effort regex |
| CSS-in-JS | None | Yes (styled-components, …) |
| Cross-platform | Requires Node.js | Single binary per platform |
| Distribution | npm package | crates.io + GitHub Releases |
The regex-based parser is intentionally pragmatic: it covers the
~95% case for definitions and var() usages in real-world code
without dragging in a full CSS grammar. The trade-off is documented
under "Known limitations" in CHANGELOG.md.
License
GPL-3.0. Originally derived from the TypeScript
css-variable-lsp by the same author. Dependency license notices are listed in
THIRD_PARTY_NOTICES.md and are included in release
archives.