clear-ls

Crystal Language Server

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 do completes to do |x| ... end with the method's block parameters; in/when in a case writes every missing branch (enum members, union members, true/false, concrete subclasses); optional class/def/if/... skeletons only where the compiler accepts them (off by default); inside require "...": 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/record accessors are one symbol for references and rename (getter, name=, @name, name?, named new arguments); completion writes setters as x.name = , operators as x + , and blocks for block-only methods (xs.map { |x| }); alias names in inlay hints and hover; { } ↔ do ... end code action; every setting in one documented schema (docs/settings.md), live through didChangeConfiguration, 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 like crystal 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, break values, procs and with … yield, splats, closures, previous_def/super, generic new, undeclared ivars from their assignments, responds_to?/tuple case narrowing, union completion, C bindings;

  • compiler integration: async diagnostics from crystal build --no-codegen, formatting via crystal 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 require paths.

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 with CLEAR_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, {})
Repository

clear-ls

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 1
  • about 2 hours ago
  • November 2, 2026
License

MIT License

Links
Synced at

Mon, 02 Nov 2026 13:00:33 GMT

Languages