๐ Zsh Lint
A Go-based semantic analyzer for Zshโ
zsh-lint is a standalone Go semantic analyzer for Zsh. It parses Zsh sources
with a real grammar front end and reports greppable diagnostics from its default
static-analysis rules.
z-shell/zsh-lintโ
Installationโ
go install github.com/z-shell/zsh-lint/cmd/zsh-lint@latest
Usageโ
zsh-lint path/to/file.zsh another.zsh
Diagnostics use the path:line:col: [rule-id] message format. Parse errors,
warnings, and errors produce a non-zero exit code.
For parser-gap corpus evaluation without static-analysis rules, use the dedicated survey command:
zsh-lint-survey path/to/file.zsh another.zsh
Referenceโ
zsh-lintโ
import "github.com/z-shell/zsh-lint/cmd/zsh-lint"
Command zsh-lint performs static analysis of Zsh shell scripts.
Indexโ
zsh-lint-surveyโ
import "github.com/z-shell/zsh-lint/cmd/zsh-lint-survey"
Command zsh-lint-survey runs the parser front end across Zsh files and reports parser gaps without evaluating static-analysis rules.
Indexโ
surveyโ
import "github.com/z-shell/zsh-lint/internal/survey"
Package survey runs the parser front end across a set of Zsh files and reports, per file, whether parsing succeeded. It produces greppable path:line:col: message diagnostics for failures and a one-line summary.
This is the reboot's parser-evaluation surface (issues #5, #8): it reports parser outcomes only and intentionally implements no lint rules yet.
Indexโ
func Runโ
func Run(names []string, w io.Writer) int
Run parses each file in names, writing per-file status and a summary to w.
Each file produces either an "OK <path>" line or a "FAIL <path>" line followed by a greppable "<path>:<line>:<col>: <message>" diagnostic. A final summary line reports the total number of files surveyed, ok, and failed.
It returns a process exit code: 0 if every file parsed, 1 otherwise.
rulesโ
import "github.com/z-shell/zsh-lint/internal/rules"
Indexโ
- func Default() []analyzer.Rule
- type Backquotes
- type EvalUsage
- type FuncDeclStyle
- type FunctionScopedOptions
- type PreferDoubleBrackets
- type SpecialParamShadow
- type UnquotedVar
func Defaultโ
func Default() []analyzer.Rule
Default returns the default set of static analysis rules.
type Backquotesโ
Backquotes reports grave-accent command substitutions.
ID: style/backquotes
Name: Prefer dollar-parenthesis command substitution
Summary: Reports the grave-accent form of command substitution in favor of $().
Why: The Zsh manual's Command Substitution section documents both $() and grave accents as supported forms. This rule prefers $() as the clearer, readily nestable modern idiom; it does not claim grave accents are invalid Zsh syntax. See https://zsh.sourceforge.io/Doc/Release/Expansion.html#Command-Substitution.
Bad:
current=`pwd`
Good:
current=$(pwd)
Severity: Hint. The forms are semantically supported; this is an idiom and readability preference rather than a correctness diagnostic.
False positives: A project may intentionally preserve historical style or mirror code shared with an environment where that spelling is required.
Suppression: Use # zsh-lint disable=style/backquotes -- <reason> on the finding line or immediately before the next non-comment, non-blank source line.
Corpus evidence: The June 12, 2026 LangZsh clean-baseline run produced zero findings from this rule across the 11 parseable corpus files. This grandfathered rule therefore has no positive corpus citation yet.
type Backquotes struct{}
func (Backquotes) Analyzeโ
func (r Backquotes) Analyze(ctx *analyzer.Context, node syntax.Node)
func (Backquotes) IDโ
func (r Backquotes) ID() diag.RuleID
func (Backquotes) Nameโ
func (r Backquotes) Name() string
type EvalUsageโ
EvalUsage reports calls to the eval shell builtin.
ID: security/eval
Name: Use of eval
Summary: Reports commands whose literal command name is eval.
Why: The Zsh manual documents that eval reads its arguments as shell input and executes the resulting commands in the current shell process. Dynamic or untrusted text can therefore become shell syntax rather than inert data. See https://zsh.sourceforge.io/Doc/Release/Shell-Builtin-Commands.html#index-eval.
Bad:
eval "print -r -- $user_input"
Good:
print -r -- "$user_input"
Severity: Info. Re-evaluating dynamic input is risky, but deliberate uses such as trusted code generation and compatibility shims are common enough that the pattern is not automatically a bug.
False positives: Static, maintainer-controlled command strings and deliberate shell-language adapters may require eval. Keep the finding visible unless the trust boundary is documented next to the call.
Suppression: Use # zsh-lint disable=security/eval -- <reason> on the finding line or immediately before the next non-comment, non-blank source line. A reason is strongly recommended for this security-category rule.
Corpus evidence: The June 12, 2026 LangZsh clean-baseline run produced zero findings from this rule across the 11 parseable corpus files. This grandfathered rule therefore has no positive corpus citation yet.
type EvalUsage struct{}
func (EvalUsage) Analyzeโ
func (r EvalUsage) Analyze(ctx *analyzer.Context, node syntax.Node)
func (EvalUsage) IDโ
func (r EvalUsage) ID() diag.RuleID
func (EvalUsage) Nameโ
func (r EvalUsage) Name() string
type FuncDeclStyleโ
FuncDeclStyle reports function declarations that combine function and ().
ID: style/function-decl
Name: Function declaration style
Summary: Reports declarations written as function name() instead of choosing either name() or function name.
Why: The Zsh manual's Complex Commands grammar documents the sh-compatible word () form and the Zsh function word form as alternatives. Combining both spellings is accepted but redundant, so choosing one form communicates the intended style more clearly. See https://zsh.sourceforge.io/Doc/Release/Shell-Grammar.html#Complex-Commands.
Bad:
function render() { print ok; }
Good:
render() { print ok; }
Severity: Hint. The mixed declaration is valid Zsh and the suggested change is stylistic.
False positives: Generated code or a project-wide convention may intentionally use the mixed spelling even though Zsh does not require it.
Suppression: Use # zsh-lint disable=style/function-decl -- <reason> on the finding line or immediately before the next non-comment, non-blank source line.
Corpus evidence: The June 12, 2026 LangZsh clean-baseline run produced zero findings from this rule across the 11 parseable corpus files. This grandfathered rule therefore has no positive corpus citation yet.
type FuncDeclStyle struct{}
func (FuncDeclStyle) Analyzeโ
func (r FuncDeclStyle) Analyze(ctx *analyzer.Context, node syntax.Node)
func (FuncDeclStyle) IDโ
func (r FuncDeclStyle) ID() diag.RuleID
func (FuncDeclStyle) Nameโ
func (r FuncDeclStyle) Name() string
type FunctionScopedOptionsโ
FunctionScopedOptions reports autoloadable function files that execute logic before localizing inherited shell options.
ID: plugin/function-scoped-options
Name: Function files should scope shell options
Summary: Reports executable files beneath a functions directory when neither builtin emulate -L zsh nor setopt local_options appears before the first non-guard top-level statement.
Why: Zsh functions inherit the caller's option state. The Zsh manual documents LOCAL_OPTIONS as restoring options on function return, while emulate -L both selects Zsh emulation and makes option changes local. Without either form, options such as SH_WORD_SPLIT, KSH_ARRAYS, or NO_EXTENDED_GLOB can silently change function behavior. See https://zsh.sourceforge.io/Doc/Release/Options.html#index-LOCAL_005fOPTIONS and https://zsh.sourceforge.io/Doc/Release/Shell-Builtin-Commands.html#index-emulate.
Bad:
local -a matches
matches=( $~pattern )
Good:
builtin emulate -L zsh
local -a matches
matches=( $~pattern )
Severity: Hint. Missing option scoping enables latent caller-dependent bugs, but some helper functions intentionally inspect or mutate caller option state.
False positives: Functions that intentionally share caller option state and trivial single-builtin functions remain findings by design; suppress them with a reason. Files outside a complete functions path segment are out of scope. A contiguous prefix of recognized condition || return or single-return if guards is accepted before scoping.
Suppression: Use # zsh-lint disable=plugin/function-scoped-options -- <reason> on the finding line or immediately before the next non-comment, non-blank source line.
Corpus evidence: Issue #63 reported missing scoping across z-a-meta-plugins and zsh-fancy-completions function files. The analyzer currently flags zsh-fancy-completions/functions/.completion-prediction and zsh-fancy-completions/functions/.force_rehash. The originally cited z-a-meta-plugins/functions/.za-meta-plugins-meta-cmd-help-handler is a fully commented-out stub and is correctly silent. Compliant setopt local_options and builtin emulate -L zsh examples exist in the same repositories.
type FunctionScopedOptions struct{}
func (FunctionScopedOptions) Analyzeโ
func (rule FunctionScopedOptions) Analyze(ctx *analyzer.Context, node syntax.Node)
func (FunctionScopedOptions) IDโ
func (FunctionScopedOptions) ID() diag.RuleID
func (FunctionScopedOptions) Nameโ
func (FunctionScopedOptions) Name() string
type PreferDoubleBracketsโ
PreferDoubleBrackets reports [ and test conditional commands.
ID: style/prefer-double-brackets
Name: Prefer [[ ]] over [ ] or test
Summary: Reports literal [ and test command names in favor of Zsh's [[ ... ]] compound command.
Why: The Zsh manual's Conditional Expressions section documents that expansions inside [[ ... ]] are constrained to one word and do not perform filename generation. With [ or test, normal command-line globbing can produce multiple words and confuse the test command's syntax. See https://zsh.sourceforge.io/Doc/Release/Conditional-Expressions.html.
Bad:
if [ "$name" = *.zsh ]; then print match; fi
Good:
if [[ $name = *.zsh ]]; then print match; fi
Severity: Hint. [ and test remain valid commands; the rule recommends the safer and more expressive Zsh-native conditional syntax.
False positives: Scripts intentionally kept portable across POSIX shells, or code that explicitly needs test command semantics, should retain the portable form and document the choice.
Suppression: Use # zsh-lint disable=style/prefer-double-brackets -- <reason> on the finding line or immediately before the next non-comment, non-blank source line.
Corpus evidence: The June 12, 2026 LangZsh clean-baseline run produced zero findings from this rule across the 11 parseable corpus files. This grandfathered rule therefore has no positive corpus citation yet.
type PreferDoubleBrackets struct{}
func (PreferDoubleBrackets) Analyzeโ
func (r PreferDoubleBrackets) Analyze(ctx *analyzer.Context, node syntax.Node)
func (PreferDoubleBrackets) IDโ
func (r PreferDoubleBrackets) ID() diag.RuleID
func (PreferDoubleBrackets) Nameโ
func (r PreferDoubleBrackets) Name() string
type SpecialParamShadowโ
SpecialParamShadow reports declarations that shadow parameters set by the shell.
ID: compat/special-param-shadow
Name: Shadowing special shell parameters
Summary: Reports local, typeset, declare, or readonly declarations of a curated set of shell-set parameters (for example ZSH_VERSION, OSTYPE, and pipestatus); explicit non-local -g declarations (including readonly -g), the export builtin, and typeset-family query/display/function modes (standalone +, -p/+p, +m, and -f/+f) are excluded. Pattern declarations with -m are reported only when an effective +g makes matching non-local parameters local. The rule also stays silent when dynamic option words or the ambient GLOBAL_EXPORT option make declaration scope uncertain. Reads are never reported.
Why: The Zsh manual's zshparam "Parameters Set By The Shell" section documents these as shell-provided state. In a function, readonly is typeset -r and creates a local binding unless -g explicitly selects non-local behavior. Version probes such as is-at-least $ZSH_VERSION are pervasive in plugin code, so a local ZSH_VERSION=... in a caller silently feeds the override to all nested code for the lifetime of the scope. At top level, the same declaration form clobbers the shell-managed outer binding instead of creating a temporary local. Unlike ordinary shadowing, the reader has no declaration of the original to look up -- the shell set it. See https://zsh.sourceforge.io/Doc/Release/Parameters.html#Parameters-Set-By-The-Shell.
Bad:
compile_zsh() {
local ZSH_VERSION="$1"
}
Good:
compile_zsh() {
local target_zsh_version="$1"
}
Severity: Warning. The pattern is functional but misleading and can change what nested code observes; deliberate compatibility shims are realistic, so it is suppressible rather than an error.
False positives: Deliberate compatibility shims or test harnesses that fake ZSH_VERSION or OSTYPE for downstream code are the rule's target behavior made intentional; suppress them with a reason. The rule flags only a curated allowlist of read-mostly shell-set parameters and stays silent for reads and the conventionally mutated path, fpath, PATH, REPLY, and match. The shell-special status and pipestatus cases remain included because local -h status=... and local -h pipestatus=(...) can replace their special behavior with ordinary local bindings.
Suppression: Use # zsh-lint disable=compat/special-param-shadow -- <reason> on the finding line or immediately before the next non-comment, non-blank source line.
Corpus evidence: Issue #64 records zd/docker/utils.zsh:78: local ZSH_VERSION="$1" inside a helper that takes a target version as its first argument. Deferred issue #72 tracks bare assignments in function scope and integer/float declarations; both are out of scope for this rule version.
type SpecialParamShadow struct{}
func (SpecialParamShadow) Analyzeโ
func (rule SpecialParamShadow) Analyze(ctx *analyzer.Context, node syntax.Node)
func (SpecialParamShadow) IDโ
func (SpecialParamShadow) ID() diag.RuleID
func (SpecialParamShadow) Nameโ
func (SpecialParamShadow) Name() string
type UnquotedVarโ
UnquotedVar reports direct, unquoted parameter expansions in command words.
ID: quoting/unquoted-var
Name: Unquoted variable expansion
Summary: Reports parameter expansions in command names or arguments that are not enclosed in double quotes.
Why: The Zsh manual's Parameter Expansion section explains that unquoted parameters are not split on whitespace by default, unlike in sh, but null words are still elided; enabling SH_WORD_SPLIT also makes unquoted values subject to field splitting. Double quotes preserve an empty scalar as an argument and keep the expansion single-word under either option state. See https://zsh.sourceforge.io/Doc/Release/Expansion.html#Parameter-Expansion.
Bad:
print -r -- $value
Good:
print -r -- "$value"
Severity: Warning. Losing an empty argument or inheriting SH_WORD_SPLIT can change command behavior, while intentional elision remains realistic.
False positives: Code may intentionally omit an empty argument, deliberately rely on SH_WORD_SPLIT, or expand a value guaranteed to be non-empty. Those cases should use a reasoned suppression rather than weakening unrelated diagnostics.
Suppression: Use # zsh-lint disable=quoting/unquoted-var -- <reason> on the finding line or immediately before the next non-comment, non-blank source line.
Corpus evidence: The June 12, 2026 LangZsh clean-baseline run produced zero findings from this rule across the 11 parseable corpus files. This grandfathered rule therefore has no positive corpus citation yet.
type UnquotedVar struct{}
func (UnquotedVar) Analyzeโ
func (r UnquotedVar) Analyze(ctx *analyzer.Context, node syntax.Node)
func (UnquotedVar) IDโ
func (r UnquotedVar) ID() diag.RuleID
func (UnquotedVar) Nameโ
func (r UnquotedVar) Name() string
Generated by gomarkdoc