Compatibility

luish generally treats dash as its reference: scripts should behave as they do under dash, including where POSIX leaves the behaviour open.

This page lists where luish deliberately differs, and what isn’t implemented yet.

Where luish goes beyond dash, zsh is the preferred model, since luish is meant to replace it as an interactive shell; some older differences follow bash instead.

Differences from dash

Following zsh

Behaviour

dash

luish

source

Not a built-in (not found, status 127)

As in zsh: ., but a name without / is looked for in the current directory before PATH, and further arguments are the positional parameters while the file runs. Special, as in zsh’s sh emulation. A file that can’t be read is an error with status 2, as for . (zsh uses 1)

builtin

Not a built-in (not found, status 127)

As in zsh and bash: runs a built-in, bypassing functions, and a missing one is status 1. A special built-in stays special for its errors, as in zsh, but its assignments (x=1 builtin export) are temporary, as in bash (zsh keeps them). builtin -- name skips the --, as in bash (zsh looks for a built-in named --)

function

An ordinary word: function f { ...; } is a syntax error, and a command named function is looked for

A reserved word (as POSIX allows), as in zsh and bash: function NAME [()] BODY defines a function whose body is a compound command, on the same line or a later one. The names needn’t be variable names (function git-up), aren’t alias-expanded, and there can be several (function f g { ...; }), as in zsh. As in bash, the body can also start right after the name with (, if, while, until, for or case (function f (cmd)), so those can’t be a second name. Unlike zsh: a name can’t be quoted or expanded, contain /, or be a special built-in (Bad function name, as for f()), and there are no anonymous functions (function { ...; } args)

[[ ... ]]

[[ is looked for as a command (not found, status 127)

A reserved word (as POSIX allows), as in zsh and bash. The words are expanded without field splitting or globbing, and only as far as the expression is evaluated. test’s operators (-a and -o are unary: a file exists, an option is on), -v NAME (a variable or positional parameter is set), -N, !, &&, || and parentheses, with operators recognized only if unquoted. The right side of =, == and != is a pattern, as in case. < and > compare bytes, and the operands of -eq and the like are arithmetic expressions. =~ matches an extended regular expression (the C library’s), after quote removal as in zsh (in bash, quoted parts match literally), and sets MATCH, MBEGIN and MEND, with offsets from 0 as in zsh’s sh emulation (from 1 in native zsh), and for a regular expression with groups, the arrays match, mbegin and mend (zsh; empty and -1 for a group that took no part). It also sets BASH_REMATCH (the matched text, then each group’s), as bash does; a failed match changes none of them, as in zsh (bash empties BASH_REMATCH, and ignores local and readonly for it). A bad regular expression is reported, and is false. After =~, ( and | are part of the word, as in bash (=~ ^(a|b)$; zsh needs quotes). With -nt and -ot, a file that exists is newer than one that doesn’t, as in bash and test (in zsh, both must exist). -n = x compares strings, as in zsh (bash rejects it), and [[ -n ]] is a syntax error, as in bash (in zsh, an error when run). set -x shows it on one line, as zsh does. No zsh extended patterns such as (a|b)

test a == b, [ a == b ]

== is an error (unexpected operator, status 2)

As in zsh and bash: the same as =, also in how expressions are parsed

**, ++, -- and , in arithmetic

** and , are syntax errors, as are x++ and x--; ++x and --x are two unary operators, so they are x

As in zsh and bash: ** is a power (binding tighter than *, less tightly than unary minus, and right-associative; a negative exponent is an error, as in bash), with **=; ++ and -- increment and decrement a variable or an array element, before or after its value is taken; , evaluates both sides and gives the value of the right one. Before or between numbers, ++ and -- are still two operators, as in dash and bash (--5 is 5, 5--1 is 6; zsh rejects both), but x--1 is an error, as in zsh and bash (dash: x - -1)

disown

Not a built-in (not found, status 127)

As in zsh (see help disown): removes jobs from the table, so they aren’t reported, waited for by wait, or warned about by exit, with bash’s options -a, -r and -h (which does nothing: luish, like dash, never sends SIGHUP to its jobs) and process ids as well as jobs. An unknown job is status 1, as in bash (zsh: 127). Without job control, jobs have no text (as in dash), so %name can’t name them

a |& b

Syntax error

As in zsh and bash: a 2>&1 | b, with the redirection after a’s own

;&, ;| and ;;& in case

Syntax errors

As in zsh and bash: after an arm’s body, ;& runs the next arm’s body too (without trying its patterns), and ;| (zsh) or ;;& (bash) goes on trying the patterns of the arms that follow

cmd &|, cmd &!

Syntax errors (a &! b is a & ! b)

As in zsh: cmd runs in the background and is disowned (see help disown). a &! b stays a & ! b, as in dash (zsh disowns a there too): &! disowns only where no command can follow it, such as at the end of a line, before ;;, } or fi, or before a comment

let

Not a built-in (not found, status 127)

As in zsh: evaluates each argument as $((...)), with status 0 if the last value is non-zero and 1 if it is zero or on an error, which doesn’t exit the shell. Arithmetic is on integers only, without zsh’s floating point or comma operator

pushd, popd, dirs

Not built-ins (not found, status 127)

As in zsh with its default options: +n and -n name entries of the stack (zsh’s sh emulation sets POSIX_CD, which makes them directory names). popd with an argument other than +n or -n is an error with status 1 (zsh usually does nothing, with status 0)

setopt, unsetopt

Not built-ins (not found, status 127)

As in zsh: set options by name (case and _ don’t matter, a no prefix inverts), dash’s and luish’s own (such as prompt.percent, whose zsh name prompt_percent also works). Without arguments they list the options that are on or off; zsh lists those that differ from their defaults

% sequences in prompts

Not supported

With setopt prompt.percent, as in zsh (after parameter expansion, as with zsh’s PROMPT_SUBST), with a subset of its sequences, and also long names (%[hostname], %[fg:red], %([status].yes.no)), which zsh doesn’t have, where an unknown name is an error. The escape sequences for attributes and colours are ANSI SGR codes rather than the terminal’s own (so turning bold off is \e[22m, where zsh writes \e[0m and restores the rest)

RPROMPT, RPS1, RPROMPT2, RPS2

Ordinary variables

In an interactive shell, a prompt at the right edge of the terminal, as in zsh (see Usage). They are separate variables, where zsh ties RPROMPT to RPS1 (and RPROMPT2 to RPS2): RPROMPT is used if set and not empty, else RPS1

<(list), >(list)

A syntax error

Process substitution, as in zsh and bash: a word that is the path of a pipe, /dev/fd/N (N is 64 or more, so as not to clash with the descriptors that scripts use), to the standard output of list or from its standard input. It isn’t split or globbed, and can be inside a word (--input=<(list)). Unlike in bash and zsh, the shell waits for a >(list) process when the command is over (they don’t, so what it prints comes at any time); it doesn’t wait for <(list) (which ends when the command stops reading, on SIGPIPE), and exec 3< <(list) waits for neither. A command with a substitution isn’t exec’d in place when it is the last of a subshell or a pipeline, so that the pipes can be closed. There is no =(list), and the processes aren’t in the command’s process group or job

<<< word

A syntax error (redirection unexpected)

A here-string, as in zsh and bash: the word, expanded without field splitting or globbing ("$@" is joined with spaces), and a newline, on standard input (or on the fd before it, 3<<< word)

**/

The same as */

With setopt glob.star (off by default), matches any number of directories, as in zsh (where it is always on)

Brace expansion, a{b,c}, {1..10}

Text

With setopt expand.braces (bash’s braceexpand; off by default), as in bash and zsh, before the other expansions (as in bash) of command words, the words of for and the elements of arrays, not of assignments (also those of local and export), case, [[ ... ]] or here-documents. Where the two differ, as in bash: the command’s name is expanded, the sign of a step is ignored (zsh: a negative step reverses the order), characters can have a step ({a..e..2}), {1..a} isn’t a sequence (zsh: the characters between), {+1..3} and {1..3..0} are sequences, an empty word ({a,}) is removed as other unquoted empty words are (zsh keeps it), tilde expansion comes after (zsh’s sh emulation does it first), a stray } is text (zsh: a parse error) and a redirection to more than one word is an error (zsh writes to each). As in zsh: the ends and the step can be expansions ({1..$n}), expanded once (bash leaves them as they are), and a sequence can be of any characters, also beyond ASCII ({α..γ}), whatever the locale. There is no zsh brace_ccl ({abc})

Glob qualifiers, *(/)

A syntax error

With setopt glob.bare_qualifiers (zsh’s bareglobqual, off by default), zsh’s glob qualifiers. Subscripts count from 0, as in zsh’s sh emulation (which sets KSH_ARRAYS; native zsh counts from 1)

setopt glob.null, glob.dots, glob.fail, glob.fail_all

No setopt

zsh’s NULL_GLOB, GLOB_DOTS, NOMATCH and CSH_NULL_GLOB (and bash’s nullglob, dotglob and failglob, which shopt sets), all off by default: a pattern that matches nothing is removed, or is an error (status 1), and patterns match hidden files (see Extended globbing). As in zsh, glob.null wins over the others and glob.fail_all over glob.fail (in bash, failglob wins over nullglob). Unlike zsh and bash, the error exits a non-interactive shell also in eval (where they return 1 from eval), as other expansion errors do in dash. .* still matches . and .. with glob.dots, as without it (as in dash)

A directory as a command

not found (status 127)

With setopt cd.auto (zsh’s autocd, off by default), changes to it when read from standard input, as in zsh

bindkey

Not a built-in (not found, status 127)

In interactive shells (and their subshells), as zsh’s for its emacs keymap and the widgets luish has; __luish_internal bindkey anywhere. Unlike zsh: Up and Down are bound to history-beginning-search-backward/-forward by default (zsh: up-line-or-history), -r gives a key back to the line editor’s own binding, vi mode can’t be changed, and keys can be named (Up, Ctrl-Right, 'Ctrl-X Ctrl-E'), so an argument such as Up is that key, not U and p (written \Up or \125p, as listings show it)

clipcopy

Not a built-in (not found, status 127, or a program of that name)

In interactive shells (and their subshells): copies its input or a file to the clipboard through the terminal (OSC 52), as oh-my-zsh’s clipcopy function does through xclip and the like; __luish_internal clipcopy anywhere

vared

Not a built-in (not found, status 127, or a program of that name)

In interactive shells (and their subshells), as zsh’s: edits a variable’s value with the line editor (see help vared); __luish_internal vared anywhere. Unlike zsh: -i and -f (luish has no widgets of the user’s own) and -t are not supported, -M takes only emacs, viins and main, and -m only vicmd; -p and -r expand % sequences whether or not prompt.percent is on; a read-only variable is refused before editing (zsh reports it after); Ctrl-D on an empty line without -e does nothing (zsh lists completions); Tab completes as in a command’s argument (zsh’s compinit completes according to the variable); subscripts count from 0 and aren’t expanded (vared 'a[i]' and vared "a[$i]" work, vared 'a[$i]' doesn’t), and keys come in the order they were added; a value that isn’t valid UTF-8 can’t be edited

print

Not a built-in (not found, status 127, or a program of that name)

In interactive shells (and their subshells), as zsh’s, with its options but -p (luish has no coprocesses, so it always fails) and -S; __luish_internal print anywhere. -P expands % sequences whether or not prompt.percent is on (in zsh, it follows PROMPT_PERCENT, which is on unless zsh emulates sh). A bad or readonly name for -v is an error with status 1, where zsh exits a non-interactive shell. -f reports bad numbers as luish’s printf does

History file

None (Debian’s dash has no line editor; upstream dash with libedit keeps the history in memory only)

In zsh’s format, so the two shells can share a file (see History). Unlike zsh: without HISTFILE the file is $XDG_STATE_HOME/luish/history (zsh saves nothing), SAVEHIST defaults to HISTSIZE (zsh: 0), HISTFILE and HISTSIZE set in the startup files take effect, a command equal to the previous one is never added (zsh’s hist_ignore_dups, off there by default), and the elapsed time is always written as 0

History expansion, !!

None: ! is an ordinary character

With setopt history.expand (off by default), in interactive shells, as in zsh and bash (see History expansion). Where the two differ: as in bash, ! before " and in [!...] isn’t expanded (zsh’s !" turns history expansion off for the rest of the line, so echo "hi!" has an unclosed quote there, and zsh expands [!a]); each line of a multi-line command is echoed as it is expanded (zsh echoes the whole command once complete); ! isn’t expanded in comments (zsh’s is); :h without a /, and :r and :e without an extension, leave the text as it is (zsh’s fail). As in zsh, a ! alone before a designator (!$) is the event of the reference before it in the line (bash: the previous command); !{...} works (bash has no braces), and an empty !{} is an error (event not found; zsh leaves !}). Unlike both, an expansion of more than 1 MiB is an error (expansion too long), as !# doubles the line each time; a range keeps the text between its words (bash joins them with spaces); ! before an operator is left as it is (echo hi!;, an error in bash). Unlike both, ! isn’t expanded in $((...)). Not supported: histchars, zsh’s :c, :l, :u and :G modifiers, and bash’s histreedit

alias, unalias options

No options: alias -g x=y defines an alias named -g, and lists a missing alias -g. unalias has only -a

zsh’s options: alias -g and -s define global and suffix aliases (below), -r, -g, -s select which to list, -m takes patterns, -L prints alias commands and + prints names only; unalias -s and -m. So an argument to alias that starts with - or + is an option, and a name that starts with one needs -- before it. Plain alias lists regular and global aliases, not suffix ones. Unlike zsh: values are always quoted (as in dash), alias -L quotes names that need it, errors are dash’s (status 2 for a bad option, a message for a missing alias), unalias -a ignores further arguments (as in dash), and command -v shows a suffix alias as alias -s SUFFIX=VALUE (zsh shows its value)

Global aliases (alias -g)

None

Expanded in any position, not only as a command name, as in zsh: also arguments, for and case words, redirection targets, and inside $(...), but not when quoted, and never as a reserved word (as with zsh’s posix_aliases, set by its sh emulation). Unlike zsh, a here-document delimiter isn’t expanded

Suffix aliases (alias -s)

None

As in zsh: a command name TEXT.SUFFIX (TEXT not empty) becomes the alias’s value followed by the name, when it isn’t a regular alias. A blank at the end of the value makes the next word eligible, as zsh does (its manual says otherwise)

RANDOM, SECONDS, UID, EUID, GID, EGID, HISTCMD, EPOCHSECONDS, EPOCHREALTIME, pipestatus, PIPESTATUS, path, dirstack, LUISH_VERSION, LUISH_PATCHLEVEL, MACHTYPE, HOSTTYPE, OSTYPE

Ordinary variables

zsh’s special parameters, computed when read (see Special variables), with LUISH_VERSION and LUISH_PATCHLEVEL for zsh’s ZSH_VERSION and ZSH_PATCHLEVEL, and bash’s HOSTTYPE (the same as MACHTYPE, the processor, where bash’s MACHTYPE is x86_64-pc-linux-gnu); their values in the environment are ignored, as in zsh (bash takes MACHTYPE, HOSTTYPE and OSTYPE from it). Unset, they read as unset until assigned again, as in zsh. Unlike zsh: assigning to UID, EUID, GID, EGID, EPOCHSECONDS, EPOCHREALTIME, pipestatus, PIPESTATUS, LUISH_VERSION or LUISH_PATCHLEVEL makes it an ordinary variable (zsh calls setuid, refuses, or sets pipestatus until the next pipeline), so scripts that use these names still work; EPOCHREALTIME has microseconds, as in bash, and EPOCH* need no zmodload zsh/datetime; a subshell seeds RANDOM anew unless it was assigned (zsh’s subshells repeat the parent’s numbers); set doesn’t list them, and local doesn’t give a function its own SECONDS. pipestatus follows zsh, where bash’s PIPESTATUS differs: after an if, a loop or { ...; } it has that command’s status (bash’s has the last pipeline’s inside), and an assignment or [[ leaves it (bash sets it); it is empty in a new shell (bash’s is 0). path is the array of the directories in PATH, as in zsh (whose sh emulation doesn’t have it), so $path is the first directory where dash has it unset; an array assignment (path=(...), path+=(...), path[i]=x, all errors in dash) sets PATH, but unlike zsh path=x makes it an ordinary variable (zsh sets PATH to x), as do unset path (zsh unsets PATH too) and local path (zsh makes PATH local too); a path in the environment is an ordinary variable; a read-only PATH can’t be set through it (zsh ignores readonly PATH there). dirstack is the directory stack, tied in the same way: indices from 0 (as elsewhere), and dirstack=x, unset dirstack and local dirstack make it an ordinary variable (zsh’s dirstack=x sets the stack to x)

Arrays, a=(x y), ${a[i]}, "${a[@]}"

A syntax error (a=(), or a bad substitution when expanded (${a[i]}); a[i]=x and a+=x are commands

As in zsh’s sh emulation (see Arrays): indices from 0, $a is ${a[0]}, no holes (unset 'a[i]' empties the element), a[i]=x past the end fills with empty elements (so does a=([i]=x)), "${a[*]/x/y}" replaces in the joined string, arrays aren’t exported, and local, export and readonly take arrays (local a=(x y)). As in bash: a string is an array of one element (zsh indexes its characters: s=abc; echo ${s[1]} prints b), an index before the start is an error (a[-9]=x; zsh prepends), and so is one of 64 Mi (67108864) or more, unset 'a[@]' unsets the array, assignments to an element before a command are temporary, as the others (a[0]=x cmd; zsh keeps them). local a keeps the global value, as local x does in dash (zsh makes a new, empty one). Errors have status 2, as other expansion errors in dash (zsh uses 1). set and readonly -p show arrays as a=('x' 'y')

Associative arrays, typeset -A h, h[key]=v, ${h[key]}

typeset is looked for as a command

As in zsh’s sh emulation and bash (see Arrays): keys are strings (also in arithmetic, $((h[x]))), h=([k]=v) and h=(k v) assign, += adds, "${h[@]}" gives the values, unset 'h[k]' removes a key, and read -A reads pairs (zsh). The values come in the order the keys were added (zsh and bash use their hash order). As in zsh: an odd number of elements, or a mix of [k]=v and pairs, is an error (bash takes an empty value, or ignores the elements without a key). As in bash: $h is ${h[0]} and h=x assigns to it (zsh reads some value, and refuses the assignment); typeset -A x makes a string the value at key 0 (zsh empties it), and an indexed array can’t become an associative one or the other way round (zsh empties it); unset 'h[k]' of a read-only one is an error (zsh removes the key). typeset -p shows them as typeset -A h=(['k']='v')

Parameter flags, ${(o)a[@]}, ${(j:,:)a[@]}, ${(k)h[@]}

Bad substitution when expanded

zsh’s flags @, k, v, j:sep:, s:sep:, f, F, L, U, C, u, o, O, i, n and a (see Arrays), applied in zsh’s order whatever order they are written in, with zsh’s rules for quoting: "${(o)a[*]}" joins before sorting, s splits also in double quotes (where only the first and last words can be empty, unless @ is given), its words aren’t split again, and where the result is a single word (x=${(o)a[@]}, case, here-documents) the elements are joined first and s doesn’t split. As in zsh’s sh emulation, flags apply to what the parameter gives, so ${(k)h} is ${h[0]}: the keys are ${(k)h[@]}. Other flags (such as P, q, e, l:n:), ${(flags)#name} and nested ${(o)${...}} are bad substitutions (zsh: an error in flags, status 1), as is ${(U)} (zsh: empty). o sorts by bytes (zsh uses the locale’s collation, which is the same in the C locale), and L, U and C change only ASCII letters, as typeset -l does

typeset, declare

Looked for as commands (not found, status 127)

Built-ins, as in zsh and bash, with -a, -A, -f, -g, -i, -l, -p, -r, -u, -U (zsh) and -x (not zsh’s other options, nor zsh’s base for -i, nor -F, which is floating point in zsh and the names of functions in bash; typeset +f lists the names, as in zsh). In a function they make local variables that start unset, as in zsh, and hide a read-only one (bash refuses). As in bash: typeset -a x makes a string an array of its value (zsh empties it, and refuses typeset -a x=value). typeset -i x evaluates the value x already has (as in zsh; bash keeps it), and typeset -i applies to the elements of arrays and associative arrays (as in bash; zsh has no integer arrays). The value of an integer variable is printed in decimal (zsh uses the base of the first number assigned, 8#10 for 010). -l and -u convert values when they are assigned, as in bash (zsh converts them when they are read, so typeset +l gives back the original), and apply to the elements of arrays, as in bash (zsh leaves arrays alone). -U on PATH also applies to array assignments to path (zsh keeps them apart), and -U on a string other than PATH does nothing (zsh also dedupes its other colon-separated variables, such as FPATH). typeset -p quotes as export -p does (typeset x='1', typeset -a a=('x' 'y'); zsh quotes only where needed and writes exported strings as export x=...). typeset -f prints definitions in luish’s own layout (four-space indents, f() {; zsh indents with tabs and keeps the text of words as written, bash has its own layout), with backquotes as $(...) and here-document delimiters renamed, and refuses variable attributes and name=value with -f (as bash; zsh ignores most of them). local takes the same options (but -f and -g) too, but still keeps the value, as in dash

read -A, read -a NAME

Illegal option

Read the fields into an array: -A as in zsh (one name), -a NAME as in bash (other names are ignored). Split as field splitting and bash do: zsh adds an empty element after a trailing delimiter (also after trailing whitespace), and makes an empty line one empty element

${x:offset:length}, ${x/pattern/replacement}

Bad substitution when expanded

As in zsh’s sh emulation and bash (see Parameter expansion), counting bytes. As in zsh: a negative offset before the start is the start (bash gives nothing), ${@:1:-1} works, & in the replacement is literal (bash 5.2 replaces it with the match), an unset or empty variable matches *, and "${*/a/b}" replaces in the joined string. A letter after the : starts a modifier (below), where bash evaluates ${x:i} as arithmetic, and ${x:1:} is a bad substitution

Modifiers, ${x:h}, ${x:t}, ${x:A}

Bad substitution when expanded

zsh’s h, t (also with a count, :h2), r, e, a, A, u and l, one after another, as in zsh (see Parameter expansion), also in glob qualifiers. As in zsh, :a is relative to the current directory as the system has it, not PWD. Unlike zsh, .. at the root is the root (zsh’s :a gives //a for /../a); an unknown modifier is a bad substitution when expanded, with status 2 (zsh fails with status 1 when parsing); ${#x:h} is a bad substitution (zsh gives the length of ${x:h}). zsh’s q, Q, P, s, g, &, c and x are bad substitutions

SHLVL

Not maintained

Incremented at startup, as in zsh, but set to 1 when it isn’t in the environment only in an interactive shell (zsh always sets it), so scripts see dash’s environment

Last command of sh -c

Debian’s dash forks it (a Debian patch; upstream dash execs it)

Replaces the shell with it unless a trap is set, as zsh, bash and upstream dash do

Following POSIX where dash doesn’t

Behaviour

dash

luish

Script read from a pipe

Reads ahead in blocks, so commands in the script that read stdin miss data

Never reads past the current command (POSIX requirement), as zsh

$LINENO

Not supported (empty)

Current line number, as in POSIX and bash

fd numbers in redirections

Only a single digit is an fd number: exec 20>f runs a command named 20, and echo hi 99>&1 prints hi 99

Any number of digits, as POSIX allows (and bash does; zsh is like dash)

exec -- cmd

No -- handling: tries to run a command named -- (status 127)

-- ends the options, as POSIX requires (and bash and zsh do)

cd -e

Not supported (Illegal option -e, status 2)

The POSIX 2024 option: with -P, status 1 if the directory is changed but its name can’t be found (as bash does)

set -o pipefail

Not supported (Illegal option -o pipefail, status 2)

The POSIX 2024 option, as in bash: a pipeline’s status is that of its last command that failed. The setting when a pipeline starts applies, also to wait for a background one (zsh ignores it there)

Options listed by set -o / set +o

The last one is debug (no option letter)

The last ones are pipefail and hashall (-h), which POSIX has and dash lacks; luish has no debug option

Following bash

Behaviour

dash

luish

set -x output

Arguments printed unquoted

Arguments quoted so the trace can be re-read as input (as bash does)

kill %n for a job started without job control

Signals the process group -pid, which doesn’t exist, and fails with “No such process”

Signals each process of the job (as bash does)

fc

Debian’s dash has none (fc: not found, status 127). Upstream dash (with libedit) lists as %5d cmd, counts its own entry, and doesn’t echo edited commands

The POSIX list format (N\tcmd, continuation lines indented by a tab). As in bash, its own entry is left out, and commands it re-runs replace that entry and are echoed to stderr. An event number outside the history is moved to the nearest end (dash moves a first that is too large to the oldest entry). Fails with status 2 in a non-interactive shell

$(( that is not arithmetic

Syntax error (dash always reads $(( as arithmetic)

Read as $( (...) ), a command substitution of a subshell, as bash does (POSIX leaves it unspecified)

${ list; }, ${| list; }

Bad substitution when expanded

As in bash 5.3 and mksh (see Usage): the list runs in the shell itself, as a function body does but with the caller’s positional parameters, and the substitution is its output (as $(list) gives it) or the value of REPLY (local to it) after it. The } must come where a reserved word can, as in bash. The list is parsed with the rest of the script, so one that doesn’t parse is a syntax error even if it is never run. zsh’s version (not yet released) keeps all but one trailing newline and doesn’t split the output

${!a[@]}, ${!a[*]}

Bad substitution when expanded

The indices of an array, or the keys of an associative one (in the order they were added), as bash’s, which zsh’s sh emulation doesn’t have (native zsh has ${(k)a}). As in bash, an unset array has none, even with set -u

${!x}, ${!x[i]}, ${!prefix@}, ${!prefix*}

Bad substitution when expanded

bash’s indirection, which zsh’s sh emulation doesn’t have (native zsh has ${(P)x}): the parameter named by the value of x (or of the element), a variable, name[index], a positional or a special parameter, with any operator applied to it (${!x:-d}, ${!x#p}). As in bash, an unset or invalid name is an error, even without set -u. Unlike bash, the index in the value is only evaluated, not expanded, as in unset 'a[i]' (so $(...) in it is an error), and ${!x:=v} can assign an element. ${!prefix@} lists the set variables whose names start with the prefix, sorted; unlike bash, specials such as RANDOM are listed only once assigned, as in set

FUNCNAME, BASH_LINENO, caller

Ordinary variables; caller is looked for as a command

The call stack, as in bash (see Special variables and help caller), with the frames of BASH_SOURCE. As there, assigning makes them ordinary variables (bash ignores the assignment). zsh’s sh emulation has none of them (native zsh has funcstack and funcfiletrace)

BASH_SOURCE

An ordinary variable

The files being run, innermost first: the script, the files read with . and the startup files, and in a function the file it was defined in (see Special variables). It is unset in -c and on standard input, as in bash, but a function defined there has an empty file (bash: main or environment). Assigning to it makes it an ordinary variable, as for luish’s other special variables (bash ignores the assignment). zsh has no BASH_SOURCE (its funcfiletrace and %x have the same information)

emacs option in interactive shells

Off (Debian’s dash has no line editor)

On unless vi is set, since it is the line editor’s mode, so $- has E (as in bash)

luish’s own

Behaviour

dash

luish

${a[i..j]}

Bad substitution when expanded

A slice of an array, as in Python, which no other shell has (see Arrays): from i up to (not including) j, either end left out, negative ends from the end, and clamped to the array. It expands as ${a[@]} does, so "${a[i..j]}" gives separate words (zsh’s "${a[i,j]}", with both ends included, joins them). A string is an array of one element, as for ${s[0]}. For an associative array the subscript is a key, as before (${h[a..b]}). $a[i..j] is still $a followed by a pattern, as in dash. No steps, and no assignment to a slice

Command cache (hash) in an interactive shell

Kept until PATH is assigned or hash -r, so a command installed earlier in PATH than a cached one is ignored

Also cleared when a PATH directory changes, checked after each line is read (see Improvements)

Running out of stack

Recursion through eval, . or a trap, and very deeply nested commands, crash the shell (SIGSEGV). A function calling itself stops at a depth of 1000, as a Debian patch (upstream dash crashes), but in an interactive shell the depth isn’t reset by the error, so every function call fails after that

The same limit for functions (Maximum function recursion depth (1000) reached, status 2), reset by the error. Other nesting that would run out of stack is an error too, nested too deeply, with status 2, which exits a non-interactive shell and returns an interactive one to its prompt

Command-line options

-o takes only the names that set -o lists, and any --name other than -- is an error (Illegal option --, status 2)

-o and +o also take any option named as for setopt (such as -o ERR_EXIT or -o prompt_percent), and there are long options such as --login and --no-rcs (see Usage)

__luish_internal

Not a built-in (not found, status 127)

luish’s own built-in, with subcommands such as savestate, which prints commands that restore the shell’s state, and print-git-rev

Startup files

Login shells read /etc/profile and ~/.profile; interactive shells read $ENV

Interactive shells first apply the settings, aliases and variables in config.toml, if it exists, and load the plugins it enables (login shells that aren’t interactive apply only its env and path tables, and the option startup.auto_cache); if rc.d exists, they then run its *.lsh files (then the plugins’ post-rc.lsh); if login.d exists, login shells then run its files instead of /etc/profile and ~/.profile. Their __luish_cache blocks are cached, and with startup.auto_cache all their effects (see Cached startup files). Without them, as dash

Grouped option names, setopt NAME=VALUE, setopt -p GROUP

No setopt

luish’s own options are named in groups (history.share, glob.star), with zsh’s names as aliases, and a no prefix goes on the last part (history.no_share). setopt NAME=VALUE sets an option or a setting with a value, and such arguments are expanded as assignments, as for export. setopt -p GROUP puts the names that follow in GROUP. zsh has none of these (setopt a=b is an error there, and setopt -p sets privileged)

help

Not a built-in (not found, status 127)

In interactive shells (and their subshells), a built-in that shows help for the built-ins. Not a built-in in scripts or -c, as in dash; there, __luish_internal help does the same

jobs -i

An illegal option (status 2)

On a terminal, a menu of the jobs, to bring one to the foreground, continue, stop or end it (see jobs). Without a terminal, an error with status 2, as in dash

plugin

Not a built-in (not found, status 127)

In interactive shells (and their subshells), a built-in that loads plugins. Not a built-in in scripts or -c, as in dash; there, __luish_internal plugin does the same

where

Not a built-in (not found, status 127)

In interactive shells (and their subshells), and in any shell while vars.trace or vars.trace_history is on, a built-in that shows where variables were set (see Tracing variables). Otherwise not a built-in, as in dash; __luish_internal where does the same. zsh’s where lists the commands a name finds instead, which luish doesn’t have

Startup caches with vars.trace

No startup caches

While variables are traced, the startup files run instead of being restored from the caches, so that where they set variables is recorded

Error messages

$0: LINE: message

FILE: LINE: message, where FILE is the file of the code that failed (a file read with ., or the file a function was defined in), followed by the text of the failing line and the call stack: a line for each function call and file read with . that led there, with the text of the line it was called from (see Error messages)

shopt

Not a built-in (not found, status 127)

bash’s built-in: shopt -s NAME and shopt -u NAME set the luish option that does the same (globstar is glob.star, autocd is cd.auto, histverify is history.verify). bash’s options that describe what luish always does (histappend, expand_aliases, inherit_errexit, xpg_echo …) are always on, login_shell tells whether the shell is a login shell (and can’t be changed; bash ignores the change), and the others are always off; setting or unsetting one the other way is an error (status 1)

Known limitations

Scripts:

  • trap with no arguments, run inside a subshell or $(...), doesn’t show the parent’s traps.

  • read is not interrupted by trapped signals.

  • ${@#pat} and ${*%pat} operate on the joined string rather than on each parameter.

  • set -v output is approximate.

  • Glob results are sorted in byte order; locale collation is not implemented.

  • command local x=1 keeps the variable in the function; in dash it is local to the command invocation and disappears.

  • Commands, $(...), ${...} and $((...)) can’t be nested as deeply as in dash: with the usual 8 MB of stack, about 4000 levels of ( ... ) in a release build (dash: more than 10,000). Deeper nesting is an error, nested too deeply, as is recursion through eval or . that runs out of stack (see above).

Interactive use:

  • History entries are stored as UTF-8, so invalid bytes in a command are replaced when it is recorded. They have no elapsed time (written as 0, as zsh does with share_history), nor other metadata such as the directory or exit status, and fc can’t show their times (zsh’s fc -d, -i). Entries read from other shells are not marked as such (zsh’s set-local-history). zsh’s hist_ignore_dups, append_history, extended_history and hist_fcntl_lock are always on, and aren’t options.

  • fc -e records the edited text as one history entry, rather than one entry per command. With history.inc_append or history.share, the fc command has been written to the file before it replaces its own entry (as in zsh).

  • set -b (immediate job notification) is accepted but does nothing: jobs are reported only before a prompt. Job notifications are given only for input read a line at a time (interactive or stdin), not in scripts run with set -m.

  • Syntax highlighting doesn’t expand aliases.

  • The state that __luish_internal savestate prints can be changed by aliases the shell reading it has already: a global alias there may be any word of it (the startup cache reads its own without aliases). Function bodies are safe, since their words that are aliases are quoted, apart from a for variable named like a global alias.

  • Completion skips filenames that are not valid UTF-8. Matching has no fuzzy matching and no ranking, and isn’t configurable. The menu has no groups (such as zsh’s headings for commands, files and so on), no colours by file type (LS_COLORS), no narrowing by typing, and no mouse. After Ctrl-C it stays on the screen above the next prompt. Completion doesn’t know about **/ or glob qualifiers.

  • Key bindings: only the emacs keymap can be changed (vi mode keeps the line editor’s keys); there are no user-defined widgets (zle -N), numeric arguments aren’t given to luish’s own widgets, and some word kills don’t go to the kill ring. After Ctrl-O the next line is filled with the entry, but the history position is the end of the history (zsh’s is the entry).

  • Prompt expansion lacks zsh’s %_, %e, %I, %N, %x, %v, the l, S, _, e and v conditions, and widths relative to the terminal’s (negative truncation lengths).

  • Glob qualifiers lack zsh’s e, +, f, F, Y and P, (#q...), the modifiers other than h, t, r, e, a, A, u and l, and EXTENDED_GLOB patterns.

  • There is no funcstack, funcfiletrace, functrace or funcsourcetrace (zsh’s, which its sh emulation lacks too); bash’s FUNCNAME, BASH_LINENO and BASH_SOURCE have the same information.

  • In a function restored from the startup cache or a saved state, LINENO (and so BASH_LINENO and caller for calls from it) counts lines of the restored text, not of the file the function was defined in.

  • The startup cache trusts its keys: a file’s entry (with startup.auto_cache) depends on PATH, HOME, the files before it and the files it reads with ., and a __luish_cache block’s only on what it lists. What else they read (other variables, files that commands read) isn’t seen, other than by check-cache; see Cached startup files.

  • Shell functions named chpwd, precmd, preexec, periodic or zshexit are not hooks, nor are the *_functions arrays: extensions register hooks instead (see Extensions).

  • Extensions’ hooks have no time limit (only completers do), so a slow precmd or prompt hook, or prompt-vars.lsh, delays the prompt. Completers can’t be interrupted with Ctrl-C, only by their time limit.

The static musl build (see Installation) differs from the usual build, which uses glibc as dash does:

  • musl reserves signals 32 to 34, so its real-time signals start at 35 rather than 34: RTMIN+n is signal 35+n, one more than in dash, kill -l 34 prints 34, and signals 32 to 34 can’t be trapped.

  • Some error messages take musl’s wording, such as Symbolic link loop for glibc’s Too many levels of symbolic links, Filename too long for File name too long, and Arithmetic exception for a process killed by SIGFPE.

  • ~user only finds users in /etc/passwd, not those from other sources such as LDAP (through NSS).

  • Scripts that do much work inside the shell take up to twice as long, since musl’s malloc is slower.