PONYλM2Modula-2

Ruby.CodeCompared.To/Bash

An interactive executable cheatsheet for Rubyists learning Bash

Ruby 4.0 Bash 5.3
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, Bash has no separate integer or string types at the variable level — everything is a string unless declare -i says otherwise, which is why greeting="Hello" and number=42 are the same kind of statement here and two different kinds in Ruby.
Curly Brace Expansion
Bash 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 five 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; Bash'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
declare 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
declare -r PI=3.14159 echo $PI # PI=3.0 # would produce: bash: PI: readonly variable
Bash 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. readonly PI=3.14159 is an equivalent spelling, and in Zsh the same declaration is written typeset -r.
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
declare -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
declare -l lowered="HELLO WORLD" declare -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 — Bash 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 ${#fruits[@]} counts elements rather than characters — so ${#…} 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. A negative offset counts back from the end, but it needs a space in front of the minus (${text: -3}), since :- already means "or this default" and the shell would otherwise read it that way.
Uppercase & Lowercase
Case modification is punctuation appended to the variable name: doubling it acts on the whole string and using it once acts only on the first character. ^ raises, , lowers.
greeting = "Hello, World!" puts greeting.upcase # HELLO, WORLD! puts greeting.downcase # hello, world! puts greeting.capitalize # Hello, world!
greeting="Hello, World!" echo ${greeting^^} # HELLO, WORLD! echo ${greeting,,} # hello, world! echo ${greeting^} # Hello, World! (first char only) echo ${greeting,} # hELLO, WORLD! (first char lowercase)
The mnemonic is that ^ points up and , hangs down. All four arrived in Bash 4, so a script that must run on macOS's system /bin/bash — still 3.2, for licensing reasons — cannot use them. Ruby's .upcase, .downcase and .capitalize are the equivalents, and Zsh spells the same operations ${(U)var} and ${(L)var} instead.
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; Bash 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 writing == 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 #{}.
Build a Repeated String
There is no repetition operator either, so a repeated string is built the long way: a counted loop appending one copy at a time with +=.
puts "-" * 20 puts "ha" * 3
separator="" for (( i=0; i<20; i++ )); do separator+="-" done echo $separator repeated="" for (( i=0; i<3; i++ )); do repeated+="ha" done echo $repeated
Ruby's "-" * 20 is one expression against four lines, and this is the row where the shell simply loses. There is a shorter trick — printf '%.0s-' {1..20} abuses a zero-width format to print the literal twenty times — but it is more obscure than the loop and no faster, so the loop is what real scripts contain.
Split a String (Manual)
Splitting is not an operation you call — it is something the shell does on its own, at every word, using the characters in IFS. To split on a comma you change IFS and then let it happen. Here the whole line is fed to read as a here-string (<<<) and caught in an array.
csv_line = "alice,bob,charlie" parts = csv_line.split(",") puts parts.inspect puts parts[1]
csv_line="alice,bob,charlie" IFS="," read -ra parts <<< "$csv_line" echo "${parts[@]}" echo "${parts[1]}"
Writing IFS="," in front of the command rather than on its own line scopes the change to that one command, which is the habit that avoids a mysterious bug three functions later. The two flags earn their place too: -r stops backslashes being treated as escapes, and -a reads into an array instead of separate variables. Ruby's String#split takes one argument and has none of this reach.
Trim Whitespace (Manual)
Bash 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.
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. Real floating point means shelling out to bc or awk.
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
Bash 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 function, 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. Anything more — actual float arithmetic — means shelling out to bc or awk: a whole second program, and a process to go with it, to multiply two numbers.
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 (( )).
Indexed Arrays
Create & Access
An array literal is a parenthesized list of words, and an element is read with braces and a subscript. Bash arrays are 0-indexed, like Ruby's.
fruits = ["apple", "banana", "cherry"] puts fruits[0] # apple puts fruits[1] # banana puts fruits[-1] # cherry
fruits=("apple" "banana" "cherry") echo ${fruits[0]} # apple echo ${fruits[1]} # banana echo ${fruits[-1]} # cherry (bash 4.1+)
🚨 The braces are not optional here. Writing $fruits without them does not expand to the array — it silently gives you element 0 alone, which looks like working code right up until the array has more than one thing in it. (Zsh differs on both counts: its arrays start at 1, and a bare $array there means every element.) Negative indices count from the end as in Ruby, and arrived in Bash 4.1.
Array Length
The same counting # that measured a string measures an array — but it needs the [@] subscript, which is what says "all of the elements" rather than "the first one".
fruits = ["apple", "banana", "cherry"] puts fruits.length # 3
fruits=("apple" "banana" "cherry") echo ${#fruits[@]} # 3
Leave the subscript off and ${#fruits} counts the CHARACTERS in element 0, which on this array is 5 and looks plausible enough to ship. [*] counts the same as [@] here; the two only diverge inside double quotes, which the next row covers.
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 element 0 instead of adding a new one, silently. Ruby's << and push cannot be confused that way. Assigning past the end (fruits[9]="fig") also works and leaves a gap, because Bash arrays are sparse.
Array Slice
A slice is the array expansion with an offset and a length appended: ${fruits[@]:1:2} is two elements starting from the second. Omitting the length takes everything to the end.
fruits = ["apple", "banana", "cherry", "date"] puts fruits[1, 2].inspect # ["banana", "cherry"]
fruits=("apple" "banana" "cherry" "date") echo "${fruits[@]:1:2}" # banana cherry
It reads exactly like Ruby's fruits[1, 2], offset and length alike, and it is the same punctuation as the substring expansion earlier — the only difference is the [@] saying which of the two is meant. That consistency is not free everywhere: in Zsh, where arrays start at 1, this particular expansion still counts from 0.
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 ! just inside the brace asks for the SUBSCRIPTS instead of the 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 "${!fruits[@]}"; do echo "$index: ${fruits[$index]}" done
Asking the array for its indices rather than counting to ${#fruits[@]} is not pedantry: Bash arrays are sparse, so after any unset the indices are no longer 0 1 2 and a counted loop reads a hole. The same syntax returns the string keys of an associative array, which is why it is the one form worth learning.
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[1]" echo "${fruits[@]}" # apple cherry echo "${!fruits[@]}" # 0 2 (gap at index 1!)
Watch the third line below: the values look right — apple cherry — while the indices are 0 2. Every counted loop over this array now reads a missing element at 1. Ruby's delete_at shifts everything down and leaves no hole. Quote the subscript — unset "fruits[1]" — 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. declare -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
declare -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 indexed array, and every string key is coerced to the subscript 0, so each assignment overwrites the last and the map appears to hold only its final entry. 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.
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]
declare -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 same ! that gave an indexed array its subscripts gives a map its keys; without it, the expansion gives the values.
scores = { alice: 95, bob: 87, carol: 92 } puts scores.keys.inspect puts scores.values.inspect
declare -A scores=([alice]=95 [bob]=87 [carol]=92) echo "${!scores[@]}" # alice bob carol (order varies) echo "${scores[@]}" # 95 87 92 (order varies)
🚨 The order is not defined and is not insertion order — the two expansions below can even disagree with each other about which entry comes first, so never pair them up positionally. When order matters, the keys have to be sorted, which in a real shell means piping them through sort. Ruby needs no such precaution, because its hashes have preserved insertion order since 1.9.
Check If Key Exists
-v asks whether a variable — or, with a subscript, one entry of a map — has been set at all. It never looks at the value, which is exactly the point.
colors = { red: "#FF0000", green: "#00FF00" } puts colors.key?(:red) # true puts colors.key?(:purple) # false
declare -A colors=([red]="#FF0000" [green]="#00FF00") if [[ -v colors[red] ]]; then echo "red exists" fi if [[ ! -v 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 with a subscript arrived in Bash 4.2.
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 "${!inventory[@]}" 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
declare -A inventory=([apples]=5 [bananas]=3 [cherries]=12) for item in "${!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; making it reproducible means piping the keys through sort — another program, and another process, for what Ruby gets from the hash itself.
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
declare -A settings=([debug]=true [verbose]=false [timeout]=30) unset "settings[verbose]" echo "${!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 ${!settings[@]} and from the -v 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
declare -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 Bash'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 Input
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.
lines = ["one", "two", "three"] lines.each do |line| puts "Line: #{line}" end
printf '%s\n' one two three > /tmp/lines.txt while IFS= read -r line; do echo "Line: $line" done < /tmp/lines.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. Feeding the same loop from a pipe instead is equally idiomatic on a real machine, but needs a second process, so only the redirect form runs here.
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() { # A function hands back text by PRINTING it. echo $(( $1 * 2 )) } # $( ) runs the function and expands to what # it wrote, so this reads like an assignment. result=$(double 21) echo $result # 42
So a Bash 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. There are two older ways round that, both still common. One is to assign to a name the caller agrees to read, which costs no process at all and so still turns up in loops where the cost of $( ) shows up. Bash 4.3 offers a third way: pass the caller's variable name and use declare -n, as the Nameref row shows.
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
Recursion works, but with no way to return a value the result has to travel through a variable the caller agrees to read. local is what gives each level of the recursion its own copy of the working values.
def factorial(number) return 1 if number <= 1 number * factorial(number - 1) end puts factorial(5) # 120
factorial() { if (( $1 <= 1 )); then echo 1 return fi local prev factorial $(( $1 - 1 )) prev=$result_var result_var=$(( $1 * prev )) } factorial 5 echo $result_var # 120
On a real machine the natural spelling is echo the result and capture it with $(factorial 4), which reads almost exactly like the Ruby — the global-variable form below is what remains when a second process is out of reach. Either way, recursion in a shell is a demonstration rather than a technique: every level costs a function frame, and on the command-substitution version, a whole process.
Nameref Variables (Reference Parameters)
A function cannot be handed an array; it can only be handed the array's NAME as text. declare -n turns that name back into a usable variable — a nameref, which reads and writes the caller's array as though it were local.
def push_item(collection, item) collection << item end items = [] push_item(items, "apple") puts items.inspect # ["apple"]
push_item() { declare -n collection=$1 # nameref to the variable named by $1 collection+=("$2") } items=() push_item items "apple" echo "${items[@]}" # apple
This is Bash's answer to Ruby passing objects by reference, and it arrived only in Bash 4.3 — which is why older scripts do the same job with eval and careful quoting, and why Zsh, which has no typeset -n at all, still must. Name the nameref something that cannot collide with what the caller passed: if both are called collection, the reference points at itself and Bash reports a circular reference.
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 BASH_REMATCH, numbered the way Ruby numbers them: [0] is the whole match and [1] onwards are the parenthesized groups.
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: ${BASH_REMATCH[1]}" echo "Minor: ${BASH_REMATCH[2]}" echo "Patch: ${BASH_REMATCH[3]}" fi
It is global and read-only, and the next =~ anywhere in the script overwrites it — including one inside a function called in between — so copy anything needed later rather than reaching back for it. Ruby hands you a MatchData object that belongs to that one match and cannot be clobbered. Zsh calls its version match and numbers it from 1, so a script ported between the two shells needs both changes.
Extended Glob Patterns
Ordinary globs cannot say "either of these". shopt -s extglob turns on a richer pattern language in which a leading symbol before the parentheses says how many times to match: @(txt|md) is exactly one of them.
filename = "config.yaml" unless filename.match?(/\.(txt|md)$/) puts "Not a text or markdown file" end
shopt -s extglob filename="config.yaml" if [[ $filename != *.@(txt|md) ]]; then echo "Not a text or markdown file" fi
The other four read the same way — *(…) zero or more, +(…) one or more, ?(…) zero or one, and !(…) anything but. Between them they cover most of what a regular expression would be doing on a filename. The option is off by default and per-script, so it belongs at the top of any file that relies on it; without it the pattern is read literally and quietly matches nothing. Zsh spells the same idea (txt|md), with no leading @.
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 and making a failure anywhere in a pipeline count.
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 ERR, which fires whenever any command fails and pairs naturally with set -e.
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 — echo -e is not POSIX and macOS's /bin/echo disagrees with Bash's builtin — so a string that begins with -n or contains \t is not portable. printf '%s\n' has no such ambiguity.
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 in Ruby) 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. Use printf rather than echo -e, which is not POSIX and is exactly the portability trap the previous row describes.
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 name, $$ the process id, $? the last exit code, and $BASH_VERSION and $HOSTNAME 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 "Bash version: $BASH_VERSION" echo "Hostname: $HOSTNAME" 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. $BASH_VERSION is also the cleanest way to tell which shell a script has ended up in — Zsh sets $ZSH_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 = "Ada" order_id = 1042 text = <<~HEREDOC Dear #{name}, Your order ##{order_id} has shipped. Thanks! HEREDOC puts text
name="Ada" order_id=1042 while IFS= read -r line; do echo "$line" done <<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 — standard input for the command on the first line. That command is usually cat, which is external and therefore absent here, so this example feeds the read builtin instead. 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.