crystal-language-server

Version, currently main branch1 version
  • main branchlatestApr 29, 2026

github.com/grepsedawk/crystal-language-server

A Language Server Protocol implementation for the Crystal programming language. Provides hover, goto-definition, formatting, document symbols, completion, diagnostics and semantic tokens over stdio JSON-RPC.

1 stars
0 dependents
License: MIT

Installation

# Add this to your shard.yml
dependencies:
  crystal-language-server:
    github: grepsedawk/crystal-language-server
    branch: main

main is a branch, not a release, so this tracks it rather than pinning a version.

Then run:

shards install

shard.yml

Crystal
>= 1.0.0
License
MIT
Author
Alex Piechowski
Target
  • crystal-language-server from src/crystal-language-server.cr

Dependencies

Runtime Dependencies

  • markd*github: icyleaf/markd
  • reply*github: I3oris/reply, commit: 13f7eba083f138dd063c68b859c8e315f44fb523
  • sanitize*github: straight-shoota/sanitize, commit: 75c141b619c77956e88f557149566cd28876398b

README

crystal-language-server

A Language Server Protocol implementation for the Crystal programming language. Speaks JSON-RPC over stdio. Built for day-to-day editing: scanner-based structure handled in-process for responsiveness, semantic answers delegated to the Crystal compiler so the type info stays honest.

Status

Every LSP method advertised below is implemented. Trade-offs between scanner-based and compiler-based answers are documented per row.

Supported LSP methods

