crystal-language-server-protocol

crystal-language-server-protocol

A dependency-free Crystal LSP library and a runnable Crystal language server. The implementation is original; the research note documents the specifications and lessons considered from other projects.

Requires Crystal 1.21.1 or later. The protocol core follows LSP 3.18, with typed values for document synchronization, positions, edits, locations, and diagnostics. Arbitrary JSON payloads support other LSP and custom methods. This is an initial implementation with explicit feature coverage, rather than a complete set of generated protocol bindings.

Run the Crystal server

mkdir -p bin
crystal build src/crystal-language-server/main.cr -o bin/crystal-language-server

Configure your editor's LSP client to launch the absolute path to bin/crystal-language-server for Crystal files. It communicates over standard input/output; logs go to standard error. An installed crystal compiler must be on PATH, or supplied with --crystal /path/to/crystal. Pass compile-time flags with repeated -D FLAG arguments. The server supports the initialize → initialized → shutdown → exit lifecycle.

Select a saved application analysis target with --entrypoint PATH so compiler diagnostics and semantic queries use its program context, including when querying a required file:

/path/to/bin/crystal-language-server \
  --working-directory /path/to/project --entrypoint src/app.cr -D development

--working-directory PATH sets the compiler's working directory. Relative entry points resolve against that directory, or the server's launch directory when it is omitted. Without an explicit working directory, compiler queries run in the nearest shard directory containing the entry point, falling back to the entry point's directory. The selected target and compilation settings remain fixed for the server process; restart the server to change them. Target analysis reads saved sources from disk; queries in a changed buffer use available source fallbacks until saved. Multi-buffer unsaved compiler overlays remain future work. Formatting always uses the current unsaved buffer.

Feature Behavior
Document synchronization Full or incremental changes, negotiated UTF-8/UTF-16/UTF-32 positions, immutable versioned snapshots
Diagnostics Debounced compiler checks of the saved target or standalone unsaved text; target errors retain their source document and range; obsolete results are discarded
Hover Compiler type context, with labeled source-declaration fallback; honors preferred markup format
Definition Compiler locations, with name-based source-index fallback
Completion Compiler context variables, indexed declarations, and Crystal keywords
Document/workspace symbols Tolerant declaration index, including incomplete and unsaved buffers
Formatting Compiler formatter returns an edit; the server never writes the source file
Cancellation Control notifications bypass execution slots; compiler jobs can be interrupted and have deadlines

Without --entrypoint, compiler queries use the current document as the entry point and send its unsaved text through --stdin-filename. Required dependencies are read from disk; other unsaved buffers are not overlaid. Unless --working-directory is set, queries run in the nearest shard directory so its lib directory can resolve. Crystal may lack type information for uncalled methods or syntactically incomplete code. Source fallbacks provide declaration facts, not reliable binding resolution, receiver-member completion, references, or rename. These optional features are not advertised. Compiler jobs are limited to two at once, with a 30-second deadline and 4 MiB output limit per stream. Edits interrupt obsolete analysis; interactive queries may still wait for compilation, since compiler results are not cached yet.

Workspace symbols index the first workspace folder/root URI in the background, excluding hidden directories, symlinks, lib, bin, node_modules, and generated docs. Discovery is capped at 2,000 .cr files, 5,000 directories, 50,000 entries, 1 MiB per file, and 32 MiB of disk source. Each indexed file contributes at most 10,000 declarations. Open snapshots override disk content; files changed outside the editor are refreshed on reopen/close or server restart. Multi-root scanning remains future work.

Use the protocol library

For local development, add this checkout as a path dependency and run shards install in your application:

dependencies:
  crystal-language-server-protocol:
    path: /absolute/path/to/crystal-language-server-protocol
require "crystal-language-server-protocol"
alias LSP = Crystal::Language::Server::Protocol

transport = LSP::Transport.new(input_io, output_io)
if message = transport.read
  if message.request?
    transport.write(LSP::Message.success(message.id.not_nil!, JSON::Any.new(nil)))
  end
end

Requiring the shard does not start a server, spawn processes, access files, or create a global LSP alias. Transport accepts any Crystal IO, leaves ownership with the caller, returns nil on clean EOF, and raises FramingError on malformed/truncated frames. Headers and bodies default to 8 KiB and 16 MiB limits; configure these through its constructor. One reader owns an input stream; a mutex serializes complete output frames.

Message.parse validates JSON-RPC discriminators, retains unknown envelope fields, distinguishes integer and string IDs, and requires exactly one of result or error for responses. Crystal nil omits optional members; JSON::Any.new(nil) represents explicit JSON null in results, error data, or nested payload values. Params must be objects or arrays. Typed parameter structs use LSP field names and tolerate unknown fields; use raw JSON when you need to preserve extension fields or optional-versus-null presence.

document = LSP::Document.new("file:///tmp/demo.cr", "crystal", 1, "puts \"🌍\"\n")
store = LSP::DocumentStore.new
store.open(document)
store.change(document.uri, 2, [LSP::TextDocumentContentChangeEvent.new("puts 42\n")])
# `document` still contains version 1. Each notification commits atomically.

Positions beyond a line's length clamp to its end. Invalid lines and positions splitting a Unicode scalar or CRLF are rejected. DocumentStore rejects stale versions, applies changes sequentially, and keeps the previous snapshot on any failure. The caller owns notification ordering and thread synchronization when using the store directly.

Server supplies lifecycle handling and bounded request dispatch. Register initialize and your language methods with on_request. Preparation runs in receive order and returns a Proc(RequestContext, JSON::Any) to execute asynchronously, allowing you to capture a snapshot before subsequent changes. Notifications run in receive order. Jobs must yield for expensive work, check their context's cancellation token, and optionally register interrupts with on_cancel. Cancellation is cooperative; the generic runtime does not preempt arbitrary handlers. The executable owns process exit; Server#run returns the exit status. This server-side dispatcher has no outgoing request/response-correlation API.

examples/echo_server.cr is a runnable language-independent server showing this interface.

Development

crystal tool format --check src spec examples
crystal spec
crystal build src/crystal-language-server/main.cr -o /tmp/crystal-language-server
python3 spec/stdio_integration.py /tmp/crystal-language-server

Tests exercise wire framing, lifecycle/cancellation, transactional documents, source indexing, compiler integration, and server behavior. Compiler tests require an installed Crystal executable. No third-party shards are needed.

GitHub Actions runs formatting checks, specs, the server build, and the stdio integration test on Ubuntu with Crystal 1.21.1 and the latest release. The workflow runs on pushes and pull requests, and can also be started manually.

Repository

crystal-language-server-protocol

Owner
Statistic
  • 0
  • 0
  • 15
  • 0
  • 0
  • about 1 hour ago
  • October 6, 2026
License

MIT License

Links
Synced at

Tue, 06 Oct 2026 22:46:27 GMT

Languages