PONYλM2Modula-2

Ruby.CodeCompared.To/Zsh

An interactive executable cheatsheet for Rubyists learning Zsh

Ruby 4.0 Zsh 5.9
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 number
greeting="Hello" number=42 echo $greeting echo $number
Variable 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}" # Interpolation
name="World" echo 'Hello, $name' # No interpolation — literal $name echo "Hello, $name" # Interpolation
Double 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 port
echo "${port:-8080}" # port is unset, so prints: 8080 port=3000 echo "${port:-8080}" # port is set, so prints: 3000
That 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 $count
This 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 constant
typeset -r PI=3.14159 echo $PI # PI=3.0 # would produce: zsh: read-only variable: PI
Zsh 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 count
typeset -i count=0 count+=5 # Arithmetic context — adds 5 count+="hello" # Non-numeric string treated as 0 echo $count
That 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 # HELLO
typeset -l lowered="HELLO WORLD" typeset -u uppered="hello world" echo $lowered # hello world echo $uppered # HELLO WORLD
The 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 # 13
message="Hello, World!" echo ${#message} # 13
The 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 mat
text="the cat sat on the mat" echo ${text/at/og} # the cog sat on the mat
Ruby'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 mog
text="the cat sat on the mat" echo ${text//at/og} # the cog sog on the mog
Ruby 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/ruby
path="/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" end
sentence="The quick brown fox" if [[ $sentence == *"quick"* ]]; then echo "Found it" fi
Note 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 $combined
A + 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-three
words=("one" "two" "three") echo ${(j:, :)words} # one, two, three echo ${(j:-:)words} # one-two-three
This 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.rstrip
text=" 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 # 256
echo $(( 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 # 13
x=10 (( x += 5 )) (( x -= 2 )) echo $x # 13
As 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 # 2
counter=0 (( counter++ )) echo $counter # 1 (( ++counter )) echo $counter # 2 (( counter-- )) echo $counter # 1
The 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" fi
The 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 result
x=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.1416
pi=3.14159265 printf "%.2f " $pi # 3.14 printf "%.4f " $pi # 3.1416
Ruby'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 # 42
let "product = 6 * 7" echo $product # 42 let "x = 2 ** 10" echo $x # 1024
Everything 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] # cherry
fruits=("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 # 3
fruits=("apple" "banana" "cherry") echo ${#fruits} # 3
Bash 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.inspect
fruits=("apple" "banana" "cherry") echo "${fruits[@]}" # apple banana cherry printf "%s " "${fruits[@]}" # one per line
Its 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.inspect
fruits=("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 end
fruits=("apple" "banana" "cherry") for fruit in "${fruits[@]}"; do echo "$fruit" done
The 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}" end
fruits=("apple" "banana" "cherry") for index in "${(@k)fruits}"; do echo "$index: ${fruits[$index]}" done
The 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 gap
Ruby'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] # 30
typeset -A person person[name]="Alice" person[age]=30 person[language]="Ruby" echo ${person[name]} # Alice echo ${person[age]} # 30
Skipping 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.inspect
typeset -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) # false
typeset -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}" end
typeset -A inventory=([apples]=5 [bananas]=3 [cherries]=12) for item in "${(@k)inventory}"; do echo "$item: ${inventory[$item]}" done
Ruby'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.inspect
typeset -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 # 3
typeset -A inventory=([apples]=5 [bananas]=3 [cherries]=12) echo ${#inventory} # 3
That 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 fiif spelled backwards — closes it.
temperature = 72 if temperature > 85 puts "Hot" elsif temperature > 65 puts "Comfortable" else puts "Cold" end
temperature=72 if (( temperature > 85 )); then echo "Hot" elif (( temperature > 65 )); then echo "Comfortable" else echo "Cold" fi
The 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 # true
language="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 # true
x=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" end
age=25 is_member=true if (( age >= 18 )) && [[ $is_member == "true" ]]; then echo "Access granted" fi
This 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 esaccase 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" end
day="Monday" case $day in Monday|Tuesday|Wednesday|Thursday|Friday) echo "Weekday" ;; Saturday|Sunday) echo "Weekend" ;; *) echo "Unknown" ;; esac
The 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" end
filename="report.pdf" case $filename in *.txt) echo "Text file" ;; *.pdf) echo "PDF document" ;; *.jpg|*.png) echo "Image" ;; *) echo "Unknown type" ;; esac
The 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 end
for color in red green blue; do echo $color done
That 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 done
This 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 end
count=0 while (( count < 5 )); do echo $count (( count++ )) done
Because 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 end
count=0 until (( count >= 5 )); do echo $count (( count++ )) done
Ruby 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 end
for (( i=1; i<=10; i++ )); do (( i % 2 == 0 )) && continue (( i > 7 )) && break echo $i done
Both 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 done
Ruby 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}" end
print -l one two three > /tmp/cczshlines.txt while IFS= read -r line; do print "Line: $line" done < /tmp/cczshlines.txt
Both 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" end
is_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" fi
That 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 # 42
double() { print $(( $1 * 2 )) } result=$(double 21) echo $result # 42
So 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) # 120
factorial() { if (( $1 <= 1 )); then print 1 return fi print $(( $1 * $(factorial $(( $1 - 1 ))) )) } print $(factorial 5) # 120
It 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[@]}" # apple
Zsh 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.sort
print -l delta alpha charlie | sort
That 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 an
Ruby'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.uniq
print -l pear apple pear fig | sort -u
The -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 3
Stopping 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 | shout
Nothing 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" end
filename="report_2026.txt" if [[ $filename == *.txt ]]; then echo "Text file" fi if [[ $filename == report* ]]; then echo "Is a report" fi
Ruby 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" end
email="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]}" end
version="2.3.1" if [[ $version =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then echo "Major: ${match[1]}" echo "Minor: ${match[2]}" echo "Patch: ${match[3]}" fi
The 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" end
setopt EXTENDED_GLOB filename="config.yaml" if [[ $filename != *.(txt|md) ]]; then echo "Not a text or markdown file" fi
The 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" end
input="yes" case $input in [Yy]|[Yy][Ee][Ss]) echo "Affirmative" ;; [Nn]|[Nn][Oo]) echo "Negative" ;; *) echo "Unknown" ;; esac
It 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..." end
false # 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 rescued
set -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 mode
This 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 variable
set -u # Treat unset variables as errors name="Alice" echo "Hello, $name" # echo "Hello, $undefined" # Would cause: unbound variable set +u
The 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" end
connect() { [[ $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 exits
cleanup() { echo "Cleanup!" } trap cleanup EXIT # Run cleanup() when the script exits echo "Working..." # "Cleanup!" prints when the script reaches the end
That "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 255
The 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 text
name="Yukihiro" order_id=42 cat <<EOF Dear $name, Your order #$order_id has shipped. Thanks! EOF
The 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.