A language server for CEL (Common Expression Language).
It operates on individual .cel files, providing various LSP features.
$ go install go.vanburen.xyz/cells/cmd/cells@latest- Semantic highlighting
- Diagnostics
- Formatting
- Hover (including declared variable types, and message field types and comments)
- References
- Go to definition
- Completion (including declared variables and message fields)
- Signature help
- Variable renaming
- Inlay hints (expression evaluation)
In addition to operating as a language server, cells provides CLI commands for use outside an editor.
Format CEL source files. With no arguments, reads from stdin and writes to stdout.
$ echo "1+2" | cells format
1 + 2
$ cells format file.cel
$ cells format --write file.cel
$ cells format --diff file.cel
$ cells format --diff --write file.celCheck CEL source files for parse and type errors, plus invalid duration(),
timestamp(), and matches() literal arguments.
Prints file:line:col: severity: message for each diagnostic and exits 1 if any are found.
$ cells check file.cel
$ cells check *.celType errors are reported as warnings unless an environment is
declared, since without one every reference to a real variable is an undeclared
reference and cells cannot tell a typo from a name it was never told about.
Show documentation for the element at a given position (file:line:col, 1-indexed).
$ cells hover file.cel:1:7List all references to the identifier at a given position.
Each reference is printed as file:line:col.
$ cells references file.cel:1:1Show where the name at a given position was declared.
$ cells definition file.cel:1:1
cel.yaml:5:11CEL has nothing to import, and every standard-library and extension function is
defined in Go, so the names with a definition to point at are the ones
a configuration declared — its variables and functions
entries — and the ones a macro binds, such as the x in list.map(x, x * 2)
or the v in cel.bind(v, ..., ...), which resolve within the file itself.
Rename the identifier at a given position.
Without --write, prints the updated content to stdout.
$ cells rename --new-name=newVar file.cel:1:1
$ cells rename --new-name=newVar --write file.cel:1:1Show what this binary was built from. The Go toolchain records it; nothing is stamped by hand.
$ cells version
cells v0.4.0
revision 8d6dbb7fb8d4
built 2026-09-11T18:08:46Z
go go1.27.1A binary installed from a module tag reports that tag and carries no revision. One built from a checkout reports the revision it came from, marked when the tree had uncommitted changes.
Real CEL expressions refer to variables their host provides — request,
resource, a request message. cells knows nothing about those by default, so
every one of them reads as an undeclared reference:
$ cat policy.cel
request.method == "POST" && retries < 3
$ cells check policy.cel
policy.cel:1:1: warning: undeclared reference to 'request' (in container '')
policy.cel:1:29: warning: undeclared reference to 'retries' (in container '')Declare them in a cel-go environment configuration, the same YAML format other CEL tooling reads:
# cel.yaml
name: my-service
extensions:
- name: strings
variables:
- name: request
type: "map<string, dyn>"
- name: retries
type: "int"$ cells check --config=cel.yaml policy.celcells finds cel.yaml on its own: with no --config, it looks in the
directory of each file being checked and then in each parent, so a
configuration covers the tree beneath it and a repository with differently
configured subdirectories works without any flags. The nearest one wins, and
--config overrides the search entirely.
The language server discovers a configuration per document the same way, and picks up edits to it without a restart. A configuration that fails to load is reported as a diagnostic on the files that depend on it.
With the environment declared, a genuine mistake is an error rather than a warning lost among undeclared references:
$ echo 'retries + "x"' > bad.cel
$ cells check --config=cel.yaml bad.cel
bad.cel:1:9: error: found no matching overload for '+' applied to '(int, string)'--config applies to every command. To name one explicitly for the language
server, set config in your editor's initializationOptions:
{ "config": "/path/to/cel.yaml" }The configuration also accepts container and imports for namespacing,
functions for host-provided functions, and extensions (see below).
stdlib subsetting is not supported.
To declare a variable whose type is a protobuf message, point cells at an
encoded FileDescriptorSet so it can resolve the type:
$ buf build -o descriptors.binpb # or protoc --descriptor_set_out=...
$ cells check --descriptor-set=descriptors.binpb --config=cel.yaml policy.cel# cel.yaml
name: my-service
variables:
- name: request
type_name: "my.service.Request"Field access is then checked against the message:
$ echo 'request.nosuchfield == 1' > bad.cel
$ cells check --descriptor-set=descriptors.binpb --config=cel.yaml bad.cel
bad.cel:1:8: error: undefined field 'nosuchfield'Hover and completion report a field's type along with the comment written
above it in the .proto file, which travels in the descriptor set:
$ cells hover --descriptor-set=descriptors.binpb --config=cel.yaml policy.cel:1:9
`method`: `string`
The HTTP method, uppercased.buf build includes the comments by default; protoc needs
--include_source_info. Without them the type is reported on its own.
Hover reports a declared variable's type and whatever description the
configuration gave it, and completion offers the declared names — after a dot
on a message-typed value, its fields:
$ cells hover --descriptor-set=descriptors.binpb --config=cel.yaml policy.cel:1:1
`request`: `my.service.Request`
The request being authorized.context_variable declares every field of a message as a top-level name
instead, which is how many hosts present their input:
context_variable:
type_name: "my.service.Request"$ echo 'method == "POST" && retries < 3' | cells check --descriptor-set=descriptors.binpb --config=cel.yaml /dev/stdin--descriptor-set is repeatable. For the language server, set
descriptorSets in your editor's initializationOptions:
{ "descriptorSets": ["/path/to/descriptors.binpb"] }cells checks against plain CEL by default. Enable cel-go extension libraries
(strings, math, network, etc.) to match the environment your expressions actually run in.
Also available: optional for optional types
(?., [?_], optional.of), and jwt and hmac from
cel-go's security extensions.
Enabling regex or jwt enables optional too, since both depend on it.
On the CLI, pass --ext (repeatable) to any command:
$ cells check --ext=network file.cel
$ cells hover --ext=network --ext=strings file.cel:1:1For the language server, set extensions in your editor's initializationOptions:
{ "extensions": ["network", "strings"] }Extensions can also be named in a configuration, under the same
names, where a version may be pinned:
extensions:
- name: strings
version: 2
- name: network--ext adds to whatever the configuration already asks for. Names given
without a version get the newest one.
Run cells --help for the full list of supported names.
cells publishes diagnostics as a file is opened and edited. Clients that
implement the LSP pull model can ask for them instead; set pullDiagnostics in
your editor's initializationOptions:
{ "pullDiagnostics": true }The two models are mutually exclusive. With pullDiagnostics on, cells
advertises the capability and stops publishing, so a client that does both does
not show every diagnostic twice. The trade is that an edit to a configuration
reaches an open file when the client next asks, rather than as soon as cells
notices.
Add to your config (e.g. ~/.config/nvim/init.lua):
vim.lsp.config("cells", {
filetypes = { "cel" },
cmd = { "cells", "serve" },
})
vim.lsp.enable("cells")Neovim recognizes .cel files by default as of 0.12.
Anything this README gives as initializationOptions goes in init_options:
vim.lsp.config("cells", {
filetypes = { "cel" },
cmd = { "cells", "serve" },
init_options = {
config = "/path/to/cel.yaml",
pullDiagnostics = true,
},
})To verify it's working, open a .cel file and run :checkhealth lsp or :LspInfo.