# shuck

A fast shell script linter, formatter, and language server, written in Rust.

Shuck parses, analyzes, formats, and powers editor feedback for shell scripts. It catches common bugs, style issues, security hazards, performance traps, and portability problems; formats shell sources with configurable layout rules; and ships a first-party Language Server Protocol (LSP) server for editor diagnostics, fixes, navigation, symbols, hover, completion, and formatting. It also lints shell embedded in supported non-shell files such as GitHub Actions workflows. A caching layer keeps incremental runs fast.

## Features

- High performance — ~20-100x faster than ShellCheck
- Linting with rules across correctness, security, performance, portability, and style categories
- Safe and unsafe fix support for selected diagnostics
- Shell formatting with write, check, diff, stdin, and configuration-file modes
- Multi-dialect support: bash, sh/POSIX, mksh, zsh
- Automatic file discovery via extensions and shebang detection
- Embedded shell extraction for GitHub Actions workflows and composite actions
- First-party Language Server Protocol server for editor diagnostics, code actions, navigation, symbols, hover, completion, and formatting
- WebAssembly npm package for bundled Node.js extensions and browser-hosted editors
- ShellCheck suppression compatibility (`# shellcheck disable=SC2086`)

## Installation

### Homebrew

```sh
brew install ewhauser/tap/shuck
```

### PyPI

```sh
pip install shuck-cli
```

The PyPI package is named `shuck-cli`, but it still installs the `shuck`
command.

### npm / WebAssembly

```sh
npm install shuck-wasm
```

`shuck-wasm` exposes source linting and formatting to wasm-aware bundlers, so
Node.js editor extensions and browser-hosted editors can use Shuck without a
separately installed executable. See the
[`shuck-wasm` package documentation](crates/shuck-wasm/README.md) for its
TypeScript API and runtime boundaries.

### From source

```sh
cargo install shuck-cli
```

### Pre-built binaries

