LSP Server
Shuck ships with a first-party Language Server Protocol server in the main CLI. Start it with shuck server and point your editor at that command over stdio.
That gives editor diagnostics from the current in-memory buffer instead of rerunning shuck check from scratch on every save.
Current scope
Shuck advertises a capability only after its request handler is implemented. The scope still varies by feature: some requests analyze only the active document, while workspace symbols and call hierarchy maintain bounded indexes across files.
| Feature | LSP methods | Scope and limitations |
|---|---|---|
| Document synchronization | textDocument/didOpen, didChange, didClose | Incremental synchronization for open shell buffers. Dialect inference uses the buffer languageId, shebang, and file path. |
| Diagnostics | textDocument/diagnostic, textDocument/publishDiagnostics, workspace/diagnostic | Pull diagnostics when the client supports them, with push diagnostics as a fallback. Workspace pulls are enabled by default and discover shell files lazily, honor workspace and ignore boundaries, prefer open buffers, reuse stable result IDs, report partial progress, and stop at configurable file, visited-entry, and source-byte limits. Inter-file diagnostic dependencies are not supported. |
| Code actions | textDocument/codeAction, codeAction/resolve | Quick fixes, disable-this-line actions, and source.fixAll.shuck for the active document. |
| Completion | textDocument/completion, completionItem/resolve | Variables, declaration names, runtime names, builtins, shell keywords, and functions visible in the active document or through unconditional static source/. edges. Literal paths, valid source hints, configured source paths, and unsaved open buffers participate; dynamic or unresolved sources do not. Command-specific option completion is not supported. |
| Hover | textDocument/hover | Semantic symbol information plus help for suppression codes in # shuck: and # shellcheck directives. Function calls can resolve through the same exact static source graph as go-to-definition, including source hints, configured source paths, and open buffers. Dynamic or ambiguous sources are not guessed, and Shuck does not fetch command manuals. |
| Go to definition | textDocument/definition | Variables resolve within the active document. Function calls also resolve through literal sources, valid # shuck: source= hints, and configured source paths, with open buffers and shell source order taking precedence. Dynamic sources are not guessed. |
| Document links | textDocument/documentLink | Resolvable literal source/. operands and valid source hints link to their target files. Configured source paths and unsaved open buffers participate; dynamic, missing, and out-of-workspace targets are omitted. |
| Find references | textDocument/references | Variables remain document-local. Function references follow exact binding identity across the statically resolved source graph, including source hints, configured source paths, cycles, and open buffers. Dynamic or ambiguous calls are omitted, and includeDeclaration is honored. |
| Document highlights | textDocument/documentHighlight | Read and write occurrences within the active document. |
| Document symbols | textDocument/documentSymbol | Functions, declarations, and assignments from the active document. |
| Workspace symbols | workspace/symbol | Fuzzy search over open buffers and discovered shell files in workspace folders, subject to a configurable file limit. |
| Rename | textDocument/prepareRename, textDocument/rename | Variables remain document-local. Function rename spans the exact statically resolved source graph by default when the client supports documentChanges; set server.rename.allowCrossFile to false to keep edits in the active document. Open buffers use versioned edits, while incomplete, ambiguous, stale, overlapping, or out-of-workspace edit sets are rejected atomically. |
| Formatting | textDocument/formatting, textDocument/rangeFormatting | Whole-document formatting or the smallest complete statement containing the selected range. Incomplete syntactic fragments are left unchanged. |
| Folding ranges | textDocument/foldingRange | Functions, conditionals, loops, case statements, subshells, brace groups, heredocs, own-line comment blocks, and active continuation regions. Closing delimiters remain visible, malformed or crossing ranges are omitted, and client range limits are honored. |
| Call hierarchy | textDocument/prepareCallHierarchy, callHierarchy/incomingCalls, callHierarchy/outgoingCalls | Preparation, incoming calls, and outgoing calls work across the statically resolvable source graph. Preparing on a sourced function call returns the exact target definition, including when a file redefines the same function name. Literal sources and valid # shuck: source= hints participate; runtime-only dispatch and unresolved dynamic sources do not. |
| Selection ranges | textDocument/selectionRange | Parser-backed expansion from words and nested expressions through commands, compound constructs, and the complete file. Comments, whitespace, heredocs, incomplete input, multiple positions, and UTF-8/16/32 position encodings are handled predictably. |
Editor formatting uses the same parser-backed formatter as shuck format. Once shuck server is attached, use your editor's normal LSP format command or format-on-save integration.
Planned or deferred
- Language-server support for embedded shell regions, such as scripts inside GitHub Actions YAML, needs a separate position-remapping design.
Not currently planned
Shuck does not currently advertise semantic tokens, inlay hints, signature help, or notebook documents. These are not permanent prohibitions, but there is no accepted implementation design for them today.
Workspace pulls are enabled by default. Disable them through initialization options if an editor should only request diagnostics for open documents:
{
"shuck": {
"server": {
"workspaceDiagnostics": {
"enabled": false
}
}
}
}Shuck does not scan a workspace at startup. Each request performs a cancellable bounded discovery with defaults of 1,000 files, 10,000 visited entries, and 32 MiB of source text. Embedded host documents remain excluded until their diagnostic position mapping is available. Dynamic configuration can change the limits or disable subsequent workspace pulls.
This page is the user-facing support contract. The repository's design specs record the scope and decisions of individual implementation stages, so an older spec may describe a feature as a non-goal that a later spec subsequently added. If an advertised feature fails within the scope above, please file a bug. If the requested behavior is listed as deferred or unsupported, file a feature request instead.
Before you start
Make sure shuck is installed and available on your PATH:
shuck --version
shuck serverIf shuck server starts successfully, your editor only needs to launch that command over stdio.
Shuck also resolves .shuck.toml or shuck.toml from the workspace and layers any LSP-provided Shuck settings on top when the client supports them.
Neovim
Neovim 0.11+ can use the built-in LSP client directly through vim.lsp:
vim.lsp.config("shuck", {
cmd = { "shuck", "server" },
filetypes = { "sh", "bash", "zsh", "ksh" },
root_markers = { ".shuck.toml", "shuck.toml", ".git" },
})
vim.lsp.enable("shuck")If your setup normalizes shell buffers to sh, leaving only sh in filetypes is fine too.
Vim
With vim-lsp, register Shuck in your .vimrc:
if executable('shuck')
augroup shuck_lsp
au!
au User lsp_setup call lsp#register_server({
\ 'name': 'shuck',
\ 'cmd': {server_info->['shuck', 'server']},
\ 'allowlist': ['sh'],
\ })
augroup END
endifMost Vim setups detect shell scripts as sh. If yours uses dedicated bash, zsh, or ksh filetypes, add them to the allowlist as well.
Emacs
Eglot can launch shuck server for shell buffers by extending eglot-server-programs:
(with-eval-after-load 'eglot
(add-to-list 'eglot-server-programs
'(sh-mode . ("shuck" "server"))))Then open a shell script and run M-x eglot.
If you use additional shell major modes, add them to the same entry or register a second one for those modes.
Zed
Zed currently routes language servers through extensions rather than letting you point a language directly at an arbitrary stdio command from settings alone. The practical route today is a small dev extension that launches shuck server for Shell Script buffers.
In extension.toml, register a shell language server:
id = "shuck"
name = "Shuck"
version = "0.0.1"
schema_version = 1
[language_servers.shuck]
name = "Shuck"
languages = ["Shell Script"]
[language_servers.shuck.language_ids]
"Shell Script" = "shellscript"Then implement language_server_command in the extension's Rust entrypoint, following the Zed language extension docs:
fn language_server_command(
&mut self,
_language_server_id: &zed::LanguageServerId,
_worktree: &zed::Worktree,
) -> Result<zed::Command> {
Ok(zed::Command {
command: "shuck".into(),
args: vec!["server".into()],
env: Default::default(),
})
}Once the extension is installed, prefer it for shell files in your Zed settings:
{
"languages": {
"Shell Script": {
"language_servers": ["shuck", "..."]
}
}
}Related docs
For project-wide lint configuration, see Configuration and Settings Reference.