This README describes the development version on
main, which can be ahead of the release. The released version is on the Marketplace and Open VSX pages, and its source, README included, is at the tag of the latest release.
Explains shell scripts line by line — like regex101, but for bash.
Hover any part of a line and see what it does — every command, option, redirection and ${…} — with worked examples you can edit. Useful for reading unfamiliar scripts, learning the shell, and teaching it.
No setup, and offline by design: no network, no AI, no telemetry. In English and Japanese.
Bash Explainer is a VS Code extension, and more: the same explanations and warnings also come as a web page you can try without installing anything, a command-line tool, a Language Server for other editors, and a GitHub Action that checks scripts in pull requests.
| Where | How |
|---|---|
| VS Code | Install Bash Explainer from the Marketplace, open a shell script and hover a word. See In VS Code. |
| VSCodium and other editors using Open VSX | open-vsx.org/extension/shun-fujioka/bash-explainer |
| vscode.dev and github.dev | The same extension works in VS Code for the Web (press . in a GitHub repository). |
| Browser, no installation | The web version: paste a script or open a file (Ctrl+O, or drop it on the editor), move the cursor, or hover a word for its card, as in VS Code. See The web page. The development version is built from main and may differ from the release. |
| Terminal | npx bash-explainer deploy.sh:12, or npm install --global bash-explainer. See cli/README.md. |
| Neovim, Helix, Emacs and other LSP editors | bash-explainer --lsp, a Language Server with the same hovers, warnings and quick fixes. See In your editor. |
| CI | uses: sf624/bash-explainer@v0 in GitHub Actions puts the warnings on the pull request's diff; elsewhere, npx bash-explainer --check. See In CI. |
| You write | Bash Explainer tells you |
|---|---|
tar -czf backup.tgz -C src . |
what -c, -z, -f and -C src each do |
cmd 2>&1 > out.log |
where stdout and stderr end up — and that errors do not go to the file here |
echo "${file%.*}" |
file=report.tar.gz → report.tar |
sed -E 's/^([a-z]+)=(.*)$/\2=\1/' |
the regex piece by piece, and a sample: a=x → x=a |
#!/bin/sh … [[ -f $f ]] |
ⓧ [[ ]] fails in dash — with a quick fix to [ -f "$f" ] |
RUN apt-get update && … in a Dockerfile |
the same explanations inside Dockerfiles, Makefiles, CI YAML, Jenkinsfiles, package.json, Python and bats tests |
| Examples | |
|---|---|
| Commands and options | about 330 commands and builtins (grep, sed, awk, find, curl, tar, git, docker, apt-get, gpg, systemctl, kubectl, helm, terraform, aws, go, …); combined flags (-rn), values (-o pipefail, --include=*.sh), subcommands (git commit), commands run by others (sudo, xargs, find -exec) |
| Operators and redirections | |, &&, ||, ;, &, >, >>, 2>&1, &>, here-documents, here-strings — including what the order of redirections does |
| Expansions | ${var:-default}, ${file%.sh}, ${#array[@]}, $?, $@, $(…), <(…), $((…)), broken into their parts |
| Languages inside arguments | regular expressions (BRE, ERE or PCRE, as the options say), sed scripts, awk programs, jq and yq filters, perl one-liners, printf and date formats, find expressions, chmod modes, sort -k / cut -f / tr sets, getopts / getopt option strings, git log --pretty / --date |
| Control flow | if, for, while, case, functions; lines inside a block show where they are (for loop › if statement) |
| Idioms and pitfalls | IFS= read -r, the script-directory idiom, strict mode, trap … EXIT, command -v … >/dev/null 2>&1, …; and what goes wrong in x = 1, rm -rf $DIR/… with an empty DIR, unquoted variables, for f in $(ls), read without -r, local x=$(cmd), sudo cmd > /etc/… |
Many constructs come with a made-up input and the output it produces. The built-in runners are checked against the real tools (GNU sed and coreutils, gawk, jq, perl, bash, git, yq) in the tests.
| For | You see |
|---|---|
grep patterns, [[ =~ ]], globs, case patterns |
strings that match and strings that do not |
sed, awk, jq, yq, perl, sort, uniq, cut, tr, head, tail |
sample input → output |
| whole pipelines | the output after each stage |
${…} expansions, printf, brace expansion |
the resulting value |
[ ], [[ ]], (( )) |
values that make the test true and false |
xargs, read, getopt |
the commands that run, the variables that get set, how arguments are rearranged |
git log --pretty / --date |
the output for sample commits |
date +FORMAT |
the output at two fixed moments; edit them in the panel |
Examples appear right under the part they illustrate (see bashExplainer.examples.placement).
Explanations follow bash. When a script is written for another shell, Bash Explainer also points out what that shell does not have, and what behaves differently there:
#!/bin/sh
[[ -n $name ]] && echo -e "hi\t$name"
# ⓧ Error: `[[ ]]` is not POSIX sh (it fails in dash). Instead, use `[ ]`.
# ⚠ Portability: `echo -e` is not POSIX sh (dash's `echo` prints `-e` as text). Instead, use `printf` …
# ⓘ Portability: dash's `echo` interprets `\t`, `\n` and the like in its arguments …- Shells: POSIX
sh(naming what fails in dash and busybox ash),dash,ash,yash,ksh,mksh,zsh. What works where was measured with each shell. - Which shell: from the shebang (
#!/bin/sh,#!/usr/bin/env dash, …), a# shellcheck shell=line or the file name — or setbashExplainer.shell. In a Markdown code block, a shebang wins over the fence's tag; a block taggedshwithout one is read as bash (write#!/bin/shin it to check it as POSIX sh), andbash,zsh,dash,kshkeep their shell.
| File | What is explained |
|---|---|
| Dockerfile | RUN / CMD / ENTRYPOINT (shell form, RUN <<EOF, exec form, ["sh", "-c", "…"]) and SHELL [...]; warns when a RUN pipe hides failures without pipefail |
| Makefile | recipes, with make's $(VAR), $@ and $$ explained |
| CI YAML | GitHub Actions run: (workflows and action.yml, following shell: and defaults), GitLab CI script:, CircleCI run: / command:, and Azure Pipelines script: / bash: with its $(variable) macros; warns when untrusted input like ${{ github.event.pull_request.title }} is pasted into a script |
package.json |
scripts |
| Markdown | code blocks marked sh, bash, console, …; the shell is the block's shebang, else the one the tag names (sh counts as bash) |
| Python | subprocess.run(…), os.system(…), …; flags strings passed without shell=True and values pasted into a shell command |
| Jenkinsfile | sh '…', sh "…", sh '''…''', sh(script: …); points out the $name and ${…} that Groovy fills in before the shell runs |
bats (.bats, or a bats shebang) |
the whole file as bash: @test "…" { as the test bats runs with set -e, and run (with !, -N, --separate-stderr), load, skip, bats_pipe; warns about ! cmd that cannot fail a test and run in a pipeline |
Each snippet is checked for the shell that runs it (/bin/sh for Dockerfiles and Makefiles unless SHELL says otherwise). Choose which kinds of files with bashExplainer.embedded.files.
Three different tools are called yq, each with its own syntax: mikefarah/yq v4, kislyuk/yq (a jq wrapper) and the older mikefarah/yq v3. Bash Explainer tells which one a command is for — from its options, subcommands and functions, and from how yq is installed in the same file — and says so on the line:
yq: explained as mikefarah/yq v4 (v4.22.1 or later). Because:
-o jsonis a mikefarah/yq v4 option;pickis a mikefarah/yq function (jq has none) Works with: mikefarah/yq v4 (v4.22.1 or later) ✓ / kislyuk/yq ✗ (no-o json,pick) / mikefarah/yq v3 ✗ (no jq-style expressions,-o json)
The first release with each feature was measured on every 4.x minor release. When nothing tells, mikefarah/yq v4 is assumed; set bashExplainer.yq.flavor to fix the choice.
Warnings appear in VS Code's Problems panel, so you see them without hovering, and the same warnings come from the command-line tool (--check), the Language Server and the GitHub Action. Where a rewrite keeps the meaning, a quick fix is offered:
| Before | After |
|---|---|
[[ -f $f && -n $x ]] |
[ -f "$f" ] && [ -n "$x" ] |
[ "$a" == b ] |
[ "$a" = b ] |
source ./lib.sh |
. ./lib.sh |
function f { |
f() { |
cmd &> log |
cmd > log 2>&1 |
cmd |& tee log |
cmd 2>&1 | tee log |
cp a b || echo failed (last in a script) |
cp a b || { echo failed >&2; exit 1; } |
source lib.sh |
source "$(dirname "${BASH_SOURCE[0]}")"/lib.sh |
grep "$user" f |
grep -F "$user" f |
read line |
read -r line |
For a construct the script's shell lacks, a fix is offered only when the meaning is kept: [[ $f == *.txt ]] is a pattern match, so it gets no fix. Each such rewrite is checked to behave in dash as the original does in bash. Where a warning or caveat shows the code to write, that code is a quick fix too (both, when it shows two: the one it recommends first). Each of these rewrites is run in bash 5.2 and 5.3 by the tests. To silence a warning, add # bash-explainer disable=<code> on the line before it (or after the code on the same line), # bash-explainer disable-file=<code> anywhere for the whole file, or list the code in bashExplainer.diagnostics.ignore; all matches every warning. Turn the Problems panel entries off with bashExplainer.diagnostics.enabled.
Problems have three levels, as VS Code's own: ⓧ Error (red) for a line that fails every time as written ([ without ], x = 1, a construct the script's shell lacks, then with no ; before it, $((09))), ⚠ Warning (yellow) for damage that cannot be undone under some values or conditions (rm -rf $DIR/* with an empty DIR, rm $f, curl … | sh), and ⓘ Information (blue) for what goes wrong only with some values or on some systems, or goes unnoticed (for f in $(ls), cat $f, read without -r, local x=$(cmd)). The explanations (in the panel, the hover, the terminal and on the web page) show each level with its own icon, colour and word ("Error:", "Pitfall:", "Caveat:"), errors first; the Problems lists show its icon. Every error, warning and caveat the explanations point out is a problem too, each with its code; a plain fact is not. bashExplainer.diagnostics.pitfalls leaves the warnings and caveats out, not the errors. --check and the GitHub Action fail on an error, on a warning only with --strict, and never on information. So by default they catch a construct the script's shell lacks only where it surely fails: what the shell refuses while it reads the file (a=(1), <<<, also in a function never called), and a command it surely gets to. In a function, in a branch, or after a command that may stop the script (. ./lib, an exit in a function), the construct may never run, so it is a warning: --strict is what catches a bashism there. Nothing is said after a command that surely stops the script (exit 0, exec with a command); after one that only may stop it (source, trap, an exit in a function), what says the shell reads the text wrongly (x = 1, [-f x], a syntax error) is a warning.
- Install Bash Explainer from the Visual Studio Marketplace.
- Open a shell script (
.sh,.bash, …). - Hover a word to see what it is. For the whole line, put the cursor on it and press
Ctrl+K Ctrl+E(Cmd+K Cmd+Eon macOS), or click Whole line in the hover.
For a side pane that follows the cursor, click Open in panel at the bottom of the hover.
- Marketplace: shun-fujioka.bash-explainer
- In VS Code: open the Extensions view (
Ctrl+Shift+X/Cmd+Shift+X) and search for Bash Explainer, or runext install shun-fujioka.bash-explainerfrom Quick Open (Ctrl+P/Cmd+P). - Command line:
code --install-extension shun-fujioka.bash-explainer - VSCodium and other editors using Open VSX: open-vsx.org/extension/shun-fujioka/bash-explainer
- VS Code for the Web: the extension also works on vscode.dev and github.dev (press
.in a GitHub repository).
Requires VS Code 1.85 or later. There are no native dependencies (the parser is plain JavaScript), so it runs wherever VS Code runs.
| How | Default | |
|---|---|---|
| Panel | A side pane that follows the cursor, colour-coded like regex101, where you can edit the example input and watch the result change | open it from the editor title bar or the hover |
| Hover | Hover a word for what it is; press Ctrl+K Ctrl+E for the whole cursor line |
on |
| Name hints | A hint after a command name that does not run what it suggests (cd that is a function, sudo cd) |
on |
Turn the hover and the panel on or off by clicking Bash Explainer in the status bar; the name hints with bashExplainer.hints.names.
The panel follows the cursor line by line. Point at a part of the tree to highlight it in the editor.
Hover a word, an option or an operator to see what it is, right where you are reading: its meaning, what kind of command a name is (a badge: keyword, builtin, special builtin, function, alias, program) and who runs it, and the command it belongs to. Moving to the next word explains that one. In the indentation, the hover shows the line's structure in one line.
For the whole line, press Ctrl+K Ctrl+E, choose Explain Bash on Current Line from the context menu, or click Whole line in the hover: one block per command, with its arguments.
The web page's editor shows the same cards (not on a touch screen); there, Ctrl+K Ctrl+E and Whole line explain the line in the panel.
A command name gets a faint hint after it where it does not run what a reader would guess: a function or an alias that hides a builtin or a program, a builtin turned off by enable -n, an alias that bash does not expand in a script, sudo cd (there is no cd program), a name that runs nothing. Hover the hint for what runs and what else the name is; click it to open the line in the panel. The web page shows the same hints in its editor. VS Code's editor.inlayHints.enabled turns all inlay hints on or off (or on while Ctrl+Alt is held).
The inline summary at the end of the cursor line was removed: bashExplainer.inline.* no longer has an effect.
The panel works like regex101:
- Edit the examples. Change the input of a worked example — grep and
[[ =~ ]]test strings, sed, awk, jq, perl,sort,cut,xargs, whole pipelines — and the output updates as you type. - See what matched. Matches are highlighted in the input, each capture group in its own colour, the same colour as the group in the explanation; a table shows what
\1,\2, … captured. - See what each command name is. At both depths a badge after the name says keyword, builtin, special builtin, function, alias or program, and who runs it (
sudo echo: the program, run bysudo). A name that is also something else gets a line saying what, and the code that runs it (timeis also the program/usr/bin/time:command time). - Choose how deep to read with Depth, or the keys 1 and 2 when the focus is in the panel; it remembers the depth. 1 What it does says what each command does, under one sentence for the line and its problems, each with a fix to copy. 2 How bash reads it adds, to the same rows and in the same order, bash's own reserved words and operators, and the parts of each word and redirection. Each row gets one line that says what the part is to each reader and who reads it (
argument → value of -f · tar reads it); press or tap a name on it to read what that kind is, with its sections in the manual. A pipe says what goes where (stdout of grep → stdin of sort). A word that expands shows bash's steps in bash's order (parameter expansion → no word splitting or globbing (quoted) → quote removal); where no step acts, they fold to one line. A subshell parses the text of each$( )again, and the string ofbash -c,evalor a trap is read as a script again; each command reads its own arguments, and some words hold a language of their own (a regex, a sed script). Rows with only such parts under them start folded. - See what each program receives with the switch under the line box, or the key W: on by default, it shows under each command its words after bash's expansions, tints those received as written, and marks what bash handles itself. A quiet mark says what is not portable to sh; Key lists the marks on screen.
- Switch the shell or yq for the current file from the header, and see the explanation and warnings change.
- Fold the explanation tree, copy it as Markdown for a review or a document, and open the searchable quick reference of expansions, redirections, tests, variables and commands. On the web page, Collapse, Copy and the tab Reference are in the explanation's own header, beside Depth; on a phone held upright, Copy is in the menu and Collapse is left out. The quick reference does not move the focus to its search field on a touch screen, so the on-screen keyboard stays down.
| Command | Shortcut / where |
|---|---|
| Bash Explainer: Explain Bash on Current Line | Ctrl+K Ctrl+E / Cmd+K Cmd+E |
| Bash Explainer: Open Bash Explanation Panel | the editor title bar, the hover |
| Bash Explainer: Turn Hover and Panel On or Off | the status bar item |
| Bash Explainer: Open Settings | the hover and the panel |
| Setting | Default | Description |
|---|---|---|
| Display | ||
bashExplainer.hover.enabled |
true |
Hover for the part under the mouse (Ctrl+K Ctrl+E, the whole line, works either way) |
bashExplainer.hover.followCursor |
false |
Show the hover whenever the cursor moves |
bashExplainer.hover.followDelay |
400 |
Delay (ms) before it does |
bashExplainer.hover.maxDepth |
0 |
Levels of the explanation tree the hover shows (0: all) |
bashExplainer.panel.autoOpen |
false |
Open the panel when a shell script is opened |
bashExplainer.panel.animations |
true |
Rows of the panel, bash's steps, "More" and the examples grow and shrink when they are unfolded and folded (off: they appear at once; reduced motion turns it off too) |
bashExplainer.panel.colors |
"classic" |
Colours of the parts of a line in the panel: classic (its own six), theme (the editor theme's, where readable), themeLike (the theme's, with new colours where the theme gives two kinds of part one colour). In a remote window, every theme when the extension is installed on the local side (where VS Code installs it); VS Code's default themes only when it runs on the remote side. VS Code only |
bashExplainer.highlight.enabled |
true |
Colour shell code as the explanations read it (semantic tokens over the editor's colours): what each command name runs, options, readonly variables. Can be set per language (below); the web page always does it |
bashExplainer.hints.names |
true |
A hint after a command name that does not run what it suggests |
bashExplainer.hints.deadCode |
true |
Fade code that never runs (a branch whose condition the script has decided, the skipped side of && or ` |
bashExplainer.inline.enabled, bashExplainer.inline.maxLength |
Removed: no longer have an effect | |
bashExplainer.examples.enabled |
true |
Show worked examples at all |
bashExplainer.examples.disabled |
[] |
Kinds of examples not to show: regex, sed, awk, jq, yq, perl, textFilters, pipeline, expansion, printf, glob, test, xargs, read, getopt, gitLog, date |
bashExplainer.examples.inHover |
true |
Show examples in the hover too (off: only in the panel) |
bashExplainer.examples.placement |
first |
Worked examples under the part they illustrate, before (first) or after (last) its pieces, or all at the end (bottom) |
bashExplainer.language |
auto |
auto (follow VS Code), en or ja |
bashExplainer.notes.idioms |
true |
Notes about well-known idioms (strict mode, IFS= read -r…) |
bashExplainer.notes.pitfalls |
true |
Point out common mistakes and how to avoid them (unquoted variables, for f in $(ls), rm -rf $DIR/……); errors (x = 1) are pointed out either way |
bashExplainer.notes.basis |
true |
Mark a note, or the explanation of a part, that rests on bash's behaviour rather than the manual ("Not in the manual · checked with bash 5.2"), with a command to try it in your terminal |
| Checks | ||
bashExplainer.shell |
auto |
The shell scripts are written for: auto, bash, sh, dash, ash, yash, ksh, mksh, zsh |
bashExplainer.bashVersion |
5.2 |
The bash release to explain for: 5.2 or 5.3. A construct of bash 5.3 (${ cmd; }) is explained as 5.3 runs it, and is an error with 5.2 |
bashExplainer.diagnostics.enabled |
true |
Errors and warnings in the Problems panel, with quick fixes |
bashExplainer.diagnostics.ignore |
[] |
Warning codes to leave out (the code shown in the Problems panel, e.g. dbracket, echoE, dockerPipefail) |
bashExplainer.diagnostics.embedded |
true |
Also warn about shell code inside other files |
bashExplainer.diagnostics.pitfalls |
true |
Also list the warnings and caveats of the explanations in the Problems panel (rm -rf $DIR/*, rm $f: warnings; cat $f, read line: information); their errors ([ without ], x = 1) are listed either way |
bashExplainer.yq.flavor |
auto |
auto, mikefarah4, kislyuk or mikefarah3 |
| Files | ||
bashExplainer.embedded.files |
all | Files whose shell code is explained: dockerfile, makefile, yaml, packageJson, markdown, python, jenkinsfile, bats |
bashExplainer.languages |
["shellscript"] |
Language IDs explained as shell scripts |
| Dictionary | ||
bashExplainer.customCommands |
{} |
Describe commands the dictionary does not know (see below) |
The colours are on for shell scripts and the shell code of other files (not Python files and Dockerfiles, which their own language servers colour). To turn them off for one kind of file, set the setting for its language in settings.json:
"[makefile]": { "bashExplainer.highlight.enabled": false },
"[yaml]": { "bashExplainer.highlight.enabled": false }The languages are those VS Code shows in the status bar: shellscript, bats, makefile, yaml (GitLab CI, and GitHub workflows without the GitHub Actions extension), github-actions-workflow, json (package.json), markdown, groovy and jenkinsfile. "bashExplainer.highlight.enabled": false alone turns them off everywhere.
In-house tools are shown as external commands until you describe them:
"bashExplainer.customCommands": {
"deploy": {
"summary": "Deploy a service to production",
"options": [
{ "names": ["-n", "--dry-run"], "description": "only show what would happen" },
{ "names": ["-e"], "description": "target environment", "takesValue": true }
],
"operands": [{ "description": "service name", "repeat": "+" }],
"subcommands": {
"status": { "summary": "Show what is deployed", "operands": [{ "description": "service name", "repeat": "*" }] }
}
}
}options: each option's names, its description, andtakesValuewhen it takes a value (-e prod,-eprod,--env=prod).operands: the words that are not options, in order.repeatis?(optional),*(any number) or+(one or more); without it, the operand is there exactly once. When the words do not fit, they are shown as plain words.style:gnu(the default: options may come after operands),posix(the first operand ends the options) orsingle-dash(long options with one dash,-name value).subcommands: the same fields, for each subcommand.
The shape before 0.7 (options as an object, "-n|--dry-run": "…", and arguments, a list of texts of which the last repeats) is still read, and a later release drops it.
The web version opens with a sample script. Type or paste a script, or open a file, and put the cursor on a line: its explanation shows beside the editor, below it in a narrow window, or in a sheet over it on a phone or a tablet held upright. Nothing is installed or uploaded (Privacy). Along the top is the app bar, with the menu (☰) and the settings (⚙).
- Files: Open and Save (
Ctrl+O,Ctrl+S) are in the editor's header, or in the menu (☰) where the explanation is a sheet. A file can also be dropped on the editor. Where the header has room for the file's name, a click on it renames the file. - File type and shell: the small button in the editor's header (the chip) shows both, shortened where there is little room, to the shell alone in the narrowest windows. Click it to choose. Auto reads the shell from
#!. - Problems are listed under the editor (in a tab of the sheet); in a narrow window the list starts folded, and a click on its title opens it. Click a problem to go to its line, or press
F8andShift+F8for the next or previous one, folded or not. - Sharing: Copy a link (in the app bar, or in the menu where the explanation is not beside the editor) puts the script in the part of the link after
#, which browsers do not send to a server. - Settings (⚙) open as a column at the right where the explanation is beside the editor, and over the whole screen elsewhere. A change applies at once (details).
- Keys:
F6andShift+F6move the focus between the editor and the explanation;Escape, thenTab, leaves the editor. The menu's Help lists more keys. - Touch screens: Read and Edit above the editor choose whether a tap on a line raises the keyboard.
The same settings work on the web page, in the command-line tool and in the Language Server, wherever they have a meaning:
Settings (bashExplainer.…) |
Web page | Command line | Language Server |
|---|---|---|---|
language, notes.idioms, notes.pitfalls, notes.basis, shell, bashVersion, yq.flavor, diagnostics.ignore, diagnostics.embedded, diagnostics.pitfalls, examples.enabled, examples.disabled, customCommands |
✓ | ✓ | ✓ |
diagnostics.enabled |
✓ | (--check asks for the warnings) |
✓ |
hover.enabled, hints.names, hints.deadCode |
✓ | ✓ | |
examples.placement, panel.animations |
✓ | ||
editor.wrap (the web page only: it is not in VS Code's settings, which has editor.wordWrap) |
✓ | ||
examples.inHover, hover.maxDepth, highlight.enabled, languages, embedded.files |
✓ | ||
hover.followCursor, hover.followDelay, panel.autoOpen, panel.colors |
The empty cells are about what only VS Code has (the hover's depth and examples, a hover that follows the cursor, a panel to open, files to choose); the web page and the command line explain the file they are given, as the kind you choose.
- Web page: click ⚙ in the app bar. The shell and yq are also chosen with the chip in the editor's header that shows the file type and the shell, or the menu's File type and shell…; its yq list shows only when the cursor's line runs yq. Changes apply at once, and are kept in your browser (localStorage); Reset to defaults puts the settings back, and Clear stored data (after a confirmation) removes everything the page keeps in the browser: the settings and the choices it remembers besides them (theme, pane sizes, depth, the “What the program receives” switch, read mode, closed hints). The defaults show at once; a hint you closed shows again at the next load. The shell and yq chosen in the chip are the dialog's Shell and yq flavor settings, and the language chosen in the menu is its Language setting. Export to a file (or Copy) gives the settings you changed as a settings file in the format below, for the command-line tool or VS Code's
settings.json; Import a file… (or dropping a file on the dialog, or pasting JSON) replaces the page's settings with those of such a file, and lists the keys it left out. - Command line and Language Server: a JSON file with the keys of VS Code's
settings.json, with or withoutbashExplainer.(comments are allowed). It is the file given with--config, else$BASH_EXPLAINER_CONFIG, else$XDG_CONFIG_HOME/bash-explainer/settings.json(by default~/.config/bash-explainer/settings.json).--set key=valuesets one setting. See Settings in the tool's README.
{
"bashExplainer.language": "ja",
"bashExplainer.shell": "sh",
"bashExplainer.diagnostics.ignore": ["crlf"],
"bashExplainer.examples.disabled": ["date"],
"bashExplainer.customCommands": {
"deploy": { "summary": "Deploy a service to production" }
}
}The command-line tool, the Language Server and the GitHub Action are one npm package, bash-explainer (npx bash-explainer, or npm install --global bash-explainer). Its README covers the options, the output formats (text, Markdown, JSON, SARIF, GitHub annotations), the set-up for Neovim, Helix and Emacs, and the action's inputs.
- Explanations are rule-based. Commands that are not in the dictionary are shown as an external command without option details.
- A few constructs are not read as bash reads them: zsh-only syntax such as
*(.)and${(f)var}(with the shell set to zsh, such a line is marked as not parsed),echo ((bash: syntax error; it is accepted without an error), an alias that stands for a declaration builtin (afteralias d=declare, bash readsd a=(1 2)asdeclarewould, andd a=$vdoes not split$v; here the first is a syntax error and the second an argument that is split), a declaration aftercommandin POSIX mode (set -o posix, sh: bash and dash do not split$vincommand declare a=$vorcommand export a=$v, and dash not in\export a=$veither; here it is split, as in bash's default mode), the base-64 digit@in arithmetic next to other terms ($((64#@))alone is read as 62, but in$((64#@ + 1))the whole expression becomes one word), and a process substitution that runs a subshell (<((…))) attached to other text of its word, before or after it (bash readsa<((ls)),<((ls))bandx=<((ls))as one word each; here the substitution is a word of its own). In a[ ], the arguments are grouped by bash's own rules fortest(-obelow-abelow!;-aand-oas unary operators where an operand is expected), whatever the parser says. - A script with Windows line endings (CRLF) is explained as if it had LF, except that no value is taken from it and the words a program receives show the CR (
a␍), and a problem (crlf, on the last word of the first such line) says that bash reads the CR as part of the last word of each line: an error where the script fails every time (a CR on the#!line, after a keyword such asthenorfi, or where a command starts: a blank line, or after;,&&,|), a warning otherwise (echo hithen prints a CR). To keep the file as it is, write# bash-explainer disable-file=crlfon a line of its own (the first line is the shebang, sodisable=before it is not possible). A here-document in such a script has no closing line, as in bash. - A string that a command runs as a script (
bash -c '…',eval, a trap's action,ssh host '…') is explained as a script only when its text is written out: one with an expansion in it (eval "$cmd",bash -c "cd $dir") is known only when it runs. A file run withbash fileorsource fileis not read. - An alias named like a reserved word (
alias {=echo,alias [[=echo) is not followed: where aliases are expanded, bash replaces{or[[with its text ({ x; }becomes a syntax error,[[ x ]]runsecho), but the line is still explained as a group or a[[ ]]. - In POSIX mode, an assignment to an array element before a special builtin (
set -o posix; n[0]=5 :) ends the script in bash, with "not a valid identifier"; the explanation goes on as if the line had run. - The values of expansions, the conditions said to be decided and the code said never to run assume that the script starts in a default environment: no variables of bash in the environment, and the options bash starts with. The explainer reads what the script itself does to bash's behaviour (
set -k,shopt -s compat42,BASH_COMPAT=42,POSIXLY_CORRECT=1,FUNCNEST=1,BASH_ALIASES, a locale other thanC,POSIXorC.UTF-8) and what its first line starts bash with (#!/bin/bash -k,#!/usr/bin/env -S BASH_COMPAT=42 bash,bash -O compat31 -c '…'). When it finds such a change, or a construct it does not know, it decides nothing in that script: no value, no decided condition, no code marked as never running. Setting the locale toC,POSIX,C.UTF-8or empty changes nothing. - It cannot see what is outside the script. A variable of the environment (
BASH_COMPAT,POSIXLY_CORRECT,FUNCNEST,SHELLOPTS,BASHOPTS,LANGorLC_ALLwith another locale, aPS4underbash -x,BASH_ENV, exported functions named like commands) can change a value or a decision, and the explanation does not take it into account. Nor does it see the options given when the script is run:bash -k scriptorsh script(the shell is read from the shebang or the setting, and the options on the command line are not known). If you run a script so, treat its values and decided conditions with care. - When a later redirection of the same descriptor replaces one that opens a file (
echo hi >a >b), links are not followed: two different paths are taken for two files, unless one has a..part or one is absolute and the other relative (>a >b/../a), which gets "unless … names the same file". A symbolic or hard link to the same file (ln -s a b; echo hi >a >bwrites toa) is not seen.
Bash Explainer works entirely offline by design:
- No network access. The extension contains no code that opens network connections, and the panel's Content Security Policy blocks external requests. The links to the Bash manual (gnu.org) are plain links: a page opens only when you click one.
- No AI. Scripts are parsed with unbash, a JavaScript parser bundled into the extension, and explained from built-in dictionaries, so the same line always gets the same explanation.
- No telemetry. The extension collects nothing.
- No external processes. Examples are computed by built-in code; your tools are never run.
A CI check fails the build if network or process APIs appear in the packaged extension (latest runs). Integration tests also run the packaged extension in VS Code — on Linux with no network at all — and fail if it opens a connection or starts a process. You can also verify this yourself: the .vsix is a zip archive, and the extension works with networking disabled.
The web version works the same way. GitHub Pages serves the page, but your script is parsed and explained in the browser and sent nowhere; the page's Content Security Policy allows no requests to other sites, and a link to a script carries it after #, which browsers do not send. A file you open is read in the browser too, and a save writes to your own disk. The same CI check covers the page, and a test opens it in Chromium, blocking every request to other sites, and keeps using it offline.
In VS Code for the Web, the extension runs in your browser; VS Code loads its files (including the parser) from the Marketplace, but your scripts are explained there and sent nowhere.
The command-line tool, which is also the Language Server, works the same way: it contains no code that opens a network connection or starts a process, which the same CI check covers, and a test runs it with network and process modules blocked. Only getting it uses the network: npx and the GitHub Action download it from npm; installed, it runs offline.
This describes the extension, its web version and the command-line tool, not VS Code, other extensions, your browser, npm or GitHub Actions. It is provided as is, without warranty (see LICENSE); please evaluate it against your own requirements before using it with sensitive data.
See CHANGELOG.md.
Issues and pull requests are welcome at github.com/sf624/bash-explainer.
npm install
npm run build # bundle into dist/
npm test # type check + unit tests
npm run coverage # unit tests with coverage (report in coverage/index.html)
npm run package # build a .vsix
npx tsx scripts/extract-snippets.ts # after changing the scripts in the tests: rewrite test/fixtures/snippets.json, the inputs of the syntax tests and the fuzz (CI runs it with --check)
npx tsx scripts/argv-corpus.ts # before a release, or after changing the word model: the argv check of test/argv.test.ts over every snippet and 200 fuzz inputs (CI runs a sample)
npx tsx scripts/normal-forms-sweep.ts --count 1000 # after changing a normalizer (quoting, command names, options): the generated forms of test/normal-forms-gen.test.ts, many more of them (CI runs a sample); --seed N for other forms, --missed to list the missed matches
npx tsx scripts/capture-web.ts --out DIR --name after # screenshots of the web page (390, 640 and 1280 px, English and Japanese, light; --themes light,dark) for a visual PR; --name before on main, for the pair
npm run test:integration # run the extension in VS Code (downloaded into .vscode-test/)
npm run test:integration:web # …and in VS Code for the Web (needs `npx playwright install chromium`)- Press
F5in VS Code to launch an Extension Development Host withtest/fixtures/playground.shopen (a gitignored copy ofsample.sh, the tour of every feature, made when it is missing; edit it freely), with the panel beside it (.vscode/playground.code-workspaceturns onbashExplainer.panel.autoOpen). F5 first runsnpm install, sonode_modulesfollowspackage-lock.jsonafter a pull. npm run build:webbuilds the web version intosite/(-- --channel=nextbuilds the development version shown at/next/, with its banner);python3 -m http.server -d siteserves it at http://localhost:8000/.- The web page's own checks (docs/design/chrome-redesign.md, C0):
test/ja-style.test.tsalso reads the Japanese inweb/(web/*.tsandweb/index.html);test/file-length.test.tskeepsweb/*.tsunder 1,000 lines too;test/web-css.test.tsruns stylelint (MIT, a devDependency;.stylelintrc.json) overweb/*.css, so that a selector is written once per file and a property once per block;test/web-hit-areas.test.tsmeasures the hit area of every control of the page (elementFromPointfrom its centre outwards) at 390, 640 and 1280 px in both languages, and wants at least 44x44 px on a phone and 24x24 px beside others (a dimension under the bar is what is gated), except the smaller controls listed intest/fixtures/hit-areas.json, a list that may only shrink (remove an entry when its control meets the bar; never add one for a new control). The web tests choose the shell and open the Problems list through the helpers oftest/support/site.ts(setShell,openProblems), so that moving a control changes a helper, not many tests. npx tsx scripts/capture-web.ts --out DIR [--name before|after] [--widths 390,640,1280] [--langs en,ja] [--themes light,dark] [--states page,menu,settings,kind,tip] [--script FILE] [--phone-scale 1.3]builds the web page, serves it on 127.0.0.1 and takes its screenshots (DIR/NAME-WIDTH-LANG-THEME-STATE.png; the phone's first-run tip of the read-mode switch is closed first, and is a state of its own,tip); every visual PR attaches the before and after sets, and its author looks at them and measures before pushing. It uses Playwright's browser (npx playwright-core install chromium; Playwright's own variables such asPLAYWRIGHT_BROWSERS_PATHapply) and draws Japanese with whatever fonts the machine has: to get the CJK fonts of CI (IPAGothic, WenQuanYi), pointFONTCONFIG_FILEat a fontconfig file that lists them (apt-get download fonts-ipafont-gothic fonts-wqy-zenhei,dpkg -xinto a directory of your own). Run it under a memory cap (systemd-run --user --scope -p MemoryMax=3G -p MemorySwapMax=0 -p TasksMax=500 …) and, where heavy runs are queued, in that queue.npx tsx test/dump.ts <file> [en|ja] [line]prints the explanation of every line of a file.npx tsx test/snapshot.ts <output file>writes the full explanation of every line of the test corpus (fixtures, host fixtures, snippets), in Japanese and English, as JSON lines, with a fixed clock and time zone. For refactoring: run it on main and on the branch, and compare withgit diff --no-index --stat(see docs/design/analyzer.md).test/file-length.test.tskeeps files ofsrc/under 1,000 lines (the listed exceptions have ceilings that may only go down, intest/fixtures/file-length.json), andtest/explain-imports.test.tskeepssrc/explain/free of import cycles and of comparisons with command names.- To try the command-line tool from this repository,
npm run build:cli, thennode cli/dist/cli.js <file>(ornpx --package=./cli bash-explainer <file>). A barenpx bash-explainerdoes not work here: the extension's ownpackage.jsonis also namedbash-explainer, so npx takes this project for the package. - Tests that compare with real tools use the shells on
PATHor in$BASH_EXPLAINER_SHELLS, and the yq releases in$BASH_EXPLAINER_YQ; the yq examples use mikefarah/yq from$BASH_EXPLAINER_YQ4(a path to the program), or a mikefarahyqonPATH(CI installs v4.54.1, MIT, checked by its SHA-256); missing ones are skipped.test/bash-ast.test.tscompares the syntax tree with bash's own parser through bash-ast (GPL: used only as an external program, never a dependency or part of a bundle), found at$BASH_ASTor onPATH; without it the test is skipped. CI runs it with the pinned release v0.3.3 (bash-ast-v0.3.3-x86_64-unknown-linux-gnu.tar.gzfrom https://github.com/cv/bash-ast/releases/tag/v0.3.3; the known differences are those of this version), downloaded on each run, checked by its SHA-256 and never stored or redistributed; setBASH_EXPLAINER_BASH_AST=1to fail instead of skipping when it cannot be run.test/host-shells.test.tscompares the scripts we read in YAML block scalars with those PyYAML loads (python3 -c 'import yaml'); without PyYAML it is skipped, andBASH_EXPLAINER_PYYAML=1(set in CI) makes that fail. To run only this test:npx mocha --no-config --node-option import=tsx test/bash-ast.test.ts(--specwould add to the whole suite). Its known differences are listed, with reasons, intest/fixtures/bash-ast-known.json; the test fails on a new difference and on an entry that no longer applies.test/command-tokens.test.tsuses it the same way: each token of the explanations lies in exactly one innermost command of our tree, crosses no command's boundary, and that command is one of bash-ast's, in the script or in the text of a substitution (see docs/design/syntax-tree.md); its known findings and the counts it pins are intest/fixtures/command-tokens-known.json.test/bash53.test.tschecks what we say of bash 5.3's constructs (${ cmd; },${| cmd; }) with a real bash 5.3, found at$BASH_EXPLAINER_BASH53(a path; skipped without it, andBASH_EXPLAINER_BASH53_REQUIRED=1makes that fail), and with the bash 5.2 onPATH. CI builds bash 5.3 from the release tarball, checked by its SHA-256 (./configure --without-bash-malloc && make), in a job of its own; bash is GPL, so it is only run as a separate program and never stored or redistributed. The web page test needs Chromium (npx playwright-core install --with-deps chromium-headless-shell). Mocha runs the test files in parallel (.mocharc.cjs: three workers, on Node.js 20 or later; each file in one worker, a worker runs its files one after another); parallel mode ignores.onlyand athis.timeout()outside adescribe, so for one file, or to debug, usenpx mocha --no-parallel --spec test/x.test.ts(or--no-configas above). Every web test file builds and serves its own copy of the page, and closes it after the file. - CI runs the unit tests in four shards per Node.js version, each on a runner of its own: scripts/test-shards.cjs splits the files by the times measured in
scripts/test-timings.json(a new file counts as the median), andtest/shards.test.tschecks that each file is in exactly one shard.MOCHA_SHARD=2/4 npx mocharuns one shard; When the shards' jobs drift apart, measure the times again in CI withnode scripts/test-shards.cjs --measure(its header says how). The jobTest (Node.js …)passes when the type checks and every shard of its Node.js version passed, and the package builds on that version; on Node.js 24 it also merges the shards' coverage. - CI measures the coverage of the unit tests with every tool installed (the Node.js 24 job): the run's summary lists the least covered files, and the
coverageartifact holds the HTML report. Measured locally, it is lower when tools are missing, as their tests are skipped; use Node.js 24 as CI does, since older versions count methods that never ran as covered.npm run coverageruns in UTC, as CI does, since some branches depend on the time zone. The reports leave out the code that tsx adds when it compiles the sources for the tests (its helpers, the getters of exports), which maps to places no test can run (scripts/coverage-report.mjs). - Every line of
src/must run in a unit test, and every branch must be taken, or CI fails (npm run coverage:check). Every file is at 100%, so coverage-floors.json is empty. A file listed there would keep a floor, its share then, which must not go down; after adding tests,node scripts/coverage-check.mjs --updateraises the floors and drops the files that reach 100%. A branch no test can take is removed, or, when it guards against a bug elsewhere, marked with/* c8 ignore next */and a comment saying why. The VS Code side (extension.ts,service.ts,ui/except the renderers) is left to the integration tests. The coverage badge at the top is static; it stays true because CI fails below 100%. - After changing dependencies, run
npm run noticesto regenerate THIRD_PARTY_NOTICES.md from what the builds include; a test fails while it is out of date, or if something under a license not yet reviewed gets in. - A weekly workflow (unbash-release.yml) opens an issue when a newer unbash (the parser, pinned exactly in
package.json) is on npm, with the upgrade checklist; Dependabot's monthly npm run proposes the same bump as a PR. - Japanese text follows docs/japanese-style.md (in Japanese); a test checks it.
- To add a command, add an entry to
src/dict/commands.ts:cmd(japanese, english, [options…], { positional: […] }). Write the descriptions in your own words: do not copy them from man pages,--helpoutput or other documentation, whose licenses (often the GPL or the GFDL) differ from this project's. CHANGELOG.mdis for users: it is the GitHub Release's notes and the Marketplace's Changelog tab. Add entries under## Unreleased, in### New,### Changedor### Fixed: one bullet per change users can see, in one or two short sentences that say what they see now. Write "Before, …" only when the behaviour changes in a way users will notice, not for every fix. Leave out tests, CI, refactors and internal design. Each version ends with an[All changes]link to GitHub's compare view of the previous and this tag (compare/vPREV...vX.Y.Z).- Fine-grained examples and the earlier behaviour go into
docs/changes/<version>.md, linked from the version's section inCHANGELOG.md(for unreleased work,docs/changes/unreleased.md, linked from## Unreleased). When a change is a fix of a kind already listed, add a few words or an example to the details file; do not grow the bullet. - To release, bump
versioninpackage.jsonandcli/package.json, rename## UnreleasedinCHANGELOG.mdto the version, renamedocs/changes/unreleased.mdtodocs/changes/<version>.md(and fix its link in the section tohttps://github.com/sf624/bash-explainer/blob/v<version>/docs/changes/<version>.md), and push a tagv<version>. The Release workflow takes the version's section ofCHANGELOG.mdas the release notes, so keep the section short and the details link in it. It checks that the Test workflow passed on the tagged commit, packages it, waits for approval in thereleaseenvironment, then publishes to the VS Code Marketplace, Open VSX and npm, creates a GitHub Release, moves thev0tag the GitHub Action is used by, and updates the web version; see docs/publishing.md for the steps and the one-time setup. The package pages show the README of their version: the packages are pinned to the tag (npm run package,scripts/build-cli.ts --tag), and the release checks it. Run by hand (Actions, Release, Run workflow), the Release workflow is a dry run: it does the checks and packages the extension, and publishes nothing.
MIT. Third-party components are listed in THIRD_PARTY_NOTICES.md.