Pre-built binaries are available for macOS (aarch64) and Linux (x86_64) from the [releases page](https://github.com/ewhauser/shuck/releases).

### GitHub Actions

Use [`ewhauser/shuck-action`](https://github.com/ewhauser/shuck-action) to install Shuck and report lint findings as native GitHub annotations:

```yaml
steps:
  - uses: actions/checkout@v4
  - uses: ewhauser/shuck-action@v1
    with:
      mode: check
      paths: |
        scripts
        .github/workflows
```

See the action's documentation for setup-only and formatting modes, version pinning, and additional inputs.

## Usage

Shuck's main workflows are split across subcommands:

| Workflow | Command |
|----------|---------|
| Lint files, directories, and supported embedded shell | `shuck check` |
| Format shell files | `shuck format` |
| Run the editor language server over stdio | `shuck server` |
| Remove project cache entries | `shuck clean` |

### Lint

```sh
# Check files and directories
shuck check script.sh src/

# Check the current directory
shuck check .

# Check GitHub Actions workflow `run:` blocks
shuck check .github/workflows/ci.yml

# Check a composite action
shuck check action.yml

# Read from stdin
echo 'echo $foo' | shuck check -

# Read from stdin using a logical filename for dialect and project settings
echo 'echo $foo' | shuck check --stdin-filename script.bash

# Apply safe fixes automatically
shuck check --fix .

# Apply opt-in unsafe fixes too
shuck check --unsafe-fixes .

# Skip the cache
shuck check --no-cache .

# Override the cache location
shuck --cache-dir .tmp/shuck-cache check .
```

### Format

The formatter CLI is currently experimental, so set `SHUCK_EXPERIMENTAL=1` when invoking `shuck format`:

```sh
# Format files in place
SHUCK_EXPERIMENTAL=1 shuck format .

# Check formatting without modifying files
SHUCK_EXPERIMENTAL=1 shuck format --check .

# Show unified diffs for files that would change
SHUCK_EXPERIMENTAL=1 shuck format --diff .

# Format stdin and infer the dialect from a filename
printf 'foo(){\necho hi\n}\n' | SHUCK_EXPERIMENTAL=1 shuck format --stdin-filename script.bash -

# Override formatting options for a single run
SHUCK_EXPERIMENTAL=1 shuck format --indent-style space --indent-width 2 --function-next-line .
```

`shuck format` uses the same file discovery, exclusion, gitignore, project-root, and cache behavior as `shuck check`, but it only rewrites standalone shell files. Embedded GitHub Actions `run:` blocks are linted by `shuck check`; they are not rewritten by the formatter.

In `--check` and `--diff` modes, `shuck format` exits with status `1` when files would change and leaves the files untouched. Formatter parse errors exit with status `2`.

### Pre-commit

Use the binary-backed hook when you want pre-commit to install `shuck`
without requiring a local Rust toolchain:

```yaml
repos:
  - repo: https://github.com/ewhauser/shuck
    rev: v0.0.38
    hooks:
      - id: shuck
```

Replace `v0.0.38` with the release you want to pin.

If you would rather run from the checked-out Rust sources, or you are on a
platform that does not have a published wheel yet, use the source hook instead:

```yaml
repos:
  - repo: https://github.com/ewhauser/shuck
    rev: v0.0.38
    hooks:
      - id: shuck-src
```

The `shuck-src` hook requires a working Rust toolchain because it runs
`cargo run -p shuck-cli -- check ...` from the cloned hook repository.

### Clean caches

```sh
# Remove cache entries for the current project
shuck clean
```

### Editor integration

Shuck ships with a first-party Language Server Protocol (LSP) server in the main CLI. Editors and LSP clients should launch it over stdio:

```sh
shuck server
```

The server analyzes the editor's in-memory buffer, publishes diagnostics as you edit, and reuses the same parser, lint rules, formatter settings, configuration, and fix machinery as the CLI. It currently supports incremental document sync, real-time diagnostics, quick fixes, `source.fixAll.shuck`, disable-this-line actions, whole-document and range formatting, hover help for rule codes in `# shuck:` and `# shellcheck` directives, completion, go-to-definition, references, document highlights, call hierarchy (incoming and outgoing calls for functions, across files connected by `source` statements and `# shuck: source=` directives), document symbols, and workspace symbols.

Any editor that can launch a stdio LSP server can use Shuck by pointing shell buffers at `shuck server`. See the [editor integration guide](https://ewhauser.github.io/shuck/docs/editors/) for setup examples.

## Output

`shuck check` prints rich code-frame diagnostics by default:

```
warning[C001]: variable `tmp` is assigned but never used
 --> deploy.sh:14:1
  |
14 | tmp=$(mktemp)
  | ^^^
  |
```

Use `--output-format concise` to keep the legacy one-line format:

```
path:line:col: severity[CODE] message
```

```
deploy.sh:14:1: warning[C001] variable `tmp` is assigned but never used
deploy.sh:31:10: error[C006] undefined variable `DEPLY_ENV`
deploy.sh:45:3: warning[S005] legacy backtick command substitution
.github/workflows/ci.yml:12:11: warning[C001] jobs.test.steps[0].run: variable `summary` is assigned but never used
```

### Exit codes

| Code | Meaning |
|------|---------|
| `0`  | No issues found |
| `1`  | Lint violations or parse errors detected |
| `2`  | Runtime error (bad arguments, I/O failure) |

## Rules

Shuck ships with rules organized into five categories:

| Category | Prefix | Description |
|----------|--------|-------------|
| Correctness | C | Bugs, errors, and likely mistakes. Enabled by default. |
| Style | S | Code quality and best-practice suggestions. |
| Performance | P | Inefficient patterns that have simpler or faster alternatives. |
| Portability | X | Bash-isms and shell-specific constructs that break under POSIX or other shells. |
| Security | K | Potentially dangerous shell patterns such as risky deletion, unsafe evaluation, or local expansion. |

Each rule has a short code (e.g., `C006`, `S001`) that appears in diagnostics and can be used in suppression directives. Diagnostics are classified as error, warning, or hint depending on severity.

### ShellCheck compatibility

Where possible, shuck rules align with ShellCheck rules. Shuck supports ShellCheck suppression syntax (`# shellcheck disable=SC2086`) and maps ShellCheck codes to their shuck equivalents, so existing suppression comments continue to work without changes. Both suppression syntaxes accept either code namespace, and native `# shuck: disable=...` follows ShellCheck's scope rules: before the first statement it is file-wide, otherwise it applies to the next command.

That said, shuck is not a port of ShellCheck. It is a clean-room reimplementation built on its own parser and analysis engine, so results will sometimes differ:

- Shuck's parser and analysis logic were written from scratch. Edge cases may be handled differently, and some diagnostics may fire in slightly different locations or contexts.
- In cases where ShellCheck's behavior appears incorrect or inconsistent with shell semantics, shuck intentionally chooses correctness over compatibility.

Compatibility is continuously validated against a large corpus of shell scripts from popular open-source projects including [acme.sh](https://github.com/acmesh-official/acme.sh), [oh-my-zsh](https://github.com/ohmyzsh/ohmyzsh), [nvm](https://github.com/nvm-sh/nvm), [pyenv](https://github.com/pyenv/pyenv), [pi-hole](https://github.com/pi-hole/pi-hole), [bats-core](https://github.com/bats-core/bats-core), [powerlevel10k](https://github.com/romkatv/powerlevel10k), [dokku](https://github.com/dokku/dokku), [gentoo](https://github.com/gentoo/gentoo), and [many others](scripts/corpus-download.sh). The latest conformance report is published at [ewhauser.github.io/shuck/reports/corpus](https://ewhauser.github.io/shuck/reports/corpus/).

## Suppression

Suppress diagnostics with inline comments. Both native and ShellCheck-style directives are supported.

```sh
# Suppress a specific rule for the next command
# shuck:disable=C001
unused_var="ok"

# Suppress multiple rules
# shuck:disable=C001,S001
code_here

# Suppress for the entire file (place anywhere)
# shuck:disable-file=S001,S002

# ShellCheck-compatible syntax (also works)
# shellcheck disable=SC2034,SC2086

# Code aliases are interchangeable in either style
# shuck: disable=SC2086
# shellcheck disable=S001

# Before the first statement, disable becomes file-wide
# shuck: disable=S001
```

For embedded GitHub Actions scripts, put suppression comments inside the `run:` block as shell comments:

```yaml
- run: |
    # shellcheck disable=SC2086
    echo $FOO
```

YAML comments outside the `run:` scalar are not visible to the shell parser and do not suppress shell diagnostics.

## Sourced files

When a `source`/`.` path is computed (for example `source "$DIR/lib.sh"`), shuck
cannot resolve it statically and reports it as an untracked source. Point shuck
at the real file with a hint comment on (or just above) the `source` line:

```sh
# Import the file's definitions so references resolve, and stop the
# untracked-source warning. The file itself is not linted.
# shuck: source=lib/util.sh
source "$DIR/util.sh"

# Same, and also lint the target as an additional input.
# shuck: source=lib/util.sh lint=true
source "$DIR/util.sh"

# Nothing to include here; just silence the warning.
# shuck: source=/dev/null
source "$maybe_present"
```

The path is resolved relative to the annotating file's directory, then against
any configured `source-paths`; the nearest match wins. A `lint=true` target is
linted like a directly checked file: its own `source` statements are imported
for symbol resolution, and nested `lint=true` directives inside it are honored
transitively. The ShellCheck-compatible `# shellcheck source=<path>` directive
is recognized too.

Configure resolution and target linting in `.shuck.toml`:

```toml
[lint]
# Extra directories (relative to the project root) searched when resolving
# `# shuck: source=` directive targets.
source-paths = ["lib", "scripts"]

# When false, lint=true directives only import symbols; the targets are not
# linted. Default: true.
lint-sources = true
```

### Resolving a file that isn't next to the script

When the target lives somewhere the annotating file can't reach relatively — a
shared `lib/` while the script sits in `scripts/` — give the hint just the
**file name** and add its directory to `source-paths`:

```sh
# scripts/deploy.sh
# shuck: source=util.sh lint=true
source "$SHARED_DIR/util.sh"
greet   # defined in lib/util.sh
```

```toml
# .shuck.toml (at the project root)
[lint]
source-paths = ["lib"]
```

Resolution tries the hint against the annotating file's own directory first
(`scripts/util.sh`), then each `source-paths` root (`lib/util.sh` — found). The
hint may be a bare name (`util.sh`), a subpath (`net/http.sh`, joined onto each
root), or an absolute path (used as-is). `source-paths` roots are relative to
the project root; the token `SCRIPTDIR` means the annotating file's directory.
This works the same in `shuck check` and in the editor (LSP call hierarchy).

## Configuration

Project settings live in `.shuck.toml` or `shuck.toml`. Shuck walks up from each
input to find the nearest of these files and treats that directory as the project
root.

If no project config is found, Shuck falls back to a user-level global config at
`~/.config/shuck/shuck.toml` (or `.shuck.toml`), honoring `XDG_CONFIG_HOME` when
set. Set `SHUCK_CONFIG_HOME` to point at a different directory for the global
config. A project config always takes precedence over the global one, and
`--config <file>` or `--isolated` bypass global config entirely.

Use the `[check]` section to control embedded-script extraction:

```toml
[check]
# Lint supported embedded shell scripts in non-shell files such as
# GitHub Actions workflows and composite actions.
# Default: true
embedded = true

[lint]
# Override shell dialect inference for matching files.
per-file-shell = { "scripts/bash/**" = "bash", "vendor/**/*.sh" = "sh" }
# Add shell overrides on top of earlier config or CLI layers.
extend-per-file-shell = { "tools/**/*.zsh" = "zsh" }

[format]
# Configure `shuck format` and editor formatting.
exclude = ["generated/**"] # project-relative globs that should never be formatted
indent-style = "tab"       # tab | space
indent-width = 4           # used when indent-style = "space"
binary-next-line = false   # put binary operators on continuation lines
switch-case-indent = false # indent case branch bodies
space-redirects = false    # add spaces around redirection operators
keep-padding = false       # preserve safe horizontal padding
function-next-line = false # put function opening braces on their own line
never-split = false        # prefer compact layouts
```

`format.exclude` applies to directory discovery, explicitly named files, and
whole-document or range formatting requested through the language server. This
makes it suitable for generated files that must remain untouched even when an
editor has format-on-save enabled.

Formatter dialect is inferred from the file name or shebang. For stdin or one-off formatting runs, use `shuck format --dialect bash|posix|mksh|zsh`.

## File discovery

When given a directory, shuck recursively discovers standalone shell scripts by:

1. **Extension**: `.sh`, `.bash`, `.zsh`, `.ksh`, `.dash`, `.mksh`, `.bats`
2. **Shebang**: files starting with `#!/bin/bash`, `#!/usr/bin/env sh`, etc.

Shuck also discovers embedded shell in supported non-shell files:

1. **GitHub Actions workflows**: `.github/workflows/*.yml` and `.github/workflows/*.yaml`
2. **Composite actions**: `action.yml` and `action.yaml`

For GitHub Actions files, shuck lints `run:` blocks independently, remaps diagnostics back to the host YAML file, and includes the step path (for example `jobs.test.steps[0].run`) in the message. Steps that target unsupported shells such as PowerShell or `cmd` are skipped.

The following directories are skipped by default: `.git`, `.hg`, `.svn`, `.jj`, `.bzr`, `.cache`, `node_modules`, `vendor`, `.shuck_cache`.

Gitignore and `.ignore` files are respected by default. Use `--no-respect-gitignore` to disable.

## Caching

Shuck caches lint and format results per file in a shared cache root outside the project tree by default. The default location follows the OS cache directory convention, which is typically `~/Library/Caches/shuck` on macOS and `$XDG_CACHE_HOME/shuck` or `~/.cache/shuck` on Linux.

Override the cache root with `--cache-dir` or `SHUCK_CACHE_DIR`.

Disable caching with `--no-cache` or remove a project's cache entries with `shuck clean [PATH]`.

## Acknowledgements

Shuck builds on ideas and inspiration from several excellent open-source projects. This section is a thank-you to those communities — it does not imply endorsement, affiliation, or any formal relationship between shuck and these projects.

- **[bashkit](https://github.com/everruns/bashkit)** — shuck-parser was originally forked from bashkit's bash lexer and parser; it has since evolved substantially to meet the needs of a linter (comment and trivia preservation, error recovery, multi-dialect parse views, extended AST coverage).
- **[Ruff](https://github.com/astral-sh/ruff)** — Linter architecture inspiration, particularly around caching, rule organization, and diagnostic output.
- **[ShellCheck](https://github.com/koalaman/shellcheck)** — An amazing project and the original source of inspiration for shuck. ShellCheck set the standard for shell script analysis.
- **[gbash](https://github.com/ewhauser/gbash)** — A lot of lessons learned from this earlier project carried forward into shuck.

## License

MIT
