Output & Running
Hello, World
The smallest complete program in each language — and the Bash one is also a command you can type at a prompt.
Option Strict On
Imports System
Module HelloWorld
Sub Main()
Console.WriteLine("Hello, World!")
End Sub
End Moduleecho "Hello, World!"No module, no entry point, no compilation.
Console.WriteLine becomes echo, which is a shell builtin rather than a library call. The important difference in kind: a Bash script is a list of commands, and every line you write is something you could also type interactively — which is why the shell is where system automation lives and why a script can be built up a line at a time.Formatted output
Interpolation happens inside double quotes with no prefix at all — and the format specifiers move to
printf.Option Strict On
Imports System
Module FormatDemo
Sub Main()
Dim name As String = "Ada"
Dim score As Integer = 42
Dim ratio As Double = 0.8756
Console.WriteLine($"Hello, {name}! Score: {score}")
Console.WriteLine($"Padded: {score:D5}, rounded: {ratio:F2}")
End Sub
End Modulename="Ada"
score=42
ratio=0.8756
echo "Hello, $name! Score: $score"
printf 'Padded: %05d, rounded: %.2f\n' "$score" "$ratio"
printf '%s|%s|\n' "left" "right"A
$name inside double quotes is expanded; inside single quotes it is literal, which is the whole distinction between the two quote characters. That makes "Hello, $name!" the equivalent of $"Hello, {name}!". For anything with a format specifier use printf, which takes the same C-style verbs as .NET does not — %05d for D5, %.2f for F2 — and needs an explicit \n. Prefer printf to echo in scripts: echo's handling of backslashes and leading dashes varies between shells.Comments, and the first line of a script
The comment character is also, on the first line, the thing that decides which interpreter runs the file.
Option Strict On
Imports System
Module CommentDemo
''' <summary>Doubles a number.</summary>
Function Twice(value As Integer) As Integer
' A line comment
Return value * 2
End Function
Sub Main()
Console.WriteLine(Twice(21))
End Sub
End Module#!/usr/bin/env bash
# A line comment. There is no block comment.
# Doubles a number.
twice() {
echo $(( $1 * 2 ))
}
twice 21' becomes #, and there is no block comment — a commented-out passage is a column of hashes. The first line, #!/usr/bin/env bash, is the shebang: it tells the system what to run the file with, and env bash finds whichever Bash is on the PATH rather than hard-coding a location. Without it the file is run by whatever shell invoked it, which on a Mac may still be Bash 3.2. There is no documentation-comment convention.Variables & Quoting
Assignment takes no spaces
The most common first error in Bash is a space around the equals sign.
Option Strict On
Imports System
Module AssignDemo
Sub Main()
Dim count As Integer = 10
Dim label As String = "widget"
Const MaximumRetries As Integer = 3
count = 20
Console.WriteLine($"{count} {label} {MaximumRetries}")
End Sub
End Modulecount=10
label="widget"
readonly MAXIMUM_RETRIES=3
count=20
echo "$count $label $MAXIMUM_RETRIES"
# count = 20 would try to RUN a command called "count"
local_only() { local scoped="inside"; echo "$scoped"; }
local_onlyDim count As Integer = 10 becomes count=10 — no spaces, because a space would make count a command and = its argument. There is no Dim and no type. Const becomes readonly. Every variable is global by default, even inside a function, which is the opposite of every other language on this anchor; local inside a function is what scopes one, and forgetting it is how a helper quietly overwrites a caller's variable.Always quote your variables
Read the two argument counts. This is the single most important row on the page.
Option Strict On
Imports System
Module QuoteDemo
Sub Show(value As String)
Console.WriteLine($"[{value}]")
End Sub
Sub Main()
Dim path As String = "my report.txt"
' One argument, whatever is in it
Show(path)
End Sub
End Moduleshow() {
printf 'argument count: %d\n' "$#"
printf '[%s]\n' "$@"
}
path="my report.txt"
echo "quoted:"
show "$path"
echo "unquoted:"
show $pathAn unquoted
$path is split on whitespace and then glob-expanded, so a filename with a space silently becomes two arguments. Quoting it — "$path" — passes exactly one. There is no equivalent mistake in Visual Basic, because a String is one value however it is passed. The rule is simple and absolute: quote every variable expansion unless you have a specific reason not to. Almost every mysterious shell-script bug involving filenames is this.Everything is text
There are no numbers — only text that some contexts agree to read as a number.
Option Strict On
Imports System
Module TextDemo
Sub Main()
Dim count As Integer = 10
Dim ratio As Double = 7 / 2
Console.WriteLine(count + 5)
Console.WriteLine(ratio)
End Sub
End Modulecount=10
echo $(( count + 5 ))
echo "$count" + 5
# Integer arithmetic only — no floats in the shell itself
echo $(( 7 / 2 ))
echo "scale=2; 7/2" | bcA Bash variable holds a string, always. Arithmetic happens only inside
$(( ... )), where a string is read as a number; outside it, "$count" + 5 is three words. Note also that $(( )) does integer arithmetic only — 7 / 2 is 3, and there is no floating point in the shell at all. Anything with a decimal point needs an external tool such as bc or awk, which is a strong hint that the task has outgrown a shell script.Defaults for unset variables
Parameter expansion has a fallback form, and it is the closest thing Bash has to the two-argument
If().Option Strict On
Imports System
Module DefaultDemo
Sub Main()
Dim supplied As String = Nothing
Dim label As String = If(supplied, "(unnamed)")
Console.WriteLine(label)
Console.WriteLine(Environment.GetEnvironmentVariable("NO_SUCH_VARIABLE") Is Nothing)
End Sub
End Moduleunset supplied
echo "${supplied:-(unnamed)}"
name="Ada"
echo "${name:-(unnamed)}"
echo "${#name}"
unset chosen
echo "${chosen:=assigned}"
echo "and it stuck: $chosen"${name:-default} yields the variable's value, or the default when it is unset or empty — If(supplied, "(unnamed)") exactly. Related forms: ${name:=default} also assigns the default, as the last two lines show, and ${#name} is the length. There is a fourth, ${name:?message}, which aborts the whole shell when the variable is unset — useful as a required-argument check at the top of a script, and not demonstrated here precisely because it would end the example. Note that Bash does not distinguish unset from empty unless you drop the colon (${name-default}), which is a finer distinction than Nothing versus "".Commands, Exit Codes & Pipes
Exit codes replace exceptions — and 0 means success
Zero means success. Read that twice, because it is backwards from every truthy value you know.
Option Strict On
Imports System
Module ExitDemo
Function Check(value As Integer) As Boolean
Return value > 0
End Function
Sub Main()
If Check(5) Then Console.WriteLine("positive")
If Not Check(-5) Then Console.WriteLine("not positive")
End Sub
End Modulecheck() {
[[ $1 -gt 0 ]] # the test's exit code IS the return value
}
if check 5; then echo "positive"; fi
if ! check -5; then echo "not positive"; fi
true
echo "true exits with $?"
false
echo "false exits with $?"Every command produces an exit code:
0 for success and non-zero for failure, available in $?. That is the shell's error mechanism — there are no exceptions and no Try. An if does not test a boolean; it runs a command and branches on its exit code, which is why if check 5 has no brackets around it. The inversion catches everyone: 0 is success here and falsy everywhere else, so never reason about $? as though it were a Boolean.Capturing a command’s output
The thing that takes a dozen lines of
ProcessStartInfo is two characters here.Option Strict On
Imports System
Imports System.Diagnostics
Module CaptureDemo
Sub Main()
' Capturing another program's output takes a dozen lines:
' ProcessStartInfo, RedirectStandardOutput, Start, ReadToEnd
Dim info As New ProcessStartInfo("echo", "hello")
info.RedirectStandardOutput = True
Using runner As Process = Process.Start(info)
Dim output As String = runner.StandardOutput.ReadToEnd().Trim()
Console.WriteLine($"[{output}]")
End Using
End Sub
End Moduleoutput=$(echo "hello")
echo "[$output]"
count=$(printf 'a\nb\nc\n' | wc -l | tr -d ' ')
echo "lines: $count"
echo "today has $(printf '%s' "24") hours"$(command) runs the command and substitutes its output in place. That is the shell's defining convenience: every program's output is text and every program's text can be captured, which is why gluing tools together is what shells are for. Trailing newlines are stripped. The older backtick form does the same thing but cannot nest cleanly — use $( ). And quote the result when you use it, for exactly the reason the quoting row gave.Pipes are the composition mechanism
A LINQ chain, built from separate programs that know nothing about each other.
Option Strict On
Imports System
Imports System.Linq
Module PipeDemo
Sub Main()
Dim lines() As String = {"pear", "apple", "pear", "fig"}
Dim result = lines.
OrderBy(Function(line) line).
Distinct().
Count()
Console.WriteLine(result)
Console.WriteLine(String.Join(", ", lines.OrderBy(Function(l) l).Distinct()))
End Sub
End Moduleprintf 'pear\napple\npear\nfig\n' | sort | uniq | wc -l
echo $(printf 'pear\napple\npear\nfig\n' | sort | uniq)
printf 'pear\napple\npear\nfig\n' | sort | uniq -c | sort -rn | head -2 | awk '{ print $1, $2 }'A pipe connects one command's output to the next one's input, so
sort | uniq | wc -l is OrderBy().Distinct().Count() assembled from three independent tools. The shared interface is lines of text — that is the whole contract, and it is why a shell can compose programs nobody designed to work together. It is also the limitation: there are no objects flowing through, so structured data has to be re-parsed at every stage, which is what PowerShell and Nushell were built to fix.Redirection replaces the file APIs
Reading and writing files is punctuation rather than an API.
Option Strict On
Imports System
Imports System.IO
Module RedirectDemo
Sub Main()
Dim tempFile As String = Path.Combine(Path.GetTempPath(), "visualbasic-bash-demo.txt")
File.WriteAllText(tempFile, "first line" & Environment.NewLine)
File.AppendAllText(tempFile, "second line" & Environment.NewLine)
Console.WriteLine(File.ReadAllText(tempFile).Trim())
Console.Error.WriteLine("this goes to stderr")
File.Delete(tempFile)
End Sub
End Modulefile=/tmp/visualbasic-bash-target.txt
echo "first line" > "$file"
echo "second line" >> "$file"
cat "$file"
echo "this goes to stderr" >&2
echo $(wc -l < "$file")
rm -f "$file"> writes (truncating), >> appends, and < feeds a file in as input — so File.WriteAllText, File.AppendAllText and File.ReadAllText are one character each. >&2 sends a line to standard error, which is Console.Error and is where diagnostics belong so they do not pollute a pipeline. 2>/dev/null discards errors, and &> redirects both streams at once.Control Flow
If, and the brackets that are really a command
Note which comparisons use words and which use symbols — mixing them up is a daily error.
Option Strict On
Imports System
Module IfDemo
Sub Main()
Dim temperature As Integer = 30
Dim name As String = "Ada"
If temperature > 25 Then
Console.WriteLine("Warm")
ElseIf temperature > 10 Then
Console.WriteLine("Mild")
Else
Console.WriteLine("Cold")
End If
If name = "Ada" Then Console.WriteLine("matched")
If name <> "" Then Console.WriteLine("not empty")
End Sub
End Moduletemperature=30
name="Ada"
if [[ $temperature -gt 25 ]]; then
echo "Warm"
elif [[ $temperature -gt 10 ]]; then
echo "Mild"
else
echo "Cold"
fi
[[ $name == "Ada" ]] && echo "matched"
[[ -n $name ]] && echo "not empty"
[[ -f /nonexistent ]] || echo "no such file"End If becomes fi and ElseIf becomes elif. The condition is a test command, and its operators split by type: numbers use -gt, -lt, -eq, -ne; strings use ==, !=, <. Using > on numbers inside [[ ]] compares them as text, so 9 > 100 is true. Prefer [[ ]] to the older [ ]: it does not word-split, so an unquoted variable is safe inside it. -n, -z, -f and -d test non-empty, empty, file and directory.Select Case becomes case
A close counterpart, and the patterns are glob patterns rather than literal values.
Option Strict On
Imports System
Module CaseDemo
Function Describe(name As String) As String
Select Case name
Case "start"
Return "starting"
Case "stop", "halt"
Return "stopping"
Case Else
Return "unknown"
End Select
End Function
Sub Main()
Console.WriteLine(Describe("start"))
Console.WriteLine(Describe("halt"))
Console.WriteLine(Describe("wobble"))
End Sub
End Moduledescribe() {
case "$1" in
start) echo "starting" ;;
stop|halt) echo "stopping" ;;
*.log) echo "a log file" ;;
*) echo "unknown" ;;
esac
}
describe start
describe halt
describe app.log
describe wobbleSelect Case becomes case ... esac, Case a, b becomes a|b), and Case Else becomes *). Each branch ends with ;;, and no branch falls through. The addition is that the labels are glob patterns, so *.log matches any name ending in .log — pattern matching that Select Case cannot do without a chain of Ifs. This is how almost every script parses a subcommand.Loops
One loop keyword, several ways to give it something to iterate.
Option Strict On
Imports System
Imports System.Collections.Generic
Module LoopDemo
Sub Main()
For index As Integer = 1 To 5
Console.Write(index & " ")
Next
Console.WriteLine()
Dim words As New List(Of String) From {"alpha", "beta"}
For Each word As String In words
Console.WriteLine(word.ToUpper())
Next
Dim remaining As Integer = 3
While remaining > 0
remaining -= 1
End While
Console.WriteLine(remaining)
End Sub
End Modulefor index in {1..5}; do
printf '%s ' "$index"
done
echo
for word in alpha beta; do
echo "${word^^}"
done
remaining=3
while (( remaining > 0 )); do
(( remaining-- ))
done
echo "$remaining"
printf 'one\ntwo\n' | while read -r line; do
echo "read: $line"
doneFor Each x In list becomes for x in item item; do ... done — and that is the only for. A counted loop uses a brace range, {1..5}, which is expanded before the loop runs. While ... End While becomes while ...; do ... done, and (( )) is the arithmetic form where a variable needs no $. The last form — piping into while read -r line — is how a script processes a file or another command's output line by line, and -r stops backslashes being eaten.Functions & Arguments
Functions take arguments by position
Parameters have no names — they arrive as
$1, $2 and so on, and naming them is your first line.Option Strict On
Imports System
Module FunctionDemo
Function Greet(name As String,
Optional greeting As String = "Hello") As String
Return $"{greeting}, {name}"
End Function
Sub Main()
Console.WriteLine(Greet("Ada"))
Console.WriteLine(Greet("Grace", "Welcome"))
End Sub
End Modulegreet() {
local name="$1"
local greeting="${2:-Hello}"
echo "$greeting, $name"
}
greet "Ada"
greet "Grace" "Welcome"
show_all() {
echo "count: $#"
for argument in "$@"; do
echo " [$argument]"
done
}
show_all "one" "two three"A function declares no parameters at all: arguments arrive positionally as
$1, $2, with $# as the count and "$@" as all of them. The idiom is to assign them to local names immediately, which is the only documentation the signature gets. A default is ${2:-Hello}, the parameter-expansion form from earlier. "$@" must be quoted — unquoted, it re-splits every argument on whitespace.return sets an exit code, it does not return a value
The keyword called
return is not the one that returns your answer.Option Strict On
Imports System
Module ReturnDemo
Function Add(left As Integer, right As Integer) As Integer
Return left + right
End Function
Sub Main()
Dim total As Integer = Add(2, 3)
Console.WriteLine(total)
End Sub
End Moduleadd() {
echo $(( $1 + $2 )) # the RESULT goes to stdout
}
total=$(add 2 3)
echo "$total"
is_even() {
(( $1 % 2 == 0 )) # return an exit CODE
}
if is_even 4; then echo "4 is even"; fi
# return only sets $? — and only 0..255
add_wrong() { return $(( $1 + $2 )); }
add_wrong 2 3
echo "return gave: $?"return sets the function's exit code, and an exit code is a single byte — 0 to 255, with 0 meaning success. So it is for success or failure, not for a value. A function's result is whatever it prints, captured by the caller with $(...). That is the shape of every Bash function that computes something, and it has one consequence worth knowing: anything else the function echoes, including a stray debug line, becomes part of the result.Arrays
Arrays
Arrays exist, and every one of these expansions needs its braces and its quotes.
Option Strict On
Imports System
Imports System.Collections.Generic
Module ArrayDemo
Sub Main()
Dim fruits As New List(Of String) From {"apple", "banana"}
fruits.Add("cherry")
Console.WriteLine(fruits.Count)
Console.WriteLine(fruits(0))
Console.WriteLine(String.Join(", ", fruits))
End Sub
End Modulefruits=("apple" "banana")
fruits+=("cherry")
echo "${#fruits[@]}"
echo "${fruits[0]}"
echo "${fruits[*]}"
for fruit in "${fruits[@]}"; do
echo " $fruit"
doneAdd becomes +=( ), Count becomes ${#fruits[@]}, and an element is ${fruits[0]}, zero-based. The distinction that matters: "${fruits[@]}" expands to one word per element, preserving spaces inside them, while "${fruits[*]}" joins them into a single string. Iterating always wants "${fruits[@]}", quoted — dropping either the quotes or the [@] re-splits everything on whitespace.Dictionaries become associative arrays
They exist, they must be declared, and they need Bash 4 or later — which matters on a Mac.
Option Strict On
Imports System
Imports System.Collections.Generic
Module DictionaryDemo
Sub Main()
Dim ages As New Dictionary(Of String, Integer) From {
{"Ada", 36},
{"Grace", 45}
}
ages("Alan") = 41
Console.WriteLine(ages.Count)
Console.WriteLine(ages("Ada"))
Console.WriteLine(ages.ContainsKey("Nobody"))
End Sub
End Moduledeclare -A ages
ages["Ada"]=36
ages["Grace"]=45
ages["Alan"]=41
echo "${#ages[@]}"
echo "${ages[Ada]}"
if [[ -v ages["Nobody"] ]]; then echo "present"; else echo "absent"; fi
for name in "${!ages[@]}"; do
echo "$name is ${ages[$name]}"
donedeclare -A creates an associative array; without it, ages["Ada"]=36 quietly assigns to index 0 of an ordinary array. ContainsKey becomes [[ -v ages["key"] ]], and "${!ages[@]}" is the list of keys — the ! means "the keys, not the values". Iteration order is unspecified. 🚨 These need Bash 4.0+, and macOS ships Bash 3.2, so a script using them must run under a Homebrew Bash and say so in its shebang.String Manipulation
String operations are parameter expansions
A whole family of string methods, spelled in punctuation.
Option Strict On
Imports System
Module StringDemo
Sub Main()
Dim path As String = "/home/ada/report.txt"
Console.WriteLine(path.Length)
Console.WriteLine(path.ToUpper())
Console.WriteLine(path.Substring(path.LastIndexOf("/") + 1))
Console.WriteLine(path.Replace(".txt", ".bak"))
Console.WriteLine(path.Substring(0, path.LastIndexOf("/")))
End Sub
End Modulepath="/home/ada/report.txt"
echo "${#path}"
echo "${path^^}"
echo "${path##*/}"
echo "${path%.txt}.bak"
echo "${path%/*}"
echo "${path/ada/grace}"These are parameter expansions, and they are worth learning because they need no external program.
${#x} is Length. ${x^^} is ToUpper. ${x##*/} strips the longest match of */ from the front, which is Path.GetFileName; ${x%/*} strips the shortest match from the end, which is the directory. ${x%.txt} removes a suffix and ${x/a/b} replaces the first occurrence (// for all). One # or % is shortest-match, two is longest.When expansion is not enough
The three tools you will reach for constantly, and what each is actually for.
Option Strict On
Imports System
Imports System.Linq
Module TextToolDemo
Sub Main()
Dim lines() As String = {"ada:36", "grace:45", "alan:41"}
For Each line As String In lines.Where(Function(l) l.Contains("a"))
Dim parts() As String = line.Split(":"c)
Console.WriteLine($"{parts(0)} -> {parts(1)}")
Next
End Sub
End Moduleprintf 'ada:36\ngrace:45\nalan:41\n' > /tmp/visualbasic-bash-people.txt
grep 'a' /tmp/visualbasic-bash-people.txt | while IFS=: read -r name age; do
echo "$name -> $age"
done
awk -F: '{ print $1, "is", $2 }' /tmp/visualbasic-bash-people.txt
sed 's/:/ = /' /tmp/visualbasic-bash-people.txt
rm -f /tmp/visualbasic-bash-people.txtgrep selects lines — Where. sed edits them — Replace, with regular expressions. awk splits each line into fields and is a small programming language of its own, which is what you want for anything column-shaped. Note IFS=: read -r name age: setting the field separator for one read splits a line straight into named variables, which is Split without an array. Learning these three well is most of what "knowing the shell" means.Failure Handling
A script does not stop when something fails
By default a failing command is ignored and the script carries on — with whatever half-finished state that leaves.
Option Strict On
Imports System
Module StrictDemo
Sub Main()
Try
Throw New InvalidOperationException("step failed")
Console.WriteLine("never reached")
Catch error_ As InvalidOperationException
Console.WriteLine($"caught: {error_.Message}")
End Try
End Sub
End Module# Without set -e, a failure is just... ignored
( false; echo "kept going after a failure" )
# With it, the subshell stops at the failing command
( set -e; false; echo "never reached" ) || echo "stopped, exit $?"
# The usual opening lines of a serious script:
# set -euo pipefail
( set -euo pipefail; echo "strict mode is on" )There is no exception to propagate, so a failed command sets
$? and execution continues to the next line. That is how a backup script deletes the old copy after the new one failed. The conventional first line is set -euo pipefail: -e exits on a failed command, -u treats an unset variable as an error, and -o pipefail makes a pipeline fail if any stage does rather than only the last. It is not a complete safety net — -e has well-known exceptions, notably inside if conditions and && chains — but it turns most silent failures into loud ones.Finally becomes trap
Cleanup is registered once and runs however the script ends.
Option Strict On
Imports System
Imports System.IO
Module TrapDemo
Sub Main()
Dim tempFile As String = Path.GetTempFileName()
Try
File.WriteAllText(tempFile, "working")
Console.WriteLine(File.ReadAllText(tempFile))
Finally
File.Delete(tempFile)
Console.WriteLine("cleaned up")
End Try
End Sub
End Modulework_file=$(mktemp)
trap 'rm -f "$work_file"; echo "cleaned up"' EXIT
echo "working" > "$work_file"
cat "$work_file"
# The trap fires when the shell exits — however it exits.trap 'commands' EXIT runs those commands when the shell exits — normally, on an error under set -e, or when interrupted. That is Finally, registered at the point the resource is acquired rather than wrapped around the work, which is the same idea as Go's defer. Other signals can be trapped too (INT for Ctrl-C, TERM), and mktemp plus a trap on the next line is the standard way a script handles a temporary file.Checking whether something worked
Three ways to react to a failure, and the message travels on standard error.
Option Strict On
Imports System
Module CheckDemo
Function Load(id As Integer) As String
If id < 0 Then Throw New ArgumentException("bad id")
Return $"record {id}"
End Function
Sub Main()
Try
Console.WriteLine(Load(1))
Console.WriteLine(Load(-1))
Catch error_ As ArgumentException
Console.WriteLine($"failed: {error_.Message}")
End Try
End Sub
End Moduleload() {
if (( $1 < 0 )); then
echo "bad id" >&2
return 1
fi
echo "record $1"
}
load 1 || echo "failed"
if ! output=$(load -1 2>&1); then
echo "failed: $output"
fi
load -1 2>/dev/null && echo "worked" || echo "failed: exit $?"A failing function writes its message to standard error (
>&2) and returns non-zero — keeping the error off stdout so a caller capturing the result does not get it mixed in. Then command || handler runs the handler on failure, if ! output=$(...) captures the message and branches, and && ... || ... chains both. There is no exception object and no stack trace, so a script that wants to be debuggable says which step failed itself.Scripts, Tools & Limits
Command-line arguments
The same positional arguments a function gets, and the loop that turns them into options.
Option Strict On
Imports System
Module ArgumentDemo
Sub Main(args As String())
Console.WriteLine($"count: {args.Length}")
If args.Length = 0 Then
Console.WriteLine("usage: program <name>")
Return
End If
Console.WriteLine($"first: {args(0)}")
End Sub
End Modulehandle() {
echo "count: $#"
if (( $# == 0 )); then
echo "usage: script <name>" >&2
return 2
fi
echo "first: $1"
while (( $# > 0 )); do
case "$1" in
-v|--verbose) echo "verbose on" ;;
*) echo "argument: $1" ;;
esac
shift
done
}
handle --verbose report.txtSub Main(args As String()) becomes $1, $2, $# and "$@" at the top level of a script. $0 is the script's own name. The option-parsing idiom is a while over $# with a case inside and shift to consume one argument per pass — there is no argument-parsing library in the language, though getopts handles single-letter flags. A usage message goes to standard error and the script exits non-zero, by convention 2 for a usage error.The linter is not optional
Bash has no compiler, so the tool that would have caught your mistake is a separate download.
Option Strict On
Imports System
Module LintDemo
Sub Main()
' The compiler catches a misspelled name, a wrong type,
' an unreachable branch — before the program ever runs
Dim total As Integer = 10
Console.WriteLine(total)
End Sub
End Module# Nothing here is checked before the line runs.
# shellcheck reads the script and finds what the shell will not:
#
# file=$1
# rm -rf $file/* SC2086: double quote to prevent globbing
# if [ $count > 5 ] SC2071: > is a redirect, not a comparison
# cd /tmp; rm -rf * SC2164: cd may fail; use cd ... || exit
#
# Run it on every script, and in CI.
file="report file.txt"
echo "quoted, so this is one argument: [$file]"Nothing checks a shell script before the line executes — a misspelled variable expands to nothing, a wrong operator silently does something else. ShellCheck is what replaces the compiler: it reads the script and reports the specific traps this page has been describing, each with an
SC#### code and an explanation. The second example above is genuinely dangerous — an unquoted, empty variable turns rm -rf $file/* into rm -rf /*. Install it, run it on everything, and put it in CI.When to stop writing shell
The most useful thing this page can tell you is where the shell stops being the right tool.
Option Strict On
Imports System
Imports System.Collections.Generic
Module LimitDemo
Sub Main()
' A .NET program handles all of this without comment:
Dim story As New Dictionary(Of String, String) From {
{"json", "System.Text.Json"},
{"decimals", "Decimal"},
{"structures", "classes and generics"},
{"testing", "a test framework"}
}
For Each entry In story
Console.WriteLine($"{entry.Key}: {entry.Value}")
Next
End Sub
End Moduleecho "json: no parser — jq, or another language"
echo "decimals: none at all — bc, awk, or another language"
echo "structures: strings and flat arrays only"
echo "testing: bats exists, but few scripts have any"
echo
echo "rule of thumb: past ~100 lines, or the first time you"
echo "want a data structure, rewrite it in Python."Bash is superb at what it was built for: running programs, wiring them together, and reacting to exit codes. It has no JSON parsing, no floating point, no nested data structures, no modules and no real testing culture. The signs to stop are concrete — you want a dictionary of lists, you are parsing JSON with
sed, you need a decimal, the file passed a hundred lines, or you have written the same three-line function twice. The usual next step is Python, which is also the other page a VBA reader on this anchor should read.⚠ Gotchas for Visual Basic Programmers
⚠ An unquoted variable is not one value
One value becomes two words, and nothing warns you — this is the defining Bash bug.
Option Strict On
Imports System
Imports System.IO
Module UnquotedGotcha
Sub Main()
Dim name As String = "my report.txt"
' Always exactly one value, however it is used
Console.WriteLine(name.Length)
Console.WriteLine(Path.GetExtension(name))
End Sub
End Modulename="my report.txt"
count() { echo "$#"; }
echo "quoted: $(count "$name")"
echo "unquoted: $(count $name)"
for word in $name; do echo " piece: [$word]"; done
for word in "$name"; do echo " whole: [$word]"; doneWord splitting happens on every unquoted expansion, so
$name holding my report.txt becomes two arguments. Then globbing runs, so a value containing * expands to matching filenames — a second way one value becomes several. Quoting stops both. There is no counterpart in Visual Basic, where a String is one value in every context. The habit that removes the entire class of bug: quote every expansion — "$name", "$@", "${array[@]}", "$(command)" — and let ShellCheck find the ones you miss.⚠ 0 means success, and 0 is also false
The same digit means opposite things depending on which construct is reading it.
Option Strict On
Imports System
Module ZeroGotcha
Sub Main()
Dim succeeded As Boolean = True
If succeeded Then Console.WriteLine("worked")
Console.WriteLine(succeeded)
End Sub
End Modulecheck() { return 0; } # 0 == SUCCESS
if check; then echo "worked"; fi
echo "exit code was $?"
# But in arithmetic, 0 is FALSE:
if (( 0 )); then echo "never"; else echo "0 is false in (( ))"; fi
if (( 1 )); then echo "1 is true in (( ))"; fiAn exit code of 0 means success, so
if command takes the branch when the command returned 0. Inside (( )), which is arithmetic, 0 is false and non-zero is true, as in C. So return 0 means "it worked" and (( 0 )) means "false" — in the same script, a few lines apart. Never store a truth value as 0/1 and test it both ways; use a string ([[ $ready == yes ]]) or rely on exit codes consistently.⚠ Comparing numbers as text
Read the middle line:
9 > 100 is true, and the shell is not wrong.Option Strict On
Imports System
Module ComparisonGotcha
Sub Main()
Dim left As Integer = 9
Dim right As Integer = 100
' Option Strict On compares these as numbers, always
Console.WriteLine(left > right)
Console.WriteLine(left < right)
End Sub
End Moduleleft=9
right=100
if [[ $left -gt $right ]]; then echo "numeric: 9 > 100"; else echo "numeric: 9 <= 100"; fi
# Inside [[ ]], > is a STRING comparison
if [[ $left > $right ]]; then echo "string: 9 sorts after 100"; fi
if (( left < right )); then echo "arithmetic: 9 < 100"; fiInside
[[ ]], < and > compare as text, so "9" sorts after "100" — the same lexicographic surprise JavaScript's sort() produces. Numeric comparison inside [[ ]] uses the word operators: -gt, -lt, -ge, -le, -eq, -ne. Or use (( )), which is arithmetic throughout and lets you write the symbols you meant. Worse still, inside the older single-bracket [ ], an unquoted > is a redirect and will create a file.⚠ A pipeline stage cannot change your variables
A subshell gets a copy of your variables — and a pipeline stage is a subshell, which is how a loop can add the numbers up correctly and then throw the answer away.
Option Strict On
Imports System
Module SubshellGotcha
Sub Main()
Dim total As Integer = 0
For Each value As Integer In New Integer() {1, 2, 3}
total += value
Next
Console.WriteLine(total)
End Sub
End Moduletotal=0
( total=99; echo "inside the subshell: $total" )
echo "outside, unchanged: $total"
# A pipeline stage is a subshell for exactly the same reason, so
# printf '1\n2\n3\n' | while read -r v; do total=$(( total + v )); done
# leaves total at 0. Feed the loop directly instead:
while read -r value; do
total=$(( total + value ))
done < <(printf '1\n2\n3\n')
echo "with process substitution: $total"A subshell — anything in
( ) — runs in a separate process with a copy of the variables, so assignments inside it are invisible outside, as the first two lines show. Each stage of a pipeline is also a subshell, which means a while read loop on the right of a | updates its own total and the original is still 0 when the pipeline ends — no error, no warning. That is why the pipeline version is written as a comment here: the in-browser Bash has no fork(), so it runs the stages in one process and would print the right answer for the wrong reason, teaching the opposite of the point. In a real shell it prints 0. The fix is to avoid the pipe: done < <(command) is process substitution, which feeds the output in as a file so the loop runs in the current shell.⚠ This is where VBScript went
If you came to this anchor asking about VBScript, this is the honest answer.
Option Strict On
Imports System
Module VBScriptGotcha
Sub Main()
' A WSH script glued Windows together:
' Set fso = CreateObject("Scripting.FileSystemObject")
' Set shell = CreateObject("WScript.Shell")
' shell.Run "robocopy ..."
' None of that has a counterpart here.
Console.WriteLine("COM automation was the Windows answer")
End Sub
End Module# There is no COM, no WScript.Shell, no FileSystemObject.
# Running a program IS the language:
echo "hello" | tr 'a-z' 'A-Z'
for file in /tmp; do
[[ -d $file ]] && echo "$file is a directory"
done
# The Windows-side successor to VBScript is PowerShell, which
# keeps the object model. Bash is the Unix answer, and its
# object model is: lines of text.
echo $(printf 'a\nb\n' | wc -l)VBScript's power on Windows came from COM —
FileSystemObject, WScript.Shell, driving Excel and Outlook by automation. None of that exists here, and Bash has no object model to replace it: the shared currency between programs is lines of text. On Windows the direct successor is PowerShell, which kept objects in the pipeline and is the closer match to a VBScript mental model. On Linux and macOS, Bash is what the machine gives you, and for anything past simple glue the answer is Python.