CapabilityImplementation
initialize / shutdown / exit
textDocument/didOpen/Change/Close/Saveincremental sync, in-memory doc store
textDocument/publishDiagnosticscrystal build --no-codegen -f json, debounced, on-save by default
textDocument/diagnostic (pull)LSP 3.17 pull model; shares a cache with publishDiagnostics
textDocument/hoverscanner + workspace index first, falls back to crystal tool context
textDocument/definitionscanner-first (local bindings, unambiguous workspace hits), compiler next
textDocument/declarationalias of textDocument/definition
textDocument/implementationscanner-walks the workspace for concrete overrides (methods) or subclasses (types), compiler tool implementations first
textDocument/typeDefinitioncrystal tool context → workspace-index lookup of the resolved type
textDocument/referencesscanner-based workspace walk with @ivar / @@cvar / $global awareness
textDocument/rename + prepareRenamescanner-based rewrite, token-range prepare
textDocument/formattingcrystal tool format
textDocument/rangeFormattingfull-document format, edits diffed to the requested range
textDocument/onTypeFormattingindent-aware newline handling
textDocument/documentSymbolscanner-based hierarchical outline
textDocument/documentHighlightscanner identifier matches
textDocument/documentLinkrequire "…" lines, resolved against shard root and relative paths
textDocument/completion + completionItem/resolvekeywords + pseudo-methods + scanner symbols; type-aware after .; snippet insertText for methods with required args; auto-require additionalTextEdits for cross-file classes; allCommitCharacters so ./(/[/, commit; LSP 3.17 labelDetails shows (args) : ReturnType in the popup
textDocument/signatureHelpnearest matching def, workspace fallback
textDocument/inlayHintinferred local-variable types (compiler)
textDocument/semanticTokens/full + /range + /full/deltascanner-based highlighting; delta responses diff the last payload for large files; declaration / readonly / defaultLibrary modifier bits emitted on name-tokens, constants, and curated stdlib types
textDocument/publishDiagnostics tagsUnnecessary (dimmed) for unused-variable warnings, Deprecated (strikethrough) for @[Deprecated] calls
textDocument/foldingRangeblock-structured folds
textDocument/selectionRangescanner tree nesting plus word / line / document layers
textDocument/codeActionquick-fix auto-require for undefined constant / undefined method; source.fixAll bundles all auto-requires; source.organizeImports sorts + dedupes the leading require block (stdlib → shards → relative)
textDocument/codeLens + codeLens/resolve"N references" over every top-level def/class; ▶ Run over every it / describe / context in *_spec.cr, wired to the crystal.runSpec command
textDocument/willSaveWaitUntilopt-in via CRYSTAL_LANGUAGE_SERVER_WILL_SAVE_ACTIONS=organize_imports: runs organizeImports edits as the editor blocks for save
textDocument/prepareCallHierarchy + incomingCalls + outgoingCallsscanner-driven caller/callee graph
textDocument/prepareTypeHierarchy + supertypes + subtypesscanner-driven inheritance tree
workspace/symbolscanner over open docs + .cr files under the workspace root
workspace/didChangeConfigurationaccepted (no-op today)
workspace/didChangeWatchedFilesinvalidates per-file index and compiler result cache; server registers a **/*.cr watcher on initialized via client/registerCapability so clients that don't auto-watch still fire the notification
workspace/didCreateFiles / didRenameFiles / didDeleteFilesexplorer-driven file ops — reindex the name index for the affected paths
workspace/executeCommandregisters crystal.runSpec, crystal.runFile, crystal.formatFile; each detaches the subprocess on a fiber so the LSP dispatch returns immediately
window/logMessageserver-originated: LogForwarder backend mirrors Log.warn/Log.error to the editor's output panel once the client connects
window/showDocumentserver-originated: Server#send_show_document lets handlers ask the editor to focus or open a URI
workspace/configuration (reverse)server-originated: Server#request_configuration asks for client settings (fire-and-forget in v1)
$/progress + window/workDoneProgress/createthe workspace-index warm pass shows an "Indexing Crystal workspace" progress bar when the client advertises window.workDoneProgress
$/cancelRequestin-flight requests reply -32800 and SIGKILL any spawned compiler subprocess

Comparison

This project isn't a drop-in replacement for the LSPs you may have used in other languages. Here's the honest shape of what it gets right and where it compromises.

vs. crystalline

Crystalline is the mature Crystal LSP. It embeds the compiler in process and keeps a persistent Crystal::Program alive across requests.

Dimensioncrystal-language-servercrystalline
Memory footprint10–40 MB resident (no retained Program)hundreds of MB on real projects
Cold-start latencymilliseconds (binary ~1 MB in subprocess mode)seconds (loads compiler + Program)
Per-request latency~µs for goto/refs/hover/outline off the warm name index; compiler fallback capped at 10sfast, in-process compiler
Type inference depthcompiler-accurate only for hover/inlay/type-definition (via subprocess crystal tool); other features are scanner-heuristicfull — every feature backed by the compiler
Scope of goto/refsscanner-based workspace walk + compiler fallback; some false positives on overloadscompiler-accurate
Completion after .receiver type from compiler, methods from scanner-indexcompiler-accurate including inherited methods
Works when code doesn't compileyes — all scanner-based features still workpartially — many features degrade
Compiler version driftsubprocess mode follows crystal on PATH; embedded mode pinned at build timepinned at build time

Pick crystalline when you want rust-analyzer-style fidelity and don't mind the RAM cost. Pick this one when you want a light editor companion, often-good-enough answers, and responsiveness even on broken code.

vs. popular LSPs in other languages

Featurethis projectcrystallinerust-analyzer / gopls / clangd
Incremental type-checkno — subprocess recompilesno — full recompileyes — incremental
Receiver-type narrowing on refs / renamenopartialyes
Cross-file macro expansioncompiler onlyyesyes (per language)
Semantic tokensscanner (types, constants, stdlib marked defaultLibrary; delta-diff responses)compiler-backedcompiler-backed
Call hierarchyscanner heuristic (text match + enclosing def)limitedcompiler-accurate
Type hierarchyscanner heuristic (parses class X < Y)limitedcompiler-accurate
Code actionsquickfix (auto-require), source.fixAll, source.organizeImportsfewextensive incl. refactors (extract/inline/rewrite)
Inlay hintscompiler (locals only)compilercompiler-backed
Diagnostic tagsUnnecessary + Deprecated bits set from compiler warning textnoyes
Work-done progress$/progress on workspace-index warmnoyes
Test CodeLens (▶ Run)scanner-detected it/describe/context above each spec example, dispatches crystal.runSpecnoyes (Rust/Go equivalents)
willSaveWaitUntilopt-in, runs organizeImports on save (env-gated)noyes
Client log forwardingwindow/logMessage backend mirrors Log.warn/error to the editor output panelnoyes
Refactor code actions (extract method / inline / rewrite)nonoyes

Known limitations

  • References / rename are text-matched. Identifiers with the same name on different receivers collapse into one set. The scanner tags @ivar / @@cvar / $global separately, but plain method names aren't disambiguated by receiver type.
  • Call hierarchy and type hierarchy are heuristics. They work off scanner matches and the class X < Y pattern in each class's opener line. They do not follow include / extend, and they do not resolve macro-generated defs.
  • Subprocess mode pays fork+parse cost per compiler call. The result cache, scanner-first fallbacks, and 10 s timeout keep most requests off the compiler, but a cold hover on a new file will wait up to a compile round-trip.
  • Workspace index is scanner-based. A persistent name → DefSite[] index is warmed in a background fiber on startup and updated incrementally from the text-sync notifications, so find_defs is a hash probe rather than a file walk. It carries no type information — overloads and receiver narrowing still need the compiler.
  • Diagnostics default to on-save. Change the default via CRYSTAL_LANGUAGE_SERVER_DIAGNOSTICS=on_change if you want per- keystroke-pause compiles.

Architecture

stdin/stdout  ──>  Transport (Content-Length framing)
                     │
                     ▼
                  Server (fiber-per-request dispatch, $/cancelRequest)
                     │
       ┌─────────────┼──────────────┐
       ▼             ▼              ▼
   DocumentStore   Scanner       Compiler::Provider
   (open buffers,  (local parse) (subprocess or embedded,
    memoized                       cancellable, result + diagnostic
    tokens/symbols                 caches)
    per version)
                     │              │
                     └── Handlers ──┘
                        (one module per LSP method)
                     │
                     ▼
              WorkspaceIndex
    (warm name→DefSite[] index, mtime-keyed scanner cache,
     symlink-loop-safe directory walk)

Design choices:

  • Scanner first, compiler second. Goto, hover, references, rename, outline, highlight, completion, folding, signature help, selection range, call hierarchy, and type hierarchy all read from a small hand-written tokenizer against the in-memory buffer, with a cross-file workspace index for cold lookups. The compiler is consulted only for the typed answers no tokenizer can give you: inferred variable types, implementations across generic instantiations, build diagnostics.
  • Handler-per-file. Every LSP method has its own module under src/crystal_language_server/handlers/. Adding a method is a single new file plus one line in Server#dispatch_* and one line in Handlers::Lifecycle.capabilities.
  • Concurrent dispatch. Each request runs in its own fiber; a slow hover on one file doesn't stall completion on another. Transport writes are mutex-guarded so replies can't interleave.
  • Bounded caches. Compile results, build diagnostics, scanner trees, receiver types, and the workspace's .cr file list are all cached with bounded entries or short TTLs. Open documents memoize their tokens + symbols per version; closed documents drop their per-URI caches. $/cancelRequest propagates into spawned compiler subprocesses so stale hovers don't hold up later ones.
  • Symlink-loop safe. Workspace walking canonicalizes each directory via realpath and skips already-seen paths, so lib/foo/lib/foo/lib/foo/… dep trees don't infinite-loop.

Compiler modes

The server can reach the Crystal compiler two ways. Pick via CRYSTAL_LANGUAGE_SERVER_MODE (defaults to subprocess):

ModeHowProsCons
subprocessshells out to crystal tool … per requestworks with whatever crystal is on PATH; stable across versions; small binaryslower per-call; capped by a 10 s timeout to keep the editor responsive
embeddedCrystal compiler linked in-process (opt-in)no fork/exec; one compile serves multiple tools; larger memory-residencylocked to the exact compiler version the LSP was built against; ~37 MB binary
# default
CRYSTAL_LANGUAGE_SERVER_MODE=subprocess crystal-language-server

# opt-in, requires a build that linked the compiler in
CRYSTAL_LANGUAGE_SERVER_MODE=embedded crystal-language-server

Use subprocess unless you want embedded and are willing to rebuild the LSP when you bump your project's Crystal version.

Install

You need Crystal / shards on PATH (the LSP is itself a Crystal program that shells out to crystal at runtime in the default mode).

As a Neovim plugin (easiest)

The repo doubles as a Neovim plugin. Ship one line to a plugin manager, and the build hook produces the binary inside the plugin directory — the bundled Lua then points nvim's native LSP at it automatically. No global install needed.

lazy.nvim

{
  "grepsedawk/crystal-language-server",
  build = "shards build --release --no-debug",
  ft    = "crystal",
}

packer.nvim

use {
  "grepsedawk/crystal-language-server",
  run = "shards build --release --no-debug",
  ft  = "crystal",
}

pckr.nvim

{
  "grepsedawk/crystal-language-server",
  run = "shards build --release --no-debug",
}

Rebuild at any time with :CrystalLspBuild.

Customise (all fields optional):

require("crystal_language_server").setup({
  cmd          = { "/custom/path/to/crystal-language-server" },
  filetypes    = { "crystal" },
  root_markers = { "shard.yml", ".git" },
  log_level    = "debug",
  log_path     = vim.fn.stdpath("state") .. "/crystal-lsp.log",
  settings     = {},
})

Set vim.g.crystal_language_server_no_autosetup = 1 before the plugin loads to skip the automatic setup and call setup() yourself.

Manual install (any editor)

git clone https://github.com/grepsedawk/crystal-language-server
cd crystal-language-server
shards build --release --no-debug
cp bin/crystal-language-server ~/.local/bin/   # or any dir in PATH

VS Code

Add a thin extension that points at the binary; the generic LSP client recipe from the VS Code docs works unchanged.

Claude Code

Claude Code has native LSP support, so this repo doubles as a Claude Code plugin. The repo root ships a one-plugin marketplace in .claude-plugin/marketplace.json pointing at ./plugin/. Install:

/plugin marketplace add grepsedawk/crystal-language-server
/plugin install crystal-lsp

After install, Claude Code spawns crystal-language-server on .cr files automatically — you still need the binary on $PATH. The plugin/README.md inside the repo walks through shards build --release. Once installed, Claude gets hover, goto-definition, diagnostics, completion, and everything else on the supported-methods list alongside your editor session.

You can also run it directly in your editor (nvim / VS Code / JetBrains) without the plugin and just let Claude Code edit files alongside — the two workflows coexist.

Environment

VariableEffect
CRYSTAL_LANGUAGE_SERVER_LOGwrite log to this file instead of stderr
CRYSTAL_LANGUAGE_SERVER_LOG_LEVELtrace/debug/info/warn/error
CRYSTAL_LANGUAGE_SERVER_CRYSTALalternate crystal binary path
CRYSTAL_LANGUAGE_SERVER_MODEsubprocess (default) or embedded
CRYSTAL_LANGUAGE_SERVER_DIAGNOSTICSon_save (default), on_change, or never
CRYSTAL_LANGUAGE_SERVER_DIAGNOSTICS_DEBOUNCEdebounce before running build diagnostics (seconds, default 0.4)
CRYSTAL_SOURCE_PATHbuild-time: path to Crystal compiler source for embedded mode

Tests

crystal spec

Unit specs cover the transport, document/position math, scanner, workspace index, and compiler adapters. Integration specs spawn the real crystal CLI.

License

MIT.

Development

git clone https://github.com/grepsedawk/crystal-language-server
cd crystal-language-server
shards install
shards build
crystal spec

CI runs the matrix defined in .github/workflows/ci.yml against Crystal 1.17.0, 1.18.1, and 1.19.1 on Ubuntu. Target any of those locally when reproducing CI failures; 1.19.1 is the version used for the crystal tool format --check gate.

Running against both modes locally:

# subprocess mode (the default)
CRYSTAL_LANGUAGE_SERVER_MODE=subprocess ./bin/crystal-language-server

# embedded mode (requires a build with the compiler linked in)
CRYSTAL_SOURCE_PATH=/path/to/crystal shards build --release --no-debug
CRYSTAL_LANGUAGE_SERVER_MODE=embedded ./bin/crystal-language-server

Formatting is enforced by the CI format-check job. Run it locally before pushing:

crystal tool format src spec            # fix in place
crystal tool format --check src spec    # just verify