Variables & Quoting
Variable Assignment
An assignment is a single unbroken token — name,
=, value — with no spaces anywhere around the equals sign. A space on either side is a syntax error, because the shell would then read the name as a command it is being asked to run.greeting = "Hello"
number = 42
puts greeting
puts numbergreeting="Hello"
number=42
echo $greeting
echo $numberVariable names are case-sensitive. Unlike Ruby, Zsh has no separate integer or string types at the variable level — everything is a string unless you use
typeset -i, which is why greeting="Hello" and number=42 are the same kind of statement here and two different kinds in Ruby.Curly Brace Expansion
Zsh interpolates a variable into a double-quoted string simply by naming it — there is no delimiter around the expression the way Ruby has
#{}. The braces in ${language} exist to show the shell where the NAME ends.language = "Ruby"
puts "I love #{language}!"
puts "#{language}ist"language="Ruby"
echo "I love ${language}!"
echo "${language}ist"They are optional for simple access and required whenever the name is immediately followed by letters, digits or underscores that are not part of it:
$languageist would send the shell looking for a variable called languageist. Ruby never has that ambiguity, because its delimiters are mandatory.Single vs Double Quotes
The quotes decide whether
$name is a variable or four literal characters. Single quotes prevent all interpretation; double quotes allow variable and command substitution.name = "World"
puts 'Hello, #{name}' # No interpolation — literal #{}
puts "Hello, #{name}" # Interpolationname="World"
echo 'Hello, $name' # No interpolation — literal $name
echo "Hello, $name" # InterpolationDouble quotes also suppress word splitting and glob expansion, which matters far more in a shell than the interpolation question does — an unquoted value containing a space becomes two words. Ruby's single-quoted strings suppress interpolation similarly but still honor
\\ and \' escapes; Zsh's honor nothing whatsoever, so a single quote cannot appear inside single quotes at all.Default Value
Read
${port:-8080} as "this variable, or 8080 if it has no value". Nothing is assigned — the variable is left exactly as it was found.port = nil
port ||= 8080
puts portecho "${port:-8080}"
# port is unset, so prints: 8080
port=3000
echo "${port:-8080}"
# port is set, so prints: 3000That last point is the difference from Ruby's
||=, which writes the fallback back into the variable; the expansion below only supplies it for this one use. The colon is doing real work: :- treats both an unset variable and an empty string as missing, while a bare - triggers only when the variable was never set at all.Assign If Unset
${count:=0} is the assigning cousin of :- — it stores the value when the variable is unset or empty, and then expands to it. The line starts with a bare :, the shell's null command, which is there only to give the expansion somewhere to happen and to throw the result away.count ||= 0
count += 1
puts count: ${count:=0}
(( count += 1 ))
echo $countThis is the expansion that matches Ruby's
||= exactly, because the assignment persists: the (( count += 1 )) on the next line is operating on a variable that now really holds 0. Without the := it would be incrementing nothing.Alternate Value
${debug:+[DEBUG] } is the mirror image of :-: it expands to the alternate text when the variable IS set, and to nothing when it is not.debug = true
prefix = debug ? "[DEBUG] " : ""
puts "#{prefix}Server started"debug=true
echo "${debug:+[DEBUG] }Server started"The everyday use is exactly the one below — dropping a prefix, a flag or a separator into a command line only when there is something to justify it, with no
if around it. Note that the alternate text is inside the braces, trailing space and all, so the space disappears along with the prefix.Readonly Variables
typeset declares a variable and gives it attributes that stick to it from then on. -r is the read-only attribute.PI = 3.14159
puts PI
# PI = 3.0 # => NameError: already initialized constanttypeset -r PI=3.14159
echo $PI
# PI=3.0 # would produce: zsh: read-only variable: PIZsh is stricter than Ruby here: reassigning a read-only variable is a hard runtime error, where a Ruby constant merely warns and then goes ahead and changes.
declare works as an alias for typeset, but typeset is the idiomatic Zsh spelling.Integer Variables
The
-i attribute changes what assignment MEANS for a variable: every value given to it is evaluated as an arithmetic expression rather than stored as text.count = 0
begin
count += "5" # TypeError — cannot coerce String to Integer
rescue TypeError => error
puts error.message
end
count += 5
puts counttypeset -i count=0
count+=5 # Arithmetic context — adds 5
count+="hello" # Non-numeric string treated as 0
echo $countThat is why
count+="hello" below is not an error. A string that will not parse as a number is simply worth zero, so the variable silently keeps its old value — the exact opposite of Ruby, which raises a TypeError rather than guess. An integer variable is convenient and quiet, and quiet is the part to watch.Automatic Case Transformation
The
-l and -u attributes attach a case transformation to the VARIABLE rather than to any particular read of it: -l lowercases and -u uppercases everything assigned to it from then on.tag = "HELLO"
puts tag.downcase # hello
puts tag.upcase # HELLOtypeset -l lowered="HELLO WORLD"
typeset -u uppered="hello world"
echo $lowered # hello world
echo $uppered # HELLO WORLDThe transformation happens at assignment time, not at read time, so the original text is gone — there is no way to get
HELLO WORLD back out of lowered. Ruby's .downcase is the opposite arrangement: the string is untouched and each read decides for itself.Environment Variables
The shell keeps no separate namespace for the environment. Anything the process inherited is already an ordinary variable, read the ordinary way.
home = ENV["HOME"]
puts "Home: #{home}"
puts "Shell: #{ENV["SHELL"]}"echo "Home: $HOME"
echo "Shell: $SHELL"Ruby has to go through the
ENV hash precisely because its own variables are somewhere else. The uppercase names are convention, not rule — Zsh enforces no distinction between an environment variable and one a script invented — which is why lowercase names for script-local variables is a habit worth keeping.String Operations
String Length
The
# just inside the brace asks for a count instead of a value.message = "Hello, World!"
puts message.length # 13message="Hello, World!"
echo ${#message} # 13The same syntax works on an array, where it counts elements rather than characters — so
${#thing} answers "how big" without your having to say what kind of thing it is. Ruby splits that across .length and .size, which are the same method twice.Substring Extraction
${text:offset:length} takes a substring, counting from zero. Leaving the length off takes everything from the offset to the end.text = "Hello, World!"
puts text[7, 5] # World
puts text[7..] # World!text="Hello, World!"
echo ${text:7:5} # World
echo ${text:7} # World!Ruby's
[start, length] maps onto this directly, right down to the zero-based offset — which is worth noticing, because Zsh arrays start at 1. Strings are the exception. A negative offset counts back from the end, but it needs a space in front of the minus (${text: -3}), since :- already means something else entirely.Uppercase & Lowercase
A parameter flag is a letter in parentheses placed immediately after the opening brace, before the variable name.
(U) uppercases, (L) lowercases and (C) capitalizes each word.greeting = "Hello, World!"
puts greeting.upcase # HELLO, WORLD!
puts greeting.downcase # hello, world!
puts greeting.capitalize # Hello, world!greeting="Hello, World!"
echo ${(U)greeting} # HELLO, WORLD!
echo ${(L)greeting} # hello, world!
echo ${(C)greeting} # Hello, World! (capitalize each word)Watch the third line:
(C) capitalizes every word, where Ruby's .capitalize raises only the first letter of the whole string and lowercases the rest. Bash spells the first two ${var^^} and ${var,,} and has nothing for the third. The flag slot is the same one used for joining, splitting, sorting and padding, so learning where it goes pays for itself many times over.Replace First Occurrence
${text/pattern/replacement} replaces the first match. The pattern is a glob — *, ?, […] — not a regular expression.text = "the cat sat on the mat"
puts text.sub("at", "og") # the cog sat on the mattext="the cat sat on the mat"
echo ${text/at/og} # the cog sat on the matRuby's
String#sub does the same job but takes a Regexp, so patterns do not carry over between the two: . is a literal dot here, and * means what .* means there. The variable is not modified; the expansion produces a new value.Replace All Occurrences
Doubling the first slash is the whole difference:
${text//pattern/replacement} replaces every occurrence rather than the first.text = "the cat sat on the mat"
puts text.gsub("at", "og") # the cog sog on the mogtext="the cat sat on the mat"
echo ${text//at/og} # the cog sog on the mogRuby marks the same distinction by changing the method name from
sub to gsub, which is harder to overlook in review than one extra slash. The pattern is still a glob rather than a regexp.Strip Prefix
# removes a matching prefix and ## removes the longest matching prefix. Both leave the variable alone and hand back the trimmed value.path = "/usr/local/bin/ruby"
# Remove leading slash and up to next slash:
puts path.delete_prefix("/usr/") # local/bin/ruby
puts path.sub(%r{^/[^/]*/}, "") # local/bin/rubypath="/usr/local/bin/ruby"
echo ${path#/usr/} # local/bin/ruby (shortest match)
echo ${path##/*/} # local/bin/ruby (longest prefix up to /)Since the pattern is a glob,
##*/ means "everything up to and including the last slash", which is how a shell script gets a basename without calling one. Ruby reaches for delete_prefix when the prefix is literal and a regexp when it is not; Zsh uses one mechanism for both.Strip Suffix
% and %% are the suffix mirrors of # and ## — shortest and longest match, taken from the end this time.filename = "archive.tar.gz"
puts filename.delete_suffix(".gz") # archive.tar
puts filename.sub(/.[^.]*$/, "") # archive.tar (last ext)
puts filename.sub(/..*$/, "") # archive (all exts)filename="archive.tar.gz"
echo ${filename%.gz} # archive.tar (shortest suffix)
echo ${filename%.*} # archive.tar (last extension)
echo ${filename%%.*} # archive (all extensions)The pairing is worth memorizing as a picture: on a US keyboard
# sits to the left of %, and it strips from the left. Those four expansions between them cover nearly all path and filename work with no external tool involved, which is why shell scripts so rarely need basename and dirname.Test String Contains
Inside
[[ ]], == is a glob match rather than a string comparison whenever its right-hand side is unquoted. Surrounding the word with * is therefore how you ask whether it appears anywhere.sentence = "The quick brown fox"
if sentence.include?("quick")
puts "Found it"
endsentence="The quick brown fox"
if [[ $sentence == *"quick"* ]]; then
echo "Found it"
fiNote where the quotes fall below:
"quick" is quoted so its own characters stay literal, while the two * outside the quotes remain live pattern. Quoting the entire right-hand side turns the whole thing back into a literal comparison and the test silently stops finding anything — the shell equivalent of == where include? was meant.String Concatenation
There is no concatenation operator. Strings join by being written next to each other, normally inside one pair of double quotes.
first = "Hello"
second = "World"
puts first + ", " + second + "!"
puts "#{first}, #{second}!"first="Hello"
second="World"
echo "$first, $second!"
combined="$first, $second!"
echo $combinedA
+ between two strings does not concatenate them — it is arithmetic, and on words it produces zero or an error rather than the joined text. The upside of having no operator is that the quoted form reads like the output it produces, which is why the shell needs nothing resembling Ruby's #{}.Join an Array into a String
The
(j:separator:) flag joins an array into one string. The colons are only delimiters marking where the separator starts and stops — any character can play that role, so (j.-.) joins on a hyphen just as well.words = ["one", "two", "three"]
puts words.join(", ") # one, two, three
puts words.join("-") # one-two-threewords=("one" "two" "three")
echo ${(j:, :)words} # one, two, three
echo ${(j:-:)words} # one-two-threeThis is Ruby's
Array#join with no method call and no external tool. Bash has no equivalent at all: it has to set IFS, expand "${array[*]}", and set IFS back, and even then the separator can only be one character long.Split a String
(s:separator:) is the mirror of (j…) — it splits a scalar into a list. The parentheses wrapped around the whole expansion are what turn that list into an array.csv_line = "alice,bob,charlie"
parts = csv_line.split(",")
puts parts.inspect
puts parts[1]csv_line="alice,bob,charlie"
parts=(${(s:,:)csv_line})
echo "${parts[@]}"
echo ${parts[2]} # bob (Zsh arrays are 1-indexed)The index is the thing to watch when coming from Ruby: Zsh arrays start at 1, so
${parts[2]} below is bob, the same element Ruby calls parts[1]. An off-by-one in a ported loop is the most common way this bites.Trim Whitespace (Manual)
Zsh has no trim. What it has is nested expansion: the inner one produces exactly the run of spaces that needs to go, and the outer one strips that off as a prefix or suffix. Read each line from the inside out.
text = " hello world "
puts text.strip
puts text.lstrip
puts text.rstriptext=" hello world "
# Strip leading whitespace
trimmed_left="${text#"${text%%[! ]*}"}"
echo "'$trimmed_left'"
# Strip trailing whitespace
trimmed_right="${text%"${text##*[! ]}"}"
echo "'$trimmed_right'"Taking the leading case apart:
${text%%[! ]*} removes the longest run starting at the first non-space, which leaves just the leading spaces; feeding that to ${text#…} then removes them. It works, it is genuinely the idiomatic answer, and it is still four punctuation marks doing the work of .strip. Where a pipeline is available, tr -d and friends are usually the more readable choice.Arithmetic
Arithmetic Expansion
Arithmetic needs its own brackets. Inside
$(( )) the shell reads C, not shell — operators mean what they do in C, and a bare name is a variable with no $ in front of it.puts 10 + 3 # 13
puts 10 - 3 # 7
puts 10 * 3 # 30
puts 10 / 3 # 3 (integer division)
puts 10 % 3 # 1
puts 2 ** 8 # 256echo $(( 10 + 3 ))
echo $(( 10 - 3 ))
echo $(( 10 * 3 ))
echo $(( 10 / 3 )) # Integer division: 3
echo $(( 10 % 3 ))
echo $(( 2 ** 8 ))Do not confuse
$(( )) with $( ), which runs a command and captures its output; the doubled parentheses are the only thing telling them apart. The arithmetic is integer-only, so $(( 10 / 3 )) is 3 in both columns — Ruby happens to agree here because Integer#/ truncates too, but $(( 1 / 3 )) is 0 where Ruby's 1.0 / 3 is not. Floating point needs zmodload zsh/mathfunc.Compound Arithmetic Tests
Dropping the
$ turns arithmetic from an expansion into a command. (( x += 5 )) does the sum for its side effect and produces no text at all.x = 10
x += 5
x -= 2
puts x # 13x=10
(( x += 5 ))
(( x -= 2 ))
echo $x # 13As a command it also has an exit code, and the rule is upside down from what a C programmer expects: a non-zero result means success (exit code 0), and a result of zero means failure. That is what lets
(( count )) read as "if count is non-zero" in an if or while. Every C-style compound operator works — +=, -=, *=, /=, %=, **=.Increment & Decrement
Zsh has the C increment operators Ruby deliberately left out. They only exist inside
(( )), in both the post- and pre- positions.counter = 0
counter += 1
puts counter # 1
counter += 1
puts counter # 2counter=0
(( counter++ ))
echo $counter # 1
(( ++counter ))
echo $counter # 2
(( counter-- ))
echo $counter # 1The two positions differ in what the expression evaluates to —
counter++ yields the old value and ++counter the new one — which matters when the result is being used for something rather than thrown away as it is below. Ruby has neither, on the grounds that += 1 says the same thing without the puzzle.Arithmetic Comparisons
A numeric comparison belongs inside
(( )), where the operators are the C ones — ==, !=, <, >, <=, >= — and the whole thing works as the condition of an if.score = 85
puts score >= 90 ? "A" : (score >= 80 ? "B" : "C")score=85
if (( score >= 90 )); then
echo "A"
elif (( score >= 80 )); then
echo "B"
else
echo "C"
fiThe alternative is the legacy
[ "$score" -ge 90 ] spelling with its -eq/-lt/-gt flags, which exists because < and > meant redirection long before (( )) was invented. Prefer (( )): the symbols mean what they look like, and no quoting is needed around the variables.Ternary in Arithmetic
There are two ways to write a conditional expression on one line, and they are not interchangeable. The C ternary inside
$(( )) can only produce a NUMBER; chaining commands with && and || is what produces text.x = 7
result = x.even? ? "even" : "odd"
puts resultx=7
echo $(( x % 2 == 0 ? 0 : 1 )) # 1 (x is odd)
(( x % 2 == 0 )) && echo "even" || echo "odd"The second form has a trap Ruby's ternary does not: it is two ordinary commands, so if the
&& branch itself fails, the || branch runs as well and you get both outputs. It is safe below because echo cannot fail, and it is worth spelling out as a real if the moment the branch does anything more interesting.Floating-Point Formatting
The shell has no rounding method, because it has no numbers to round —
pi below is a string that happens to look like one. Formatting it is printf's job, and %.2f rounds as a side effect of formatting.pi = 3.14159265
puts pi.round(2) # 3.14
printf("%.4f
", pi) # 3.1416pi=3.14159265
printf "%.2f
" $pi # 3.14
printf "%.4f
" $pi # 3.1416Ruby's
.round(2) returns a new number you can go on calculating with; printf hands back text and nothing else. That distinction stops mattering the moment the value is on its way to a screen, which is most of the time in a shell script. Real floating-point arithmetic needs zmodload zsh/mathfunc, after which $(( sqrt(2) )) works.The let Builtin
let is the older way to spell arithmetic assignment. It takes its expression as a quoted string, one argument per expression.product = 6 * 7
puts product # 42let "product = 6 * 7"
echo $product # 42
let "x = 2 ** 10"
echo $x # 1024Everything below could be written
(( product = 6 * 7 )), which is the contemporary idiom and needs no quotes. let is worth recognizing rather than writing: it turns up constantly in scripts old enough to predate (( )), and it works identically in Bash.Indexed Arrays
Create & Access
Zsh arrays are 1-indexed. The first element is
${fruits[1]}, not [0], and this is the single most important thing to carry into the rest of this section.fruits = ["apple", "banana", "cherry"]
puts fruits[0] # apple
puts fruits[1] # banana
puts fruits[-1] # cherryfruits=("apple" "banana" "cherry")
echo ${fruits[1]} # apple (Zsh arrays start at 1)
echo ${fruits[2]} # banana
echo ${fruits[-1]} # cherry (negative index counts from end)Ruby and Bash both start at 0, so a loop translated straight across is off by one and quietly reads one element short. Negative indices work the same as Ruby's, counting back from the end.
setopt KSH_ARRAYS switches the whole shell to 0-based if a script really needs it, at the cost of every other Zsh array idiom on this page.Array Length
The same
${#…} that counted characters in a string counts elements in an array. No subscript is needed.fruits = ["apple", "banana", "cherry"]
puts fruits.length # 3fruits=("apple" "banana" "cherry")
echo ${#fruits} # 3Bash requires
${#fruits[@]} here, and that longer form works in Zsh too, so it is the one to write in a script meant for both. Watch out for the difference in what the two count when the name holds a scalar: on a string, ${#name} is a character count in either shell.All Elements
"${fruits[@]}" is the expansion that means "every element, each one still a separate word". The quotes are part of the idiom, not decoration.fruits = ["apple", "banana", "cherry"]
puts fruits.join(" ")
puts fruits.inspectfruits=("apple" "banana" "cherry")
echo "${fruits[@]}" # apple banana cherry
printf "%s
" "${fruits[@]}" # one per lineIts near-twin
"${fruits[*]}" looks the same and is not: it glues every element into ONE word, joined by the first character of IFS. The difference is invisible until an element contains a space, at which point [@] keeps it whole and [*] has already thrown the boundary away. Prefer [@] unless the joining is the point.Append Elements
+= appends to an array when its right-hand side is a parenthesized list — one element or several, in a single step.fruits = ["apple", "banana"]
fruits << "cherry"
fruits.push("date")
puts fruits.inspectfruits=("apple" "banana")
fruits+=("cherry")
fruits+=("date" "elderberry")
echo "${fruits[@]}"The parentheses are what make it an append rather than a string concatenation:
fruits+="cherry" without them glues the text onto the LAST element instead of adding a new one, silently. Ruby's << and push cannot be confused that way. Assigning past the end (fruits[6]="fig") also works and leaves a gap.Array Slice
Zsh has two slice syntaxes and they count from different places.
[@]:offset:length is the Bash-compatible one and its offset is 0-based; [first,last] is Zsh's own and is 1-based like every other Zsh subscript.fruits = ["apple", "banana", "cherry", "date"]
puts fruits[1, 2].inspect # ["banana", "cherry"]fruits=("apple" "banana" "cherry" "date")
echo "${fruits[@]:1:2}" # banana cherry (offset is 0-based here)
echo "${fruits[2,3]}" # banana cherry (Zsh's own form, 1-based)Both lines below select the same two elements, which is the clearest way to see the trap: in a shell where arrays start at 1, one of these slices does not. Ruby's
fruits[1, 2] matches the first spelling exactly, offset and length alike.Iterate Over Array
A
for loop walks a list of words, and "${fruits[@]}" is how an array becomes exactly that list.fruits = ["apple", "banana", "cherry"]
fruits.each do |fruit|
puts fruit
endfruits=("apple" "banana" "cherry")
for fruit in "${fruits[@]}"; do
echo "$fruit"
doneThe quotes are the whole safety story. Left off, an element containing a space arrives as two separate iterations, which is the classic shell bug that only shows up once someone has a file called
My Documents. Ruby's .each has no equivalent failure mode, because there is nothing being re-parsed.Array Indices
The
(@k) flag expands an array to its SUBSCRIPTS instead of its values, so looping over it gives the index and the element can be fetched inside the body.fruits = ["apple", "banana", "cherry"]
fruits.each_with_index do |fruit, index|
puts "#{index}: #{fruit}"
endfruits=("apple" "banana" "cherry")
for index in "${(@k)fruits}"; do
echo "$index: ${fruits[$index]}"
doneThe numbers printed are 1, 2, 3 where Ruby's
each_with_index gives 0, 1, 2 — the 1-based rule again, and worth remembering before using such an index in arithmetic. Bash spells the same thing ${!fruits[@]}, which also works in Zsh and also yields Zsh's 1-based numbering.Delete an Element
Removing an element takes two steps, and only the first is obvious.
unset empties the slot; the array is then sparse, with a hole where the element was, until it is reassigned to itself to close the gap.fruits = ["apple", "banana", "cherry"]
fruits.delete_at(1)
puts fruits.inspect # ["apple", "cherry"]fruits=("apple" "banana" "cherry")
unset "fruits[2]" # Deletes "banana" (index 2 = second element)
echo "${fruits[@]}" # apple cherry
fruits=("${fruits[@]}") # Compact to remove gapRuby's
delete_at does both halves at once, which is why the second line below has no counterpart in the left column. Quote the subscript — unset "fruits[2]" — or the brackets are read as a glob pattern and the shell goes looking for a matching filename.Brace Expansion Range
Brace expansion writes out a sequence of words:
{1..5} becomes the five words 1 2 3 4 5, and a third field sets the step.(1..5).each { |i| puts i }
(0..10).step(2) { |i| puts i }for i in {1..5}; do
echo $i
done
for i in {0..10..2}; do
echo $i
done🚨 It happens at parse time, before any variable has a value, so
{1..$count} does not produce a range — it produces the literal string {1..$count}, and the loop runs once over that. A variable bound needs the C-style for (( index = 1; index <= count; index++ )) instead. Ruby's Range has no such restriction, since it is an ordinary object built at run time.Associative Arrays
Create & Access
An associative array has to be declared before anything is put in it.
typeset -A is what makes the name a map; there is no literal that creates one on the spot the way { } does in Ruby.person = { name: "Alice", age: 30, language: "Ruby" }
puts person[:name] # Alice
puts person[:age] # 30typeset -A person
person[name]="Alice"
person[age]=30
person[language]="Ruby"
echo ${person[name]} # Alice
echo ${person[age]} # 30Skipping the declaration does not raise anything — the name stays an ordinary scalar and the string subscripts are quietly ignored, which is the usual way this goes wrong. Two further limits worth knowing up front: keys and values are strings and only strings, and iteration order is undefined, where a Ruby hash has preserved insertion order since 1.9. Bash spells the declaration
declare -A, which Zsh also accepts.Initialize with Values
The whole map can be filled in the declaration itself, one
[key]=value pair per word inside parentheses.colors = { red: "#FF0000", green: "#00FF00", blue: "#0000FF" }
puts colors[:red]typeset -A colors=([red]="#FF0000" [green]="#00FF00" [blue]="#0000FF")
echo ${colors[red]}The brackets around each key are mandatory: without them the parentheses are just an ordinary array literal, and the pairs become elements alternating key, value, key, value. This is as close as the shell gets to Ruby's hash literal, and it is the form to reach for whenever the contents are known in advance.
All Keys & Values
The
(k) and (v) flags ask a map for its keys or its values. Without a flag, expanding the name gives the values.scores = { alice: 95, bob: 87, carol: 92 }
puts scores.keys.inspect
puts scores.values.inspecttypeset -A scores=([alice]=95 [bob]=87 [carol]=92)
echo "${(k)scores}" # alice bob carol (order varies)
echo "${(v)scores}" # 95 87 92 (order varies)🚨 The order is not defined and is not insertion order — the two lines below can even disagree with each other about which entry comes first, so never pair them up positionally. When order matters, add
o to sort: ${(ko)scores}. Ruby needs no such precaution, because its hashes have preserved insertion order since 1.9.Check If Key Exists
The
+ just inside the brace asks "does this exist", answering 1 or 0 rather than the value. Wrapping it in (( )) is what turns that number into a condition.colors = { red: "#FF0000", green: "#00FF00" }
puts colors.key?(:red) # true
puts colors.key?(:purple) # falsetypeset -A colors=([red]="#FF0000" [green]="#00FF00")
if (( ${+colors[red]} )); then
echo "red exists"
fi
if (( ! ${+colors[purple]} )); then
echo "purple does not exist"
fi🚨 The obvious-looking
[[ -n ${colors[purple]} ]] is not the same test: it asks whether the value is non-empty, so a key deliberately set to the empty string reports as missing. Ruby keeps the two questions apart as key? and [], and so should you. [[ -v colors[red] ]] is the Bash-compatible spelling of the same existence test.Iterate Key-Value Pairs
There is no loop that hands over a key and a value together. The shell way is to loop over the keys and look each value up in the body — which is what
(@k) produces, one whole key per iteration even if a key contains a space.inventory = { apples: 5, bananas: 3, cherries: 12 }
inventory.each do |item, count|
puts "#{item}: #{count}"
endtypeset -A inventory=([apples]=5 [bananas]=3 [cherries]=12)
for item in "${(@k)inventory}"; do
echo "$item: ${inventory[$item]}"
doneRuby's block takes both at once and needs no lookup, which is the more pleasant arrangement and also the reason Ruby can guarantee an order. Here the order is undefined; adding
o to the flags, as "${(@ko)inventory}", sorts the keys and makes the output reproducible. "${!inventory[@]}" is the Bash-compatible spelling.Delete a Key
The same
unset that deletes a whole variable deletes one entry when given a subscript.settings = { debug: true, verbose: false, timeout: 30 }
settings.delete(:verbose)
puts settings.keys.inspecttypeset -A settings=([debug]=true [verbose]=false [timeout]=30)
unset "settings[verbose]"
echo "${(k)settings}"Unlike the indexed-array case, nothing needs compacting afterwards — a map has no positions to leave a hole in, so the key is simply gone from
${(k)settings} and from the ${+…} existence test. The quotes around "settings[verbose]" are still required, or the brackets are read as a glob and the shell tries to match a filename.Number of Keys
One more use of the counting
#: on a map it returns the number of keys.inventory = { apples: 5, bananas: 3, cherries: 12 }
puts inventory.size # 3typeset -A inventory=([apples]=5 [bananas]=3 [cherries]=12)
echo ${#inventory} # 3That makes three things the same prefix counts — characters in a string, elements in an array, keys in a map — decided entirely by what the variable happens to hold. Ruby uses
.size for all three as well, so the habit carries over intact.Control Flow
if / elif / else
The block is delimited by words rather than punctuation:
then opens it, elif and else divide it, and fi — if spelled backwards — closes it.temperature = 72
if temperature > 85
puts "Hot"
elsif temperature > 65
puts "Comfortable"
else
puts "Cold"
endtemperature=72
if (( temperature > 85 )); then
echo "Hot"
elif (( temperature > 65 )); then
echo "Comfortable"
else
echo "Cold"
fiThe semicolon before
then is not optional. if takes a command, and the shell needs to be told where that command ends; a newline does the job just as well. Ruby's elsif and Zsh's elif differ by one letter, which is the kind of thing that costs ten minutes exactly once.String Comparisons
String tests go inside
[[ ]], which is a shell keyword rather than a command. Its vocabulary is == and != for comparison, -z for "zero length" and -n for "not zero length".language = "ruby"
puts language == "ruby" # true
puts language != "python" # true
puts language.empty? # false
puts language.length > 0 # truelanguage="ruby"
[[ $language == "ruby" ]] && echo "match"
[[ $language != "python" ]] && echo "not python"
[[ -z $language ]] && echo "empty" || echo "not empty"
[[ -n $language ]] && echo "has content"Each line below is a test followed by
&&, so it reads as "if that held, do this" — the compact form a shell prompt encourages. Always prefer [[ ]] to the older single-bracket [ ], which is an ordinary command: an unquoted empty variable disappears from its argument list entirely and it fails with a syntax error rather than answering the question.Numeric Comparisons
Numbers can be compared two ways, and the pairs below do the same job twice: the legacy word-flags
-eq, -ne, -lt, -le, -gt, -ge inside [[ ]], or the C operators inside (( )).x = 5
puts x == 5 # true
puts x < 10 # true
puts x >= 5 # truex=5
[[ $x -eq 5 ]] && echo "equals 5" # Legacy flags
[[ $x -lt 10 ]] && echo "less than 10"
(( x == 5 )) && echo "equals 5" # Modern (( ))
(( x < 10 )) && echo "less than 10"The word-flags exist because
< and > already meant redirection when the test command was designed, so they could not be spent on comparison. Inside (( )) that conflict is gone. Write the arithmetic form in new code, and expect to read the other one everywhere.Logical Operators
&& and || join whole commands, not just tests, so an arithmetic test and a string test can be combined even though they are written in different brackets.age = 25
is_member = true
if age >= 18 && is_member
puts "Access granted"
endage=25
is_member=true
if (( age >= 18 )) && [[ $is_member == "true" ]]; then
echo "Access granted"
fiThis is why the shell has no single all-purpose condition syntax and does not need one: each bracket does the kind of comparison it is good at, and
&& glues the results together. The -a and -o flags inside the old [ ] are the previous generation's answer and are best avoided — they are ambiguous with operands that look like operators.case / esac
Four pieces of punctuation carry this construct:
) closes a pattern, | separates alternatives within one, ;; ends a branch, and esac — case backwards, like fi — ends the whole thing.day = "Monday"
case day
when "Monday", "Tuesday", "Wednesday", "Thursday", "Friday"
puts "Weekday"
when "Saturday", "Sunday"
puts "Weekend"
else
puts "Unknown"
endday="Monday"
case $day in
Monday|Tuesday|Wednesday|Thursday|Friday)
echo "Weekday"
;;
Saturday|Sunday)
echo "Weekend"
;;
*)
echo "Unknown"
;;
esacThe patterns are globs, not regexes, so the bare words below match literally and
* is the catch-all that stands in for Ruby's else. Leaving the final *) off is legal and means "if nothing matched, do nothing" — silently, which is worth a deliberate decision rather than an omission.case with Glob Patterns
Because the patterns are globs,
case is the natural way to branch on a filename: *.pdf is a pattern the shell already knows how to match.filename = "report.pdf"
case filename
when /.txt$/ then puts "Text file"
when /.pdf$/ then puts "PDF document"
when /.(jpg|png)$/ then puts "Image"
else puts "Unknown type"
endfilename="report.pdf"
case $filename in
*.txt) echo "Text file" ;;
*.pdf) echo "PDF document" ;;
*.jpg|*.png) echo "Image" ;;
*) echo "Unknown type" ;;
esacThe Ruby column has to reach for regexps and anchor each one with
$; the glob needs no anchor because it must match the whole word by definition. Regexps are the more powerful tool, but for the job below — dispatch on an extension — the glob says exactly as much and reads like the filenames it matches.Short-Circuit Evaluation
🚨 The shell decides truth by exit code, and the polarity is the reverse of nearly every language: 0 means success and anything else means failure.
&& runs its right side when the left one succeeded; || runs it when the left one failed.dir = "/tmp"
File.exist?(dir) && puts("#{dir} exists")
# Or:
puts "Dir exists" if File.exist?(dir)dir="/tmp"
# && runs right side only if left side succeeds (exit code 0)
[[ -d $dir ]] && echo "$dir exists"
# || runs right side only if left side fails
[[ -d /nonexistent ]] || echo "Does not exist"The reason for the inversion is that there is only one way to succeed and many ways to fail, so the failures get the numbers. Chaining the two as
test && on-success || on-failure looks like a ternary and is not one: if the success branch itself fails, the failure branch runs too and both outputs appear. Ruby's trailing if in the left column has no such edge.Loops
for-in Loop
A
for loop walks a list of words. Written out as literals, as below, the words are simply whatever is separated by spaces — no quotes, no commas, no brackets.["red", "green", "blue"].each do |color|
puts color
endfor color in red green blue; do
echo $color
doneThat list is still ordinary shell text, so it goes through glob expansion on the way in: a word containing
* turns into matching filenames rather than staying a literal. This is a feature when looping over *.txt and a surprise otherwise. When the list comes from an array, quote it — for item in "${array[@]}" — as the Arrays section spells out.C-Style for Loop
The doubled parentheses put all three clauses in arithmetic context, so the variable is written bare —
i, not $i — in every one of them.5.times { |i| puts i }
0.upto(4) { |i| puts i }for (( i=0; i<5; i++ )); do
echo $i
doneThis is the form to use whenever the bound is a variable, because brace expansion cannot be. The loop variable survives the loop and holds the value that ended it, which is occasionally useful and occasionally the source of a stale value two screens further down. Ruby's
.times block scopes its index and has neither property.while Loop
The condition is a command, not an expression, and the loop continues while that command keeps succeeding.
(( )) is the command to use when the condition is arithmetic.count = 0
while count < 5
puts count
count += 1
endcount=0
while (( count < 5 )); do
echo $count
(( count++ ))
doneBecause it is a command, anything at all can serve as a condition —
while read -r line loops until the read fails at end of input, which no expression-based while can express as briefly. The ; before do is the same requirement as before then: the shell has to be told where the condition command ends.until Loop
until is while with the sense reversed: it keeps going for as long as the condition keeps failing, and stops the moment it succeeds.count = 0
until count >= 5
puts count
count += 1
endcount=0
until (( count >= 5 )); do
echo $count
(( count++ ))
doneRuby has the same pair with the same inversion, and the advice is the same in both: it earns its place when the natural way to say the condition is positive — "until the file appears", "until the count reaches five" — and costs the reader a mental negation the rest of the time.
break & continue
break leaves the loop and continue skips to the next iteration — Ruby's break and next under different names.(1..10).each do |i|
next if i.even?
break if i > 7
puts i
endfor (( i=1; i<=10; i++ )); do
(( i % 2 == 0 )) && continue
(( i > 7 )) && break
echo $i
doneBoth take an optional count, so
break 2 leaves the two innermost loops at once. Ruby has no equivalent and needs a flag variable or a catch/throw to get out of nested loops, which makes this one of the few places the shell is the more expressive of the two.Brace Expansion in Loops
Brace ranges count through letters as readily as numbers, so
{a..e} is a five-word list. A third field is the step.('a'..'e').each { |letter| puts letter }
(1..10).step(3) { |n| puts n }for letter in {a..e}; do
echo $letter
done
for n in {1..10..3}; do
echo $n
doneRuby needs two different constructs for the two loops below — a
Range of strings for the letters and .step for the numbers — where the shell spells both the same way. The catch is the one from the Arrays section: the range is written out before any variable has a value, so a computed bound needs a C-style loop instead.Read Lines of a File
The shell has no "for each line" loop. It has
read, which consumes one line and fails at end of input — so an ordinary while around it walks the whole file, with the file attached to the loop by < filename at the very end.File.write("/tmp/cczshlines.txt", "one\ntwo\nthree\n")
File.foreach("/tmp/cczshlines.txt") do |line|
puts "Line: #{line.chomp}"
endprint -l one two three > /tmp/cczshlines.txt
while IFS= read -r line; do
print "Line: $line"
done < /tmp/cczshlines.txtBoth halves of
while IFS= read -r line are protective and both are worth typing every time. Setting IFS to nothing for this one command stops leading and trailing whitespace being trimmed off each line; -r stops a backslash in the data from being treated as an escape. Without them, this loop quietly mangles exactly the input you would most want it not to.Functions
Define & Call a Function
A function has no parameter list. The
() after the name is punctuation and stays empty; arguments arrive as $1, $2 and so on, and the function is called like any other command — name, space, arguments, no parentheses.def greet(name)
puts "Hello, #{name}!"
end
greet("Alice")
greet("Bob")greet() {
echo "Hello, $1!"
}
greet "Alice"
greet "Bob"The upside of a function being just another command is that it can be used anywhere a command can: in a pipeline, as a condition, in place of a program that is not installed. The downside is the one visible below — the signature says nothing about what the function expects, so the first line of the body is often
local name="$1" purely to give the argument a readable name. function greet { … } is an accepted alternative spelling.Parameters & Argument Count
Every function gets the same two shorthands:
$# is how many arguments arrived, and $@ is all of them. They are scoped to the function, so the caller's own arguments are untouched.def describe(*args)
puts "Got #{args.length} arguments: #{args.join(", ")}"
end
describe("one", "two", "three")describe() {
echo "Got $# arguments: $@"
}
describe "one" "two" "three"Quote it as
"$@" whenever the arguments are being passed on to something else — that is the spelling that keeps each argument intact, spaces and all, and it is the reason "$@" appears so often in wrapper functions. Its unquoted twin $* welds everything into one string. Ruby's *args is a real array and needs no such care.Local Variables
🚨 Variables inside a function are global by default.
local is what keeps an assignment from reaching out and changing the caller's variable of the same name.counter = 10
def increment
counter = 0 # Local — does not affect outer
counter += 1
puts "Inside: #{counter}"
end
increment
puts "Outside: #{counter}"counter=10
increment() {
local counter=0 # Must use 'local' keyword
(( counter++ ))
echo "Inside: $counter"
}
increment
echo "Outside: $counter"This is the reverse of every default Ruby has, and it fails quietly: without the
local below, increment would set the outer counter to 1 and the last line would print Outside: 1. Nothing warns. The habit worth forming is to declare every working variable in a function local on first use, whether or not anything outside currently shares the name.Return Values
A function does not return a value. It returns an exit code — a number from 0 to 255 where 0 means success — taken from its last command, or from an explicit
return n.def is_even?(number)
number.even?
end
if is_even?(4)
puts "4 is even"
endis_even() {
(( $1 % 2 == 0 )) # Returns 0 (true) if even, 1 (false) if odd
}
if is_even 4; then
echo "4 is even"
fi
if ! is_even 7; then
echo "7 is odd"
fiThat is why the function below is a single arithmetic test with nothing around it:
(( )) already produces the right exit code, so the function is a predicate without ever saying so. Note the double inversion at work — the arithmetic is true when non-zero, and the exit code is true when zero — which cancels out and reads correctly, but is worth tracing once. return can only carry a number; a string needs the next row.Returning a String
Since
return can only carry an exit code, a function that has to hand back text prints it instead, and the caller catches that output with $( ) — command substitution, which runs the command and expands to whatever it wrote.def double(number)
number * 2
end
result = double(21)
puts result # 42double() {
print $(( $1 * 2 ))
}
result=$(double 21)
echo $result # 42So a shell function's "return value" is its standard output, and the two are impossible to tell apart: a stray
echo left in for debugging becomes part of the value and corrupts it silently. That is the cost of the arrangement, and the benefit is that any command at all can be used this way, not only functions. The older workaround — assigning to an agreed global and reading it after the call — avoids the extra process and is still common in performance-sensitive scripts.Default Parameter Values
With no parameter list, there is nowhere to put a default — so it goes in the body, using the
${1:-World} expansion from the Variables section.def greet(name = "World")
puts "Hello, #{name}!"
end
greet # Hello, World!
greet("Alice") # Hello, Alice!greet() {
local name="${1:-World}"
echo "Hello, $name!"
}
greet # Hello, World!
greet "Alice" # Hello, Alice!The
local matters as much as the default does: it both names the argument and keeps it from escaping. Ruby's def greet(name = "World") says the same thing in the signature, where a reader looking for the function's interface will actually find it. This is the shell's biggest readability gap, and the reason a shell function's first few lines are so often nothing but unpacking.Recursive Function
A recursive function calls itself the same way anything else calls it — by name — and reads the result back through
$( ). The last line nests three of them: the multiplication, the recursive call, and the subtraction that feeds it.def factorial(number)
return 1 if number <= 1
number * factorial(number - 1)
end
puts factorial(5) # 120factorial() {
if (( $1 <= 1 )); then
print 1
return
fi
print $(( $1 * $(factorial $(( $1 - 1 ))) ))
}
print $(factorial 5) # 120It reads almost like the Ruby, which is a recent luxury: before command substitution could be captured, the result had to be passed back through an agreed global variable and the whole shape of the function changed. The cost is that every level of recursion is a whole extra process, so this is fine for a demonstration and the wrong tool for real recursive work — a hundred levels deep means a hundred processes.
Nameref Variables (Reference Parameters)
A function cannot be handed an array; it can only be handed the array's NAME as text, and then reach back through that name to the caller's variable. That indirection is what the
eval below is doing.def push_item(collection, item)
collection << item
end
items = []
push_item(items, "apple")
puts items.inspect # ["apple"]push_item() {
local collection_name=$1 # the NAME of the caller's array
eval "${collection_name}+=( "$2" )"
}
items=()
push_item items "apple"
echo "${items[@]}" # appleZsh has no
typeset -n nameref — that is a Bash and ksh feature, and Zsh 5.9 rejects it outright with bad option: -n. Writing through a name therefore means eval, with all the quoting care that implies; reading through one is safer and needs no eval, via the ${(P)name} flag. Ruby needs none of this, because items is already a reference to the same array the caller holds.Pipelines
Chaining Commands
A pipeline joins one command's output to the next command's input.
print -l writes one word per line and sort reads lines, so what travels down the pipe is text — specifically, lines of it.words = %w[delta alpha charlie]
puts words.sortprint -l delta alpha charlie | sortThat is the whole difference from the Ruby column, and it cuts both ways. Ruby passes an Array from method to method, so the elements are never re-examined; the shell passes bytes, so every stage parses the text again and a value containing a newline arrives as two items. In exchange, any stage can be replaced by any program in the world that reads standard input, which is why a shell pipeline can do things no single language's standard library covers.
Keeping Some Lines
grep is the pipeline's filter: it reads lines and passes along only the ones matching its pattern.puts %w[apple banana cherry].grep(/an/)print -l apple banana cherry | grep anRuby's
Enumerable#grep is named after this command and does the same job on objects. The pattern here is a POSIX regular expression rather than a Ruby one, so the shorthand classes are spelled out — [0-9], not \d. Useful flags: -v inverts the test, -c counts instead of printing, and -q prints nothing and answers through the exit code.Transforming Every Line
tr translates characters, one for one, as they pass through. The two arguments are the set to look for and the set to replace it with.puts %w[alpha beta].map(&:upcase)print -l alpha beta | tr '[:lower:]' '[:upper:]'It is the narrowest tool in this section — it knows nothing about lines, words or patterns, only characters — which is exactly why it is the fastest way to change case or to delete a character class outright with
tr -d. Zsh can do this particular job without a pipeline at all, using the ${(U)word} flag from the String Operations section; reach for tr when the text is already flowing past.Sorting and Deduplicating
sort orders the lines it is given, and -u makes it drop duplicates while it is at it.puts %w[pear apple pear fig].sort.uniqprint -l pear apple pear fig | sort -uThe
-u is not merely shorter than a separate | uniq — it is necessary, because uniq only removes adjacent duplicates and is therefore useless on unsorted input. Ruby's .uniq has no such precondition. Two flags worth remembering: -n sorts numerically, without which 10 sorts before 9, and -r reverses.Taking the First Few
seq counts, one number per line, and head -n 3 passes on the first three lines and stops.puts (1..100).first(3)seq 1 100 | head -n 3Stopping is the interesting part:
head closes the pipe once it has what it needs, and the stage feeding it is killed rather than left to finish. That is what makes | head safe in front of a command that would otherwise produce millions of lines — it is lazy in the same way Ruby's .lazy.first(3) is, without having to ask. tail -n 3 is the other end, and it cannot be lazy, because the last three lines are not knowable until the input ends.Capturing a Pipeline into an Array
Read the Zsh line from the inside out:
$( ) runs the pipeline and expands to everything it printed as one long string, the (f) flag splits that string on newlines, and the surrounding parentheses make the result an array.sorted = %w[pear apple fig].sort
puts "#{sorted.length} lines"
puts "first is #{sorted.first}"sorted=("${(f)$(print -l pear apple fig | sort)}")
print "${#sorted} lines"
print "first is ${sorted[1]}"The double quotes look wrong next to the warning that flags do nothing inside quotes, and they are right here for a different reason: they stop ordinary word splitting from cutting the text at every space, leaving
(f) as the only thing doing any splitting. Drop them and a line containing a space becomes two elements. This is the bridge between the two halves of the shell — a pipeline on one side, a real array with a length and an index on the other.Adding Up a Pipeline
A
while read loop can be a pipeline stage, consuming lines one at a time. This works in Zsh because the last stage of a pipeline runs in the current shell, so the total the loop builds is still there on the line after done.puts "total: #{(1..10).sum}"total=0
seq 1 10 | while read -r number; do
(( total += number ))
done
print "total: $total"🚨 That rule is Zsh's, not the shell's in general. Bash runs every stage in a subshell, so the identical script there prints
total: 0 — the loop counts correctly and then the process holding the answer exits. It is the single most common way a working Zsh script breaks when it is run under Bash. The portable spelling feeds the loop from a redirect instead of a pipe: done < <(seq 1 10).Writing Your Own Stage
A function is a command, so it can be a pipeline stage. It takes no argument for the input — it reads standard input, the same way
sort and grep do.def shout(words)
words.map { |word| "#{word.upcase}!" }
end
puts shout(%w[alpha beta])shout() {
while read -r line; do
print "${(U)line}!"
done
}
print -l alpha beta | shoutNothing marks
shout as a filter; it simply reads until the input runs out, and that is the entire contract every stage in this section obeys. The Ruby method has to be handed its collection and hands one back, which makes it composable with other Ruby and with nothing else. This one composes with every program on the machine — and, on this page, with fifteen real coreutils compiled into the browser runtime.Pattern Matching
Glob Pattern Matching
A glob has to match the whole word, so
*.txt is a suffix test and report* is a prefix test with no anchoring characters needed. * stands for any run of characters, ? for exactly one.filename = "report_2026.txt"
if filename.end_with?(".txt")
puts "Text file"
end
if filename.start_with?("report")
puts "Is a report"
endfilename="report_2026.txt"
if [[ $filename == *.txt ]]; then
echo "Text file"
fi
if [[ $filename == report* ]]; then
echo "Is a report"
fiRuby needs a differently named method for each end of the string; the shell needs only a differently placed
*. Both tests below are cheap enough to use freely — no regular-expression engine is involved. As always inside [[ ]], quoting the right-hand side turns the pattern back into a literal and the test stops matching.Regular Expression Matching
When a glob is not enough,
=~ matches against a real regular expression — a POSIX extended one, so ^ and $ anchor it and the shorthand classes are spelled out.email = "user@example.com"
if email.match?(/\A[\w.%+-]+@[\w.-]+\.[a-z]{2,}\z/i)
puts "Valid email"
endemail="user@example.com"
if [[ $email =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
echo "Valid email"
fi🚨 There is no
\d, \w or \s in POSIX ERE; write [0-9], [A-Za-z0-9_] and [[:space:]]. A pattern lifted from Ruby that leans on those will not error — it will match the literal letters d, w and s, and simply fail to match anything real. Leave the pattern unquoted, or it becomes a literal string comparison.Regex Capture Groups
A successful
=~ leaves its capture groups behind in an array called match, and being a Zsh array it is 1-indexed — so ${match[1]} is the first parenthesized group, exactly as Ruby numbers them.version = "2.3.1"
if (match = version.match(/^(\d+)\.(\d+)\.(\d+)$/))
puts "Major: #{match[1]}"
puts "Minor: #{match[2]}"
puts "Patch: #{match[3]}"
endversion="2.3.1"
if [[ $version =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
echo "Major: ${match[1]}"
echo "Minor: ${match[2]}"
echo "Patch: ${match[3]}"
fiThe whole matched text is in
$MATCH separately, which is the slot Ruby fills with match[0]. Both are overwritten by the next =~ and are only meaningful while the if that tested them is still in scope, so copy anything needed later. Bash calls its version BASH_REMATCH and numbers it from 0 — a script ported between the two needs both changes.Extended Glob Patterns
Ordinary globs cannot say "either of these".
setopt EXTENDED_GLOB turns on a richer pattern language in which (txt|md) matches exactly one of the alternatives.filename = "config.yaml"
unless filename.match?(/\.(txt|md)$/)
puts "Not a text or markdown file"
endsetopt EXTENDED_GLOB
filename="config.yaml"
if [[ $filename != *.(txt|md) ]]; then
echo "Not a text or markdown file"
fiThe option is off by default and is per-script, so it belongs at the top of any file that relies on it — a pattern using
( | ) without it is read as a literal and quietly matches nothing. The same switch also brings ^pattern for negation and #/## as repetition operators, which between them cover most of what a regular expression would be doing on a filename.Pattern Matching in case
case takes globs and only globs, so case-insensitivity has to be built by hand out of character classes: [Yy] matches either letter, and a whole word is spelled one bracket at a time.input = "yes"
case input
when /^y(es)?$/i then puts "Affirmative"
when /^no?$/i then puts "Negative"
else puts "Unknown"
endinput="yes"
case $input in
[Yy]|[Yy][Ee][Ss]) echo "Affirmative" ;;
[Nn]|[Nn][Oo]) echo "Negative" ;;
*) echo "Unknown" ;;
esacIt looks laborious next to Ruby's
/^y(es)?$/i, and it is — but it is also the standard shell idiom for a yes/no prompt, so it reads as boilerplate rather than as cleverness. When a pattern grows past this, that is the signal to switch to [[ $input =~ … ]] and a real regular expression.Error Handling
Exit Codes & $?
There are no exceptions in the shell. Every command leaves behind an exit code in
$? — 0 for success, anything else for failure — and that number is the entire error-reporting mechanism.begin
raise "Something went wrong"
rescue => error
puts "Error: #{error.message}"
puts "Handling it..."
endfalse # A command that always fails (exit code 1)
echo "Exit code: $?"
true # A command that always succeeds (exit code 0)
echo "Exit code: $?"Read
$? immediately or not at all: it belongs to the command that just finished, so the echo printing it has already replaced it by the time the next line runs. true and false are real builtins that exist for no other purpose than to produce 0 and 1. Ruby's exceptions carry a class, a message and a backtrace; a shell script has one number and whatever the command printed on its way out.Automatic Exit on Error
By default a failing command is simply noted in
$? and the script carries on. set -e changes that to "stop at the first failure", and set +e — a plus, not a minus — turns it back off.# Ruby raises exceptions by default; you must handle them explicitly
def risky
raise "Oops"
end
# risky # Would propagate and crash unless rescuedset -e # Exit immediately if any command fails
echo "Step 1"
echo "Step 2"
# false # Uncommenting this would exit the script here
echo "Step 3"
set +e # Turn off strict modeThis is the shell opting into the behavior Ruby has by default: an unhandled failure ends the program instead of being ignored. It has famous exceptions of its own — a command whose failure is already being handled by
if, && or || does not trigger it — so it is a safety net rather than a guarantee. The usual production incantation is set -euo pipefail, adding the unset-variable check from the next row.Unset Variable Protection
A misspelled variable name is not an error in the shell — it expands to nothing at all, and the script continues with a hole where the value should be.
set -u makes that a fatal error instead.# Ruby raises NameError for undefined variables
# undefined_variable # => NameError: undefined local variableset -u # Treat unset variables as errors
name="Alice"
echo "Hello, $name"
# echo "Hello, $undefined" # Would cause: unbound variable
set +uThe damage this prevents is not subtle:
rm -rf "$prefix/" with a typo in prefix becomes rm -rf "/". Ruby raises NameError for the same mistake without being asked. Where a variable is legitimately optional, ${name:-} supplies a default and keeps set -u happy.Error Handling with ||
With no
rescue to reach for, the shell's error handler is || followed by a brace group: run this, and if it fails, run all of that.def connect(host)
raise "Connection failed to #{host}" unless host == "localhost"
"Connected to #{host}"
end
begin
result = connect("remotehost")
rescue => error
puts "Error: #{error.message}"
puts "Using fallback"
endconnect() {
[[ $1 == "localhost" ]] || return 1
echo "Connected to $1"
}
connect "remotehost" || {
echo "Connection failed — using fallback"
}The braces are what let more than one command hang off the
||, and they need the semicolon before the closing brace — { echo one; echo two; } — because a brace group is a list of commands rather than an expression. For a single fallback command the braces can go entirely. The equivalent if ! connect "remotehost"; then … fi says the same thing and takes three lines.Cleanup with trap
trap attaches a command to an event. EXIT is the event that fires when the script ends, however it ends — normally, on an error, or through set -e.at_exit { puts "Cleanup!" }
puts "Working..."
# Cleanup! is printed when the script exitscleanup() {
echo "Cleanup!"
}
trap cleanup EXIT # Run cleanup() when the script exits
echo "Working..."
# "Cleanup!" prints when the script reaches the endThat "however it ends" is what makes it the right place for removing a temporary directory or releasing a lock; Ruby's
at_exit and ensure exist for the same reason. The other events worth knowing are INT for Ctrl-C and ZERR, which fires whenever any command fails — Zsh's spelling of what Bash calls ERR.Output & Formatting
echo vs printf
The split is the same one Ruby draws between
puts and print: echo adds a newline, printf adds nothing you did not ask for and takes a C format string.puts "Hello, World!" # Adds newline
print "Hello, World!" # No newline
printf "Name: %s
", "Alice"echo "Hello, World!" # Adds newline
printf "Hello, World!" # No newline
printf "Hello, World!
" # With explicit newline
printf "Name: %s
" "Alice"Prefer
printf in scripts. echo's handling of leading dashes and backslash escapes differs between shells and even between builds of the same shell, so a string that begins with -n or contains \t is not portable; printf '%s\n' has no such ambiguity. Zsh's own print builtin is a third option with useful extras, notably print -l for one argument per line.printf Format Strings
The format string is C's, so it is the one already known from Ruby:
%s, %d, %f, %x, %o, with a width before the letter and a precision after a dot. A leading minus left-aligns.printf("%-10s %5d %8.2f
", "Alice", 30, 98.6)
printf("Hex: %x, Oct: %o
", 255, 255)printf "%-10s %5d %8.2f
" "Alice" 30 98.6
printf "Hex: %x, Oct: %o
" 255 255The only real difference below is punctuation — Ruby separates its arguments with commas, the shell with spaces. One shell-specific bonus: if more arguments are supplied than the format uses,
printf starts the format over and consumes them all, which is how printf "%s\n" "${array[@]}" prints an entire array one element per line.ANSI Color Codes
Color is not a shell feature — it is text the terminal interprets. Each sequence is the escape character (
\033, or \e) followed by [, some numbers, and m: 30–37 pick a foreground, 40–47 a background, 1 is bold and 0 resets everything.puts "e[32mGreen texte[0m"
puts "e[1;34mBold bluee[0m"
puts "e[31;47mRed on whitee[0m"printf "\033[32mGreen text\033[0m\n"
printf "\033[1;34mBold blue\033[0m\n"
printf "\033[31;47mRed on white\033[0m\n"Because it is only text, the two columns are the same sequences in both languages, and the same ones any other language would emit. Always emit the reset at the end — a script that exits without it leaves the user's prompt colored. Zsh has a friendlier
%F{green}…%f spelling, but only inside prompts and print -P.Formatted Table Output
A table is one format string used several times. Keeping it in a variable is what guarantees the header and the rows line up, since there is only one set of widths to change.
header = "%-12s %8s %10s"
printf(header + "
", "Name", "Score", "Grade")
printf("-" * 32 + "
")
printf(header + "
", "Alice", 95, "A")
printf(header + "
", "Bob", 87, "B")header="%-12s %8s %10s
"
printf "$header" "Name" "Score" "Grade"
printf "%s
" "--------------------------------"
printf "$header" "Alice" "95" "A"
printf "$header" "Bob" "87" "B"Left-align with a minus (
%-12s) and right-align without one (%8s) — names read better on the left, numbers on the right. Quote the variable as "$header"; unquoted, the format string is split on its spaces and only the first piece is used as a format. This is the whole of table formatting in a shell, with no column or awk involved.Special Shell Variables
A handful of variables are set by the shell itself and are always there:
$0 is the script or function name, $$ the process id, $? the last exit code, and $ZSH_VERSION and $HOST describe where the script is running.puts $0 # Script name (in Ruby: $PROGRAM_NAME)
puts $$ # Process ID (in Ruby: Process.pid)
puts $?.to_i # Last exit code (in Ruby: after system())echo "Script name: $0"
echo "Process ID: $$"
echo "Zsh version: $ZSH_VERSION"
echo "Hostname: $HOST"
false
echo "Last exit: $?"Two more are worth knowing because they have no Ruby counterpart at all:
$RANDOM hands back a fresh random integer every time it is read, and $SECONDS counts up from the moment the shell started, which makes timing a script a subtraction. $ZSH_VERSION is also the cleanest way to tell which shell a script has ended up in — Bash sets $BASH_VERSION instead, and neither sets the other's.Here-Documents
A here-document is a block of literal text fed to a command's standard input.
<<EOF opens it, a line containing nothing but EOF closes it, and variables inside are expanded as they would be in double quotes.name = "Yukihiro"
order_id = 42
text = <<~HEREDOC
Dear #{name},
Your order ##{order_id} has shipped.
Thanks!
HEREDOC
puts textname="Yukihiro"
order_id=42
cat <<EOF
Dear $name,
Your order #$order_id has shipped.
Thanks!
EOFThe difference from Ruby's heredoc is where the text goes: Ruby's is a string and can be assigned, while the shell's is plumbing — it is standard input for the command on the first line, which is why
cat is there at all. Quoting the delimiter as <<'EOF' turns expansion off, matching Ruby's <<~'HEREDOC', and <<-EOF strips leading tabs so the block can be indented with the code around it.