clear-ls
clear-ls
Experimental Crystal language server written in Crystal, built on Tree-sitter with an editor-oriented semantic model. Status: Phase 14 —
-
multi-root workspaces: every workspace folder is its own project (its own
lib/, program views, local cache and compiler facts); stdlib and shard declarations parsed once for all of them; each program is compiled in its project's directory, nested projects included; folders added or removed while running (workspace/didChangeWorkspaceFolders); -
snippets and require completion:
xs.each docompletes todo |x| ... endwith the method's block parameters;in/whenin acasewrites every missing branch (enum members, union members,true/false, concrete subclasses); optionalclass/def/if/... skeletons only where the compiler accepts them (off by default); insiderequire "...": local files, stdlib paths one level at a time, and direct shard dependencies, each verified to resolve, with the file's declarations as detail; -
editing UX:
property/getter/setter/recordaccessors are one symbol for references and rename (getter,name=,@name,name?, namednewarguments); completion writes setters asx.name =, operators asx +, and blocks for block-only methods (xs.map { |x| }); alias names in inlay hints and hover;{ }↔do ... endcode action; every setting in one documented schema (docs/settings.md), live throughdidChangeConfiguration, including per-kind inlay hint switches; -
fast starts: a persistent index cache (stdlib and shard declarations shared across projects, the rest in the project's
.clear-ls/) brings a warm start on Marten (1955 files) to ~240 ms; macro-generated methods from the last compiler check are available before the first new one; while idle, a background warm-up infers the workspace so the first references search is fast (88 ms vs 1.3 s cold); -
responsiveness on large projects: references, rename and incoming calls run as cancellable jobs (other requests answered in between,
$/cancelRequest, time budgets that fail with a clear message); specs compile as one program likecrystal spec; on Marten (1955 files) memory stays under 300 MB with 100 open specs; -
workspace identity: references, rename, incoming calls by resolved identity across the program (overloads, unrelated same-name methods and other programs stay apart; rename refuses when any use is unconfirmed and takes override families along); ivar definitions and references; type hierarchy; quick fixes from compiler diagnostics ("did you mean", missing
require); one program view per project, nested project, script, or loose file; -
language-semantics parity: every example of the Crystal 1.22 syntax and semantics book gets the compiler's type or a documented deviation (docs/book-gaps.md): literal and autocast rules, enums, primitives,
breakvalues, procs andwith … yield, splats, closures,previous_def/super, genericnew, undeclared ivars from their assignments,responds_to?/tuplecasenarrowing, union completion, C bindings; -
compiler integration: async diagnostics from
crystal build --no-codegen, formatting viacrystal tool format, macro expansion preview, methods generated by hooks/{% for %}/unknown macros and ivar types imported from the compiler, compiler-confirmed local types in hover; -
return types of unannotated methods inferred per call (summaries), so collection chains (
map,compact_map,find,reduce,group_by,to_h, hash and tuple indexing) keep concrete types; inferred returns in hover and inlay hints; -
flow-sensitive local types: branch joins, narrowing by
if x,nil?,is_a?,&&/||/!,case/in, early exits, loops, rescue; -
completion: members of inferred receivers,
Foo::paths, constants,@ivars, locals, methods, keywords; -
overload resolution: hover, definition, and signature help show the overloads that fit the call's arguments; named-argument completion;
-
implementation (overrides, abstract def implementers, subtypes) and call hierarchy (incoming and outgoing);
-
hover with inferred types for locals and resolved method signatures;
-
inlay type hints for locals, block arguments and inferred return types;
-
document and workspace symbols, folding, selection ranges;
-
goto-definition for types, constants, locals, ivars, and method calls;
-
highlights, and references/rename for locals and parameters;
-
semantic tokens (params, locals, ivars, types, declarations);
-
links and goto-definition for
requirepaths.
See docs/status.md for where development stands, docs/roadmap.md for the plan, and docs/development.md for workflow, tests, and tools.
Build
Requires Crystal ≥ 1.22, a C compiler, git, and make; Linux or macOS.
make # fetch pinned deps into vendor/, build bin/clear-ls (debug)
make release # optimised build; use this one in the editor
make spec # run specs
make verify-static
make probes # analysis probes into bin/probes/ (tools/README.md)
make nvim-check # headless Neovim checks against tools/fixtures/
The Tree-sitter runtime (v0.26.9) and tree-sitter-crystal grammar are compiled from source and linked statically. make grammar-check (needs the tree-sitter CLI) parses the Crystal stdlib and lists grammar failures. After a grammar bump, run make syntax-gen to regenerate src/clear_ls/syntax/kinds.cr.
Usage
bin/clear-ls lsp [--log-level debug] [--log-file PATH] [--no-cache] # stdio server
bin/clear-ls cache info|clear [ROOT] # persistent cache locations / delete
bin/clear-ls settings-doc # settings reference (docs/settings.md)
bin/clear-ls dump-cst [--ranges] FILE # print the CST
bin/clear-ls dump-items FILE # print declarations
bin/clear-ls dump-hir FILE LINE # print a def's body HIR
bin/clear-ls dump-cfg FILE LINE # print a def's control-flow graph
The server reads CRYSTAL_PATH, version, and target from crystal env / crystal --version (override the executable with CRYSTAL=...) and indexes the prelude's require closure, required shards, and all .cr files under each workspace root.
Several projects. Each workspace folder (or the project above it, when the folder is a directory inside a project) is indexed as its own project; requests on a file are answered by the project that contains it (the innermost, for nested folders), and a stdlib or shard file you jumped into by the project you came from. References and rename stay within one project; workspace symbols cover all of them. In Neovim, add a folder to the running client with :lua vim.lsp.buf.add_workspace_folder("/path/to/other"): buffers under it then attach to the same client (a buffer under another root_markers match that is not a folder gets a client of its own, as before).
Every setting, and a recommended Neovim setup (including blink.cmp without automatic ()), is in docs/settings.md (initializationOptions or workspace/didChangeConfiguration).
Neovim (0.11+):
vim.lsp.config("clear_ls", {
cmd = { "/path/to/clear-ls/bin/clear-ls", "lsp", "--log-file", "/tmp/clear-ls.log", "--log-level", "debug" },
filetypes = { "crystal" },
root_markers = { "shard.yml", ".git" },
})
vim.lsp.enable("clear_ls")
Debug request: clearLs/syntaxTree {textDocument: {uri}} returns the CST as an S-expression.
Cache and warm-up
Declarations are cached between sessions (design: docs/design/persistent-cache.md):
- stdlib and shard packs in
$XDG_CACHE_HOME/clear-ls(~/.cache/clear-ls; macOS~/Library/Caches/clear-ls; override withCLEAR_LS_CACHE_DIR), shared by every project, checked by file content; - everything else, plus the last compiler facts, in
<root>/.clear-ls/cache/(ignored by git through its own.gitignore), checked by size and mtime.
A new clear-ls version, Crystal version or target invalidates entries; a damaged file is ignored and rewritten. After indexing, the server infers workspace bodies while idle (no messages for 300 ms) so references searches start warm. Settings:
Settings: cache.*, warmup.enabled (docs/settings.md). clearLs/clearCache (no params) deletes every root's project cache and the global packs they use, then re-indexes.
Compiler integration
Diagnostics come only from the compiler. In the background, clear-ls runs crystal build --no-codegen for each program that contains the file: the shard.yml targets that require it, or the file itself. Each program is compiled in its project's directory (the nearest shard.yml above it), so a nested project or another workspace folder uses its own lib/. These checks run at startup, when a file is opened, and when a file is saved. The compiler reads files from disk, so diagnostics describe the saved text. Formatting runs crystal tool format on the buffer text. Settings (compiler.*) are in docs/settings.md.
List every program in shard.yml targets. clear-ls learns a project's programs from targets only (or the compiler.entries setting). A file outside every target is compiled on its own, so a file that relies on another file's requires reports false errors. Example: a Marten project from marten new has no targets, and config/routes.cr reports undefined constant Marten because only src/project.cr requires marten. Add the project's programs:
targets:
server:
main: src/server.cr
manage:
main: manage.cr # Marten CLI; the only program that requires src/migrations/**
Targets don't affect dependency resolution or the compiler itself; they are what shards build builds (into bin/), and bare shards run needs a target name once there are several. Every target that contains a file is checked in the background when that file is saved, so several large targets cost CPU but never block editing. Targets also define the project's program view for hover/definition (without them clear-ls falls back to everything under src/).
Macro expansion preview: clearLs/expandMacro {textDocument, position} runs crystal tool expand on the saved file and returns {status, message, expansions: [{original, steps: [{source, macros}]}]}. Hook macros (inherited, included) can't be expanded this way. In Neovim:
vim.api.nvim_create_user_command("CrystalExpand", function()
local params = vim.lsp.util.make_position_params(0, "utf-16")
vim.lsp.buf_request(0, "clearLs/expandMacro", params, function(err, res)
if err then return vim.notify(err.message, vim.log.levels.WARN) end
local lines = { "# " .. res.message }
for _, e in ipairs(res.expansions) do
table.insert(lines, "# " .. e.original)
for _, s in ipairs(e.steps) do vim.list_extend(lines, vim.split(s.source, "\n")) end
end
vim.lsp.util.open_floating_preview(lines, "crystal")
end)
end, {})
clear-ls
- 0
- 0
- 0
- 0
- 1
- about 2 hours ago
- November 2, 2026
MIT License
Mon, 02 Nov 2026 13:00:33 GMT