# Zsh Lint

> Review Zsh scripts for Bash-isms and non-idiomatic patterns, then suggest or apply Zsh-native rewrites

- Skill: `zplug/zsh-lint` (Agent Skill)
- Install (CLI): `npx skillmds@latest add zplug/zsh-lint`
- Raw SKILL.md: https://api.skillmd.com/api/skills/zplug/zsh-lint/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: zplug (https://skillmd.com/u/zplug)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/zplug/zsh-lint

---


# Zsh Idiom Linter

Review Zsh scripts and rewrite non-idiomatic patterns into proper Zsh style.
Detects Bash-isms, unnecessary external command usage, and missed Zsh-native features.

## Trigger

Activate when:

- User asks to "make this more Zsh-like", "review Zsh style", "rewrite in Zsh idioms"
- User pastes shell code and asks for improvement in a Zsh context
- User asks to remove Bash-isms from a script
- Working on `.zsh` files in this repository

## What to Check

### 1. External Commands Replaceable by Parameter Expansion

| Avoid | Prefer | Why |
|-------|--------|-----|
| `basename "$path"` | `${path:t}` | Zsh has `:t` (tail) modifier |
| `dirname "$path"` | `${path:h}` | Zsh has `:h` (head) modifier |
| `echo "$x" \| tr A-Z a-z` | `${(L)x}` | Expansion flag |
| `echo "$x" \| tr a-z A-Z` | `${(U)x}` | Expansion flag |
| `echo "$x" \| sed 's/a/b/g'` | `${x//a/b}` | Pattern substitution |
| `echo "$x" \| cut -d. -f1` | `${x%%.*}` | Suffix removal |
| `echo "$x" \| rev` | `${(j::)${(@Oa)${(s::)x}}}` | Only if simple; otherwise `rev` is fine |
| `echo "$x" \| wc -l` | `${(w)#${(f)x}}` or `${#${(f)x}}` | Line count via expansion |
| `echo "$x" \| sort -u` | `${(ou)${(f)x}}` | Sort + unique flags |
| `echo "$x" \| head -1` | `${${(f)x}[1]}` | Array indexing |
| `expr $a + $b` | `$(( a + b ))` | Arithmetic expansion |
| `seq 1 10` | `{1..10}` | Brace expansion |
| `cat file` | `$(<file)` | Zsh file read syntax |

### 2. Bash-isms to Avoid

| Bash-ism | Zsh Equivalent | Notes |
|----------|---------------|-------|
| `${arr[0]}` (first element) | `${arr[1]}` | Zsh is 1-indexed |
| `declare -a` | `typeset -a` | `typeset` is the Zsh convention |
| `declare -A` | `typeset -A` | Same |
| `$BASH_REMATCH` | `$MATCH` / `$match` | After `=~` |
| `${!var}` (indirect) | `${(P)var}` | Parameter expansion flag |
| `${!arr[@]}` (keys) | `${(k)arr}` | Key expansion flag |
| `read -a arr` | `read -A arr` | `-A` for array in Zsh |
| `[[ $x = *pattern* ]]` | `[[ $x == *pattern* ]]` | `==` preferred in Zsh |
| `function foo {` | `foo() {` | POSIX-style preferred; both work |
| `echo -e` | `print` | `print` supports escapes natively |
| `echo -n` | `print -n` | Or `print -rn --` for safety |
| `source file` | `source file` or `. file` | Both fine, but be consistent |
| `export VAR=val` | `export VAR=val` or `typeset -gx VAR=val` | Both fine |

### 3. Idiomatic Zsh Patterns

| Instead of | Prefer | Reason |
|------------|--------|--------|
| `if [ -n "$(ls dir)" ]` | `if (( $#dir_files )); ...` or glob qualifier | Avoid command substitution for file checks |
| `for f in $(ls *.txt)` | `for f in *.txt(N)` | `(N)` prevents error on no match |
| `test -d "$d" && cd "$d"` | `cd "$d" 2>/dev/null` or `[[ -d $d ]]` | `[[ ]]` preferred over `test`/`[ ]` |
| `cmd \| while read line` | `while read line; do ...; done < <(cmd)` or `for line in ${(f)$(cmd)}` | Avoid subshell from pipe |
| `local IFS=,; arr=($str)` | `arr=(${(s:,:)str})` | Split flag |
| `echo "$a" "$b" "$c"` | `print -r -- "$a" "$b" "$c"` | `print -r` avoids escape interpretation |
| `VAR=$(echo $x \| cmd1 \| cmd2)` | Chain expansions or single pipe | Reduce subshells |
| `[ $? -eq 0 ]` | Direct `if cmd; then` | Check exit status directly |
| `grep -q pat file && ...` | `[[ $(<file) == *pat* ]]` | For simple pattern checks on small files |
| `arr=($(echo $str))` | `arr=(${=str})` | `${=var}` does word splitting in Zsh |
| `wc -l < file` | `${#${(f)"$(<file)"}}` | Pure Zsh line count |

### 4. Quoting & Safety

| Issue | Fix |
|-------|-----|
| Unquoted `$var` in arguments | Usually safe in Zsh (no word splitting by default), but quote in `[[ ]]` RHS for literal match |
| `$(cmd)` with word split intent | Use `${(f)$(cmd)}` to split by lines, or `${=var}` for word split |
| Missing `emulate -L zsh` in public functions | Add for predictable behavior regardless of caller options |
| Missing `setopt LOCAL_OPTIONS` | Use when temporarily changing options inside a function |

### 5. Path/File Modifiers (Zsh-specific)

| Modifier | Meaning | Example |
|----------|---------|---------|
| `:t` | Tail (basename) | `${path:t}` → `file.txt` |
| `:h` | Head (dirname) | `${path:h}` → `/usr/local` |
| `:r` | Root (remove extension) | `${file:r}` → `archive.tar` |
| `:e` | Extension | `${file:e}` → `gz` |
| `:l` | Lowercase | `${var:l}` |
| `:u` | Uppercase | `${var:u}` |
| `:a` | Absolute path | `${path:a}` |
| `:A` | Absolute path with symlink resolution | `${path:A}` |
| `:gs/x/y/` | Global substitution | `${path:gs/./_/}` |

## Review Process

1. **Read** the target file or code
2. **Identify** patterns from the tables above
3. **Report** each finding with:
   - Line number and original code
   - Suggested Zsh-idiomatic replacement
   - Brief reason
4. **Categorize** severity:
   - **Error**: Will break or behave differently in Zsh (e.g., 0-indexed array access)
   - **Warning**: Works but not idiomatic (e.g., `basename` instead of `:t`)
   - **Style**: Minor preference (e.g., `declare` vs `typeset`)
5. **Offer to apply** the fixes automatically if the user agrees

## Output Format

```
## Zsh Lint: <filename>

### Errors
- **L42**: `${arr[0]}` → `${arr[1]}` — Zsh arrays are 1-indexed

### Warnings
- **L15**: `basename "$path"` → `${path:t}` — use Zsh path modifier
- **L28**: `echo "$x" | tr A-Z a-z` → `${(L)x}` — use expansion flag

### Style
- **L3**: `declare -a` → `typeset -a` — Zsh convention

### Summary
3 issues found (1 error, 2 warnings, 1 style)
```

When the user asks to fix (not just lint), apply changes directly using the Edit tool.

