Skip to main content
Systems Programming

Shell Scripting: Control Structures, Loops, Arrays and String Handling

Published: 2026-08-20
Level: postgraduate
Audience: Postgraduate students in Systems Programming

11.1 Assignment Context and Recap of Previous Shell Concepts

Hook — why this recap matters: The next assignment is not a pen-and-paper quiz. You will chain half a dozen shell commands with arguments, patterns, and branches inside a single script and make the output match a spec. If wildcards, \$?, or #!/bin/bash are still fuzzy, every later case and loop in this lecture will feel shaky. This section re-anchors those foundations so the new control structures have something solid to sit on.

11.1.1 Assignment Overview and Group Work

The coming assignment builds directly on material covered across the last two weeks and carries a heavy weight on shell scripting. The task asks you to select the right command for each requirement, supply the right arguments and parameters, and combine them cleanly inside scripts to produce the required outputs. You will need commands already practised plus a few you have not met in class — the marking looks at whether you can read a manual page and integrate a new tool, not just recall what was demoed.

It is a group assignment. With a class strength around 26 to 27, groups are of two members each. You find a partner yourself and submit one joint solution per group. The solution will lean on the constructs taught in this lecture (case, for, while, until, break/continue, arrays, string and file tests) and on the commands discussed before. The expectation is fluency: choosing the right tool, chaining it with pipes or tests, and keeping the script readable.

Exam note: The assignment tests command-plus-script fluency, not isolated command recall. Keep notes from the previous class and this one together; the assignment draws on both. Slides for both sessions will be uploaded and a separate announcement will collect the group list, assignment statement, and presentation schedule.

Scope — what this assignment is and is not: It rewards neat, tested scripts that handle arguments, quote variables, and exit with meaningful status. A single clever one-liner that happens to work on one directory but breaks when * expands differently will lose marks. Plan to test with empty inputs, spaces in names, and missing files.

11.1.2 Recap: Wildcards, Substitution and Special Variables

The previous session showed three building blocks you will reuse in every loop and test this week.

Wild or meta characters and pathname expansion. A pattern such as * is not a literal star. The shell expands it before the command runs — called globbing or pathname expansion. In for f in * the * becomes the list of file names in the current directory. The same idea applies to ?, [abc], and brace forms.

Command substitution — inserting a command's output into another line. Two spellings do the same job: the modern \$(command) and the older ` command with backticks. For example, today=\$(date) stores the output of date in today. Inside arithmetic you will see the related form \$(( expression ))`, which evaluates the expression as integers.

Special variables — a script inspecting its own run. These names are always available:

  • \$?exit status of the last command: 0 means success, any other number means error or interruption. Right after echo \$? you learn whether the previous run finished cleanly.
  • \$# — count of positional parameters (./script a b c gives 3).
  • \$* and \$@ — all positional parameters as words; "\$@" keeps each argument as a separate word, "\$*" joins them.
  • \$0 — the script name as invoked.
  • \$\$ — the process identifier (PID) of the running shell, a number such as 319 or 5401.

A small demo program from last week printed all of these in their true form so you could see \$?, \$#, \$0, and \$\$ expand with a leading \$.

Everyday analogy — the backstage pass: Think of special variables as the backstage badges at a concert. \$0 is the badge that says which artist (script) is on stage, \$# tells how many guests (arguments) were invited, \$@ lists the guests by name, \$\$ is the unique wristband number for tonight's show, and \$? is the thumbs-up or thumbs-down the stage manager gives the moment an act finishes. The analogy breaks where badges are physical — these variables are just text the shell replaces before running the next command.

Visual intuition: picture a pipeline diagram with a horizontal timeline. Each command is a box with an arrow out. The arrow carries an integer back to the shell — 0 drawn in green, non-zero in red. \$? is a little window that always shows the colour of the last arrow. \$# and \$@ sit beside the input hopper showing how many parcels arrived and what was written on each.

Pitfalls: Forgetting to quote "\$@" collapses separate arguments into one when they contain spaces. Writing \$? after an echo checks the echo, not the command you meant — capture the status immediately. And * in an assignment like list=* without quotes expands at assignment time, which surprises newcomers inside loops.

11.1.3 Recap: Variables, Shebang and if Constructs

Variables — how to declare and how not to. In shell you write name=value with no spaces around =. name = value tries to run name as a command. You expand with \$name or, more safely, "\$name". You never declare a type first — a shell variable holds text until you use it in arithmetic, when the evaluator treats it as an integer if it looks numeric.

Shebang — the first line that picks the interpreter. A script often starts with #!/bin/bash. The two characters #! are the shebang or hash-bang; the loader reads the path after it and runs that interpreter on the rest of the file. #!/bin/bash says "run this with Bash"; #!/bin/sh says "run this with the system shell". Without a shebang the caller decides (bash script.sh vs sh script.sh), which changes which features are available.

Running and editing. Edit with any text editor such as vi. Run in three ways: bash script.sh (explicit Bash), sh script.sh (system shell — on many Linux systems this is Bash in compatibility mode), or make executable with chmod +x script.sh and then ./script.sh (kernel uses the shebang).

Decision making with if. The session began branching with if and if … else using the test brackets [ condition ]. For example, if [ \$x -eq 3 ]; then echo yes; fi runs the then block only when the test returns true (exit 0). More tests — string (=, -z, -n) and file (-f, -d, -x) — are exactly what this lecture extends with case and loop guards.

Pitfalls: x= 5 with a space after = creates an empty x and tries to run 5 as a command. Forgetting fi to close an if, or forgetting quotes around "\$var" inside [ ] when the variable may be empty or contain spaces, produces too many arguments or silent wrong branches.

Recap + bridge: The recap gives you three guarantees for today — how names expand (\$var, \$?, \$\$), how a script declares its interpreter (#!), and how a single branch works (if). From here the lecture trades a chain of ifs for the multi-way case, then adds repetition (for/while/until), fine control (break/continue), grouping (arrays), and the tests that make real admin scripts reliable (string and file checks). Every later example assumes you quote "\$var" and read \$? right away.

Real-world & domain connection: In systems programming and DevOps, the same three recap ideas appear in every nightly job — a deployment script starts with #!/bin/bash, reads \$# to verify it received a host list and a version tag, loops with for h in "\$@", logs \$? after each ssh or rsync, and branches with if or case to decide whether to roll back. Mastering them now saves hours of debugging when a production pipeline mis-expands * or silently ignores a non-zero exit status.

11.2 The case Statement — Many-Way Branching

Hook: What if a script must answer ten different words — a greeting, a farewell, a help request, and seven more — and do something different for each? Nesting if [ "\$x" = "value1" ] … elif … elif … quickly becomes a tall, hard-to-scan ladder. Is there a single structure that lays every choice side-by-side and jumps straight to the matching one?

11.2.1 Why a Many-Way Branch Helps

When a script must choose among many alternatives, a chain of if … then … else gets hard to follow and easy to break with a missing fi or ;. A single many-way branch is easier to read and to grade. In C this is switch. In shell it is case. Think of it this way: based on whatever input you have given, the flow switches to the block whose pattern matches, and that block runs — everything else is skipped. This gives clarity when you would otherwise need ten or fifteen layered if-else tests. Instead of a staircase that is hard to visualise, you write one case with several labelled shelves, and the reader can spot at once which value maps to which action.

Intuition + analogy — the railway switchyard: Picture a switchyard where one incoming track (the value in \$variable) reaches a set of levers. Each lever is labelled with a pattern (value1, bye, *). The operator flips the first lever whose label matches the incoming wagon and sends that wagon down one siding only. Once the wagon has rolled into its siding, it leaves the yard — it does not continue through other sidings. The ;; is the buffer stop at the end of each siding that prevents roll-through. The analogy breaks where real yards allow shunting between sidings — case never falls through unless you deliberately omit ;; or use the special ;&/;;& extensions.

11.2.2 Syntax: case, in, Patterns, ;; and esac

The keywords are case, in, ;;, and esac. esac is case spelled backward — it marks the end of the whole structure, just as fi marks the end of an if. A minimal skeleton looks like:

case \$variable_name in
  pattern1)
    commands for pattern1
    ;;
  pattern2)
    commands for pattern2
    ;;
  *)
    commands for anything else
    ;;
esac

Formal structure — piece by piece:

  • case \$variable_name in — evaluates \$variable_name and starts matching. The word after case can be any expansion ("\$input_string", "\$remainder", "\$1"). Always quote it as "\$variable_name" if it may contain spaces or be empty.
  • pattern) — the pattern sits on its own line and must be closed with a right parenthesis ). That ) is compulsory — missing it is a syntax error near unexpected token . A pattern is a shell pattern (glob), not a regular expression: hello matches exactly hello; * matches anything; hello|hi) with a | matches either word (see 11.2.4).
  • commands — one or more shell commands. Shell is built around commands, so a branch normally runs commands like echo, printf, or assignments.
  • ;; — two semicolons end that branch. Those two semicolons are the break. Once the matching block runs, control leaves the case and does not fall through to later patterns. Forgetting ;; tries to run the next pattern as a command.
  • esac — closes the whole case. Misspelling it as case again or omitting it is a parse error.

Execution is first-match-wins: patterns are tested in order from top to bottom; the first match runs and the rest are skipped. If no pattern matches and there is no *, nothing inside the case runs and execution continues after esac. The pattern * is the catch-all; it matches anything and everything. It is placed last so that if none of the earlier patterns matched, its block runs as the default.

Visual intuition: draw a vertical flowchart. A diamond at the top labelled \$variable. Three horizontal branches to the right, each with a label hello), bye), *). Each branch leads into a rectangle of commands and ends with a thick bar labelled ;; that routes back to a single exit point below esac. Only the branch whose label matches lights up; the others stay dim. The final echo "that's all folks" sits below esac and always lights up.

Assumptions & scope — when case is the right tool and when it is not:

  • Works wherever patterns are shell globs — good for words, integers-as-strings, file-name styles (*.txt)), or alternations (yes|y|Y)).
  • Not a numeric range tester — [ "\$n" -le 5 ] style range checks still belong in if or loop guards. For glob ranges use [0-9] inside a pattern.
  • Order matters: put specific patterns before general ones; *) must be last, otherwise it swallows everything above.
  • Portability: case is POSIX and works in sh, bash, ksh, zsh alike — safer than [[ … ]] when you need strict portability.

11.2.3 Pattern Details and the Catch-All Star

A pattern can be an integer or a string — you define it to fit what you plan to match. Examples: 0) matches the string 0 (used for remainder in 11.3), hello) matches the word hello, *.log) matches any word ending in .log. Several patterns can be written, for example pattern1, pattern2, pattern3, pattern4, and the value is matched in order until a match is found. If no match is found and there is no *, nothing inside the case runs — a silent no-op that can be confusing without a default. If there is a *, its block runs as the fallback.

Shell patterns inside case support a few useful shorthands:

  • | for alternation: hello|hi|hey) matches any of the three.
  • Character classes: [Yy]es) matches Yes or yes.
  • Wildcards: file* matches file1, filename, etc.; *.txt matches text files.

Pitfalls:

  • Forgetting ) after the pattern or ;; at the block end — either gives syntax error near unexpected token . The case_odd_num.sh build in 11.3 hit exactly this error before ;; was added.
  • Expecting fall-through like C switch without break. In shell ;; is the break — without it the shell falls through to the next pattern's commands, which is rarely intended.
  • Writing an explicit break inside a case branch — unnecessary and, outside a loop, a runtime error (break: only meaningful in a 'for', 'while', or 'until' loop). The live demo first kept a break word inside a case block then removed it to prove ;; alone was enough.

11.2.4 Worked Example: Hello-Bye Conversation

A small interactive script shows the whole mechanism. The file is often called case.sh.

#!/bin/bash
echo "Please talk to me"
read input_string
case \$input_string in
  hello)
    echo "hello yourself"
    ;;
  bye)
    echo "see you again"
    ;;
  *)
    echo "sorry, I don't understand"
    ;;
esac
echo "that's all folks"

Trace — three real runs, every step:

Setup: read input_string declares the variable at the moment of use. You use \$input_string because you want the value. Each block ends with ;;.

Run 1 — exact match hello:

\$ bash case.sh
Please talk to me
hello
hello yourself
that's all folks

Path: \$input_string expands to hellohello) matches on line 1 → prints hello yourself;; jumps to esac → prints that's all folks. The bye) and *) blocks are not even tested after the match.

Run 2 — exact match bye:

\$ bash case.sh
Please talk to me
bye
see you again
that's all folks

Path: bye fails hello) → succeeds bye) → same exit via ;;.

Run 3 — no literal match (hello me with a space):

\$ bash case.sh
Please talk to me
hello me
sorry, I don't understand
that's all folks

Path: hello me is one string with a space. It fails hello) (needs exact hello) and fails bye) → falls to *) which always matches → prints the apology → exits via ;; → final echo runs. The final echo "that's all folks" always runs because it is after esac, outside the case.

Sense-check: The three runs confirm first-match-wins, exact-string matching, and that the default * truly catches everything else. Try HELLO — it also hits *, because patterns are case-sensitive unless you write [Hh]ello).

Q & A — deduplicated:

Q: Can we match several different strings with a single case branch? A: Yes — the language allows alternation. The usual spelling is pattern1|pattern2) on one line, for example hello|hi) or yes|y|Y). The bar | means "or". You can also write [Yy]es) to match Yes or yes. The demo kept separate branches for clarity, but the combined form is valid and preferred when branches would do the same action. Frequency note: several students asked this; one canonical form covers all of them.

Q: If we want to match a literal star * instead of the catch-all, how do we write the pattern? A: Write the star as a quoted or escaped pattern so the shell does not treat it as a glob. All three work: "*") or '*') or \*). Without quoting, a bare * is the catch-all. With quoting or escaping, it becomes the single character *. The same rule applies to ? and [ when you want them literally.

Pitfalls — the three most common slips in the lab:

  1. hello ) with a space before ) still parses, but hello without ) does not — ) is mandatory.
  2. ;; on its own line vs ; — a single ; does not terminate a branch; you need ;;.
  3. * vs "*" — forgetting quotes turns a literal-star test into a universal default that swallows every later pattern.

Recap + bridge: case is the many-way switch — case value in pattern) commands ;; … *) default ;; esac, first match wins, ;; is the break, *) is the default. You just saw it route exact strings (hello/bye) with a safe fallback. Next, the same skeleton routes a computed number — the remainder on division by two — to tell even from odd, and you will learn why feeding it a word still prints "even".

Real-world & domain connection: System scripts use case exactly like this to dispatch on the first argument: case "\$1" in start) start_service ;; stop) stop_service ;; restart) stop_service; start_service ;; *) echo "Usage: \$0 {start|stop|restart}" ;; esac. Package managers, init scripts, and CLI tools all follow the pattern — one case at the top selects the mode, each branch runs a pipeline, and *) prints usage. The shape you practised with hello/bye is the production shape.

11.3 Arithmetic Forms and the Odd-Even Program with case

Hook: You can already route words with case hello|bye. Can you route numbers the shell computes — for example, the remainder when a user-supplied value is divided by two — and use that single digit to decide "even" versus "odd"? The twist in class: when the user types a letter instead of a number, the same script confidently prints "even". Why does a word become 0?

11.3.1 Evaluating Arithmetic: Double Parentheses, Backticks and expr

Shell arithmetic needs an evaluator — the shell does not do n % 2 by itself unless you invoke one.

Three spellings that do the same integer arithmetic:

  1. Modern preferred — arithmetic expansion with double parentheses:

In shell:

remainder=\$(( input_number % 2 ))
# or equivalently
remainder=\$(( \$input_number % 2 ))

Inside \$(( … )) you write the expression directly. The inner % is the remainder (modulus) operator. Evaluation is integer arithmetic — the same as expr but without forking an external command. The outer \$ does the expansion; omitting it as (( input_number % 2 )) runs the test without producing a value (useful in if (( … ))).

  1. Traditional — backticks with expr:
remainder=`expr \$input_number % 2`

An older form surrounds expr with the backtick character that sits with the tilde key, often called backtick or backquote. Inside the backticks, expr evaluates the integer expression. The advice given was: if the backtick quoting feels confusing, just use \$(( … )) with two round brackets — it is clearer and faster.

  1. Modern \$( … ) form of the same expr idea:
remainder=\$(expr \$input_number % 2)

This is the \$(command) spelling of command substitution — same semantics as backticks, but easier to nest.

When to pick which: Prefer \$(( … )) for arithmetic; keep expr only when you need its string or pattern features on older systems. Inside \$(( … )) you rarely need the \$ before variable names (\$(( input_number % 2 )) is idiomatic), though \$((\$input_number % 2)) also works.

Symbol guide on first use:

  • — the value read from the user, treated as an integer for the remainder test. In shell this lives in input_number after read input_number.
  • — defined as

value 0 for even, 1 for odd. An integer in when the input is an integer. In shell this is remainder.

  • — the divisor for the even-odd test. Changing it to 10 would give the last decimal digit instead.

Detailed intuition with numbers: because . because . The remainder is what is left after taking out as many 2s as possible.

Scope — integer only: Both \$(( … )) and expr do integer arithmetic. 5/2 gives 2, not 2.5. For floating point you need bc, awk, or another tool. Division by zero is an error. And any non-numeric string is treated as 0 in arithmetic context (see 11.3.4) — no warning is printed, which is the silent-pitfall this lecture highlights.

11.3.2 Building the Odd-Even Script Step by Step

The aim is to tell whether a given number is odd or even, but now using case instead of if. The file is often named case_odd_num.sh.

#!/bin/bash
printf "Enter the number to be checked\n"
read input_number
remainder=\$(( input_number % 2 ))
case \$remainder in
  0)
    printf "This is an even number\n"
    ;;
  1)
    printf "This is an odd number\n"
    ;;
  *)
    printf "I do not understand\n"
    ;;
esac

Line-by-line intent:

  • #!/bin/bashshebang line (see 11.1). Tells the loader to run this file with Bash.
  • printf "Enter the number …\n" — prints a prompt with a newline. echo would also work; printf gives finer control.
  • read input_number — reads one line from the user and stores it in input_number. No prior declaration needed.
  • remainder=\$(( input_number % 2 )) — computes

In words: take the input, divide by two, keep the remainder. Examples: input_number=22 % 2 = 0; input_number=33 % 2 = 1.

  • case \$remainder in — branches on that single digit. Value 0 means even → first branch; 1 means odd → second branch; * catches anything else (for example, if input_number were -1, the remainder in Bash is -1, which would hit * — a useful guard).

The alternative with expr would be remainder=expr \$input_number % 2`. Both forms were discussed, with \$(( … )) recommended for clarity. The debug aid printf "\$remainder\n" before the case` lets you see what value is actually being tested while building the script; remove it later.

Comparison — two ways to get the same remainder:

Form Spelling Pros Cons
Arithmetic expansion remainder=\$(( input_number % 2 )) Clear, no subshell, fast Bash/POSIX sh only for integers
expr with backticks ` remainder=expr \$input_number % 2 ` Works on very old shells, also handles pattern matching Requires backticks, fork, easy to misquote

When to pick: inside a Bash script prefer \$(( … )); when reading a legacy script that already uses expr, recognise the backtick form as equivalent.

11.3.3 Running the Script and Fixing the Missing Semicolons

Worked example — the missing ;; and its fix:

First run — ;; omitted on each branch:

syntax error near unexpected token `1)'

The shell parser treated the next pattern 1) as a command continuation because the previous branch had not been closed with ;;. No branch ran.

After adding ;; at the end of each branch:

\$ bash case_odd_num.sh
Enter the number to be checked
2
This is an even number

\$ bash case_odd_num.sh
Enter the number to be checked
3
This is an odd number

Step-by-step for input 3: read input_number stores 3; remainder=\$(( 3 % 2 )) evaluates to 1 because ; case \$remainder in tests 1 against 0) (no match), then 1) (match) → runs printf "This is an odd number";; jumps to esac. For input 2, the remainder 0 hits the first branch.

Sense-check: 0 → even, 1 → odd, exactly what parity means. Try 0 itself — remainder 0, even, which matches the mathematical convention that zero is even.

The live edit also added printf "\$remainder\n" before the case to make the branching value visible — a standard debugging move: print the discriminator before you dispatch on it.

Q & A:

Q: What is the other way to evaluate the remainder if we do not want to use \$(( … ))? A: Use the expr command: ` remainder=expr \$input_number % 2 . Inside the backticks, expr evaluates the integer expression. Both forms give the same integer remainder. Modern scripts also write it as remainder=\$(expr \$input_number % 2)` — same semantics, clearer quoting.

Pitfalls:

  • Forgetting ;; or esac — the syntax error near unexpected token you saw is almost always a missing ;; or ) in a case.
  • Writing remainder = \$(( … )) with spaces around = — creates a command remainder instead of an assignment.
  • Treating = like -eq — inside case patterns you match strings; inside [ ] tests you must write [ \$remainder -eq 0 ], not [ \$remainder = 0 ] for integers, though case \$remainder in 0) is a string match on the decimal representation and works because 0 and 1 have canonical forms.

11.3.4 What Happens with Letters Instead of Numbers

Striking run — letters fed to n % 2:

Enter the number to be checked
a
This is an even number
b
This is an even number
c
This is an even number
wow
This is an even number
he
This is an even number

A debug print showed remainder was 0 in every case. That is not ASCII: ASCII for a is 97 (odd, because ), for b is 98 (even, because ), so if ASCII were used the results would alternate odd, even, odd. They did not — every alphabetic input fell into the 0 branch.

Why 0? The shell arithmetic evaluator treats any non-numeric string as value 0 for this operation, and so every alphabetic input hits the even branch. Similarly: Try the same inside for1.sh with \$(( i + 1 )) when i=hello — the shell prints 1, because it interpreted hello as 0 then added one.

Test with real numbers to confirm the contrast: with input_number=4, remainder → even; with input_number=7, → odd. Both behave; words collapse to 0.

Sense-check: If the input were 2.5, the shell would also mis-handle it (integer context truncates or errors). The lesson is that \$(( … )) does not validate — it coerces silently.

Teaching moment — letters treated as zero, not ASCII:

Shell has no strict types. When you feed a to \$(( a % 2 )), the evaluator does not look up the character code; it coerces the whole word to the integer 0. The session explicitly called this out because students often guess "maybe the shell uses ASCII and alternates odd/even" — the trace proved it does not. Different words could in principle be compared by length, but the test run showed no length effect: a, hello, I am fine all gave 0. What matters is not the shape of the word but that it is non-numeric.

Q & A — deduplicated:

Q: Can we define a variable with a fixed data type and enforce it so words are rejected? A: No — shell does not have strict type declarations as in C or Java. You can assign a number or a string to any variable; the type is decided at run time when the value is used. If the value looks numeric at the moment of arithmetic, it is treated as a number; otherwise it is treated as text. This dynamic feel is even looser than some other dynamic languages that still have wrappers for integers and floats — here there is no such wrapper at declaration.

Q: So what does the modulus see when the input is a word? A: It sees 0. A non-numeric word is taken as 0, so the remainder is 0, which is why words are labelled even. The fix is to guard before arithmetic (see Exam note).

Visual intuition: imagine a funnel labelled \$(( … )) that has a sieve at the top. Numbers fall through unchanged; words are caught, replaced by a 0 chip, and that chip drops into the mod 2 machine. Both the a chip and the wow chip are swapped for 0, so the machine always outputs 0.

Recap + bridge: Remainder parity is with \$(( input_number % 2 )) (or ` expr … ), then case \$remainder in 0) … ;; 1) … ;; *) … ;; esac. Integers behave; words silently become 0 and mis-route to "even". Next, you will see the same coercion inside a for loop when hello gives 0+1=1`, and how to guard loops that must handle arbitrary words.

Exam note: A question that asks you to restrict input to numbers only is a natural follow-up. You would add a test before the modulus — for example with a pattern case "\$input_number" in ''|*[!0-9]*) echo "not a number";; *) remainder=\$(( input_number % 2));; esac or a string test — and prompt again if it is not numeric. The session flagged this as potential assignment material.

Real-world & domain connection: Production scripts that read user input or CSV fields always guard before arithmetic. A payroll script that computes pay = days * rate will first test case "\$days" in ''|*[!0-9]*) echo "bad days: \$days" >&2; continue;; esac before (( pay = days * rate )). Without that guard, a missing field becomes 0 and an employee silently gets zero pay — exactly the silent-wrong-answer class this odd-even demo was designed to expose.

11.4 The for Loop — List and C-Style Forms

Hook: You need to say hello to five people, then to every file in a directory, then count from 1 to 100 by twos. Must you copy the same echo fifty times? How does the shell let you write the action once and feed it a new name on each pass?

11.4.1 List Form: for Variable in List; do ... done

The for loop is one of the most used constructs. Shell offers a form that iterates over a list of words — the list is written explicitly, not as a range object.

Syntax — the classic list form:

for variable in list
do
  commands using \$variable
done
  • for starts the loop, in introduces the list, do marks what to do inside the block, done marks the end. Forgetting done is syntax error: unexpected end of file.
  • On each entry to the loop, the named variable is assigned the next word from the list. If the list is 1 2 3 4 5, then on the first iteration the variable is 1, on the second it is 2, and so on until the list is exhausted.
  • You do not need braces around the list for this form, though braces appear in other languages and in brace expansion (11.4.4). The words in the list can be numbers, characters, strings, or a mix. Order does not matter to the interpreter; it just walks the list left to right.
  • Inside the block you refer to \$variable to get the value. echo i prints the letter i; echo \$i prints the value held in i. Forgetting the \$ is a frequent early slip.
  • do and done delimit the body. Some texts write for i in 1 2 3; do echo \$i; done on one line — the semicolon before do is then required.

A tiny example that prints numbers one to five:

for i in 1 2 3 4 5
do
  echo \$i
done

Equivalent one-liner: for i in 1 2 3 4 5; do echo \$i; done. Both print 1 through 5, one per line.

Intuition — the conveyor belt: picture a conveyor belt carrying parcels labelled 1, 2, 3, 4, 5. The worker (i) picks one parcel each cycle, carries it into the do … done room, runs the commands while holding that parcel, puts it down, and goes back for the next. When the belt is empty, the worker stops.

Scope — where ; do matters: In the multi-line form for i in 1 2 3 newline replaces the semicolon. In the single-line form you must write ; do. Mixing them — for i in 1 2 3 do without ; or newline — is a parse error.

11.4.2 Mixed Lists and Type Behaviour

A list can be mixed without any change in syntax:

for i in 1 hello mine you 0
do
  echo \$i
done

The shell treats list items as words. When you later use a word in arithmetic, the shell decides how to handle it. The session built a richer loop often called for1.sh to see this:

for i in 1 2 3 4 5 hello I am fine
do
  printf "Looping number %s incremented value is %s\n" "\$i" "\$(( i + 1 ))"
done

Word splitting in the list: I am fine without quotes is three separate words I, am, fine. The loop therefore sees eight items (1 2 3 4 5 hello I am fine1,2,3,4,5,hello,I,am,fine), not six. Quoting as "I am fine" would keep it as one item — a distinction that explains many "extra iteration" bugs.

Arithmetic coercion: When arithmetic i + 1 is tried with i=hello, the shell takes hello as 0, so the result is For numeric items, 1 becomes , 2 becomes 3, and so on. This matches the idea that everything in shell is a string until used as a number — the same coercion seen with letters fed to n % 2 in 11.3.

Formally, for each iteration the printed second column is with i coerced to an integer (hello → 0, I → 0, 2 → 2). So hello gives 1, I gives 1, am gives 1, fine gives 1.

A variant tried to compute \$(( \$i + 1 )) when i itself held the letter i. Writing i=i and then \$(( \$i + 1 )) with \$i inside caused a recursion-like expansion error because the evaluator kept expanding the variable name as if it referred to itself. Changing the variable name to something other than i for the outer loop removed the confusion — a concrete lesson to keep the loop variable distinct from any value that looks like the same letter when expanded inside \$(( … )). Modern shells handle \$(( i + 1 )) without the extra \$ more robustly, which is another reason to prefer the form without \$.

Worked trace — for1.sh with numbers then mixed:

Numbers only (1 2 3 4 5):

Looping number 1 incremented value is 2   # 1+1=2
Looping number 2 incremented value is 3   # 2+1=3
Looping number 3 incremented value is 4   # 3+1=4  (recorded as "4→5" in class notes)
Looping number 4 incremented value is 5   # 4+1=5
Looping number 5 incremented value is 6   # 5+1=6

(The class session notes collapsed the middle lines; the arithmetic is the same for each.)

Mixed (1 2 3 4 5 hello I am fine): the first five lines repeat as above, then:

Looping number hello incremented value is 1   # hello→0, 0+1=1
Looping number I incremented value is 1       # I→0, 0+1=1
Looping number am incremented value is 1      # am→0, 0+1=1
Looping number fine incremented value is 1    # fine→0, 0+1=1

Sense-check: The 1s for words are not random — they are exactly the 0→1 coercion, the same class of bug as letters becoming "even" in 11.3.

Failed variant — i=i: with for i in …; do echo \$(( \$i + 1 )); done when the list itself contains i, the inner \$i expands to i, so \$(( i + 1 )) becomes \$(( i + 1 )) recursively. Renaming to for val in …; do echo \$(( val + 1 )); done fixes it.

11.4.3 Glob Expansion: When Star Becomes a File List

Another run used the list hello 1 * 2 goodbye. The * did not stay as a star character. It expanded to the list of files in the current directory, as if ls had been run.

Looping number hello ...
Looping number 1 ...
Looping number file1 ...
Looping number file2 ...
Looping number 2 ...
Looping number goodbye ...

where file1, file2 were the actual file names present where the script ran. The expansion happened before the loop saw the list. This is globbing or pathname expansion: the pattern * matches every file and directory name in the current directory (hidden dot-files excluded without dotglob).

Teaching moment — star expands to directory files, and that is useful, not a bug:

The session explicitly flagged this as both a pitfall and a feature. As a pitfall, the accidental 1 * 2 without an evaluator does not multiply — it lists files, because * is not an operator inside a plain word list. As a feature, for f in * is the idiomatic way to visit each file and then test its type inside the loop. Because * expands to names, you can add file tests ([ -f "\$f" ], [ -d "\$f" ]) to decide whether a name is a regular file, a directory, or a symbolic link. The moment starred * in a list becomes a file-name generator is exactly what makes for file in * the backbone of admin scripts.

Visual intuition: imagine the * as a wildcard-shaped stencil placed over a tray of name cards. When laid down, the stencil lights up every card at once and hands the whole lit set to the for belt. If you wrap the stencil in plastic (quotes or \), the light cannot get through — only the stencil shape itself is handed over.

The shell order that matters here is: brace expansiontilde expansionparameter expansionglobbingword splitting. The * glob happens after \$i expands but before for iterates, which is why the loop sees file names and not a star.

11.4.4 Keeping Star Literal: Escape and Quotes

To keep * as a plain character, three ways work:

for i in hello 1 \* 2 goodbye   # with backslash escape
for i in hello 1 '*' 2 goodbye  # with single quotes
for i in hello 1 "*" 2 goodbye  # with double quotes

All three give:

hello
1
*
2
goodbye

instead of a file list. The backslash escapes the special meaning, and quoting hides it from expansion. The same idea applies to other wild characters (?, […]) when you want the literal shape rather than the pattern. The demo contrasted the two runs back-to-back so the difference was unmistakable: unquoted * → file list; quoted/escaped * → one item *.

Q & A — deduplicated:

Q: Where did that list of files come from in the mixed loop? A: From the * in the list. The shell expanded * to all file names in the current directory before the loop started, so the loop saw those names as its items. If you want the star itself, quote or escape it as '*', "*", or \*.

Q: Does range notation work? A: Yes. Brace expansion such as {1..5} can generate a sequence, and that sequence can be used as the list for for. For example, for i in {1..5}; do echo \$i; done prints 1 through 5 without writing each number. With a step, Bash also supports {1..10..2} for 1 3 5 7 9. The classic portable alternative is \$(seq 1 5): for i in \$(seq 1 5); do …; done.

11.4.5 C-Style for and Shell Compatibility

Shell also supports a C-like three-expression form:

for (( c=1; c<=5; c++ ))
do
  echo "welcome \$c"
done

Anatomy of the C-style for (( … )):

  • for (( init; test; step )) — three expressions inside double parentheses, separated by semicolons.
  • Expression one c=1 runs once at start.
  • Expression two c<=5 is checked before each iteration; the loop enters only when

is true.

  • Expression three c++ (post-increment) runs at the end of each iteration. Variants like c+=2 for a step of two are valid, making "count by twos" plain: for (( c=1; c<=10; c+=2 )).
  • The body between do and done runs while the test stays true. For the example, iterations give c=1,2,3,4,5 and then c=6 fails 6 <= 5, so the loop stops.

Worked run — for2.sh and portability:

# for2.sh
for (( c=1; c<=5; c++ ))
do
  echo "welcome \$c"
done
\$ bash for2.sh
welcome 1
welcome 2
welcome 3
welcome 4
welcome 5

\$ sh for2.sh
welcome 1
welcome 2
welcome 3
welcome 4
welcome 5

The same script did not run under csh or tcsh on the machine used, and some ksh/zsh installations needed their own syntax variant, because the arithmetic for (( )) form is specific to Bash and a few compatible shells. The advice is to use bash or sh on a system where sh points to Bash when you rely on this form. For the list form for i in … the compatibility is wider — it is POSIX and runs everywhere.

Choosing between the two forms:

Need Natural form Example
Visit arbitrary words or files List form for f in * or for h in web1 web2 db1
Count with numeric bounds and steps C-style for (( c=1; c<=10; c+=2 ))
Sequence without manual list Either for i in {1..5} or for (( i=1; i<=5; i++ )) Both work; C-style makes step and test explicit

When to pick which: If a question says "write a for loop that needs a step of 2," the C-like form makes the step plain (c+=2); if it says "visit each file," the list form with * is the natural pick.

Pitfalls:

  • Omitting one of the semicolons inside (( … ; … ; … )) is a parse error — the double parentheses do not forgive missing ;.
  • Using c++ vs ++c matters only when the step expression's value is used; for the increment itself either works.
  • Assuming C-style for is POSIX — it is not. A strictly POSIX sh only guarantees the list form.

Recap + bridge: for is your fixed-set repeater — for var in list; do … done walks words left to right (globbing * into file names unless quoted), and for (( c=1; c<=5; c++ )) counts with explicit init/test/step. Words coerced to 0 in arithmetic (hello→1 after +1) echo the same lesson as 11.3. Next, when the number of repetitions is not known ahead of time and depends on a test that changes inside the loop, you trade for for while and until.

Real-world & domain connection: DevOps tooling uses both forms daily. A deploy script uses for h in "\${hosts[@]}" to ssh into each host and rsync a build; a benchmark script uses for (( i=0; i<1000; i++ )) to time an operation and average the result. The list form dominates file and host tasks; the C-style dominates arithmetic sweeps. Knowing which to reach for — and how to keep * literal when you need the character, not the file list — is exactly what the session's contrasting runs drilled.

11.5 The while Loop — Test-Driven Repetition

Hook: A for loop is perfect when you know the list upfront — 1 2 3 4 5 or * as files. What about when you do not know how many steps you need — reading a chat until the user says bye, or reading a CSV until end of file? How do you say "keep going while this is still true"?

11.5.1 Syntax and Test Operators

A while loop repeats as long as a given condition is true:

while [ condition ]
do
  commands
done

Structure — what each piece does:

  • while [ condition ] — evaluates the test in brackets. In shell, [ … ] is the test command; it exits with 0 (true) or non-zero (false). The loop enters the body when the test is true and re-tests before every iteration. When the test becomes false, control jumps to after done.
  • do … done — delimits the body. Every while body must advance the state so the test can eventually become false — otherwise you have an infinite loop.
  • Integer relational operators inside [ ] (all start with -, all need spaces around them):
Operator Meaning Formal test
-le less than or equal
-lt less than
-ge greater or equal
-gt greater than
-eq equal
-ne not equal

Example with all pieces:

i=1
while [ \$i -le 5 ]
do
  echo \$i
  i=\$(( i + 1 ))
done

In words: set ; while is true, print and then set to . Walk i=1→2→3→4→5 prints 1 2 3 4 5; when i becomes 6, [ 6 -le 5 ] is false, so the loop stops. The test [ \$i -le 5 ] returns true exactly when the integer in i is less than or equal to 5.

The increment inside uses the same arithmetic forms as before: \$(( i + 1 )) or ` expr \$i + 1 . The backtick form is the character that shares the key with tilde; prefer \$(( … )) to avoid confusion with plain single quotes '`.

Visual intuition: picture a turnstile with a sign that reads the current value of i. The gate lifts only while the sign shows in green. Each trip through the loop room increments the sign by one. After 5→6, the sign flips red — gate stays down, and the crowd routes to done. Forgetting the increment is like a turnstile that never updates the sign — the green light never turns red.

Comparison — for vs while for the same count 1…5:

Aspect for i in 1 2 3 4 5 while [ \$i -le 5 ]
Knows bounds upfront? Yes — list given No — tests each time
Stopping logic List exhausted Test becomes false
Needs manual increment? No Yes — i=\$(( i+1 )) inside body
Best when Fixed set of words/files Open-ended, data-driven repetition

Assumptions & scope:

  • [ \$i -le 5 ] is an integer test. With words like hello, it gives integer expression expected — use string tests (=, !=, -z, -n) for words.
  • Spaces are mandatory: [\$i -le 5] without spaces is not a command invocation. Write [ \$i -le 5 ] with spaces after [ and before ].
  • Quoting: for integer tests, [ "\$i" -le 5 ] is safer when i may be empty; for string tests, quoting is mandatory (see 11.5.3).

11.5.2 Infinite Loops, Colon and Interrupt Exit Status

The : command and while ::

: is a shell builtin that does nothing and always succeeds — it exits with status 0. It is not punctuation. So while : means "while the always-true command succeeds, repeat". That loop never becomes false on its own.

The file case.sh contained an infinite while:

while :
do
  echo "Please talk to me"
  read input_string
  # ... case dispatch ...
done

Each iteration prints the prompt, reads a line, dispatches on it with case, and then loops. The only way to stop it is from outside — the interrupt key Ctrl-C, which sends SIGINT to the foreground job.

Trace — killing the infinite loop and reading \$?:

  1. Run bash case.sh. The prompt reappears forever:
Please talk to me
hello
hello yourself
Please talk to me
hi
sorry, I don't understand
Please talk to me
^C
  1. Immediately inspect the exit status:
echo \$?
130

Why 130? The usual rule is that a program that finishes without error exits with 0; a non-zero signals error or interruption. The value 130 reflects how the interrupt was handled: on most Unix shells, exit status after Ctrl-C is 128 + signal_number, and SIGINT is signal 2, so . The exact number depends on internal signal handling, but the point is stable: \$? right after a command tells you if the last run finished cleanly (0) or was killed/signalled (non-zero, here 130). A normal run that ends on its own — for example, the guarded loop in 11.5.3 when the user finally types bye — would leave \$? as 0.

Multiple concrete cases:

  • Normal exit after done with no error: echo \$?0.
  • Killed by Ctrl-C after while :: echo \$?130 (on this shell; 131 for SIGQUIT, 143 for SIGTERM are analogous).
  • Failed test inside loop that exits via break on error: leaves the status of the last command before break.

Sense-check: \$? is ephemeral. Run another command and the previous status is lost. Capture it immediately: status=\$? then test status.

Teaching moment — colon always succeeds, interrupt is not zero:

Students often think killing a loop "succeeded" because they asked for it. The shell disagrees — 130 is a non-zero status that parent scripts can test. The session used this to introduce the habit: after an important command or loop, check if [ \$? -ne 0 ]; then echo "interrupted or failed"; fi. This is also why while : is written with a colon and not with while true: is a builtin and avoids forking an external true command.

Real-world illustration: administrators write guarded while loops to process log files or payroll CSV files. The sketch is:

while IFS=, read name days rate; do
  # validate \$name, compute pay, write slip
done < payroll.csv

Here read returning false at end-of-file ends the loop naturally, not a colon. The while : form is reserved for interactive daemons that should run forever until an operator stops them.

11.5.3 Guarded Reading Loop

A second while example avoids the infinite case by adding a real string test:

input_string="hello"
while [ "\$input_string" != "bye" ]
do
  printf "Please type something in (bye to quit)\n"
  read input_string
  printf "%s\n" "\$input_string"
done

Guarded loop — step by step:

  • input_string="hello" seeds the guard so the loop enters (any value != "bye" works). Without seeding, an empty input_string would still enter because "" != "bye" is true, but explicit seeding makes intent clear.
  • while [ "\$input_string" != "bye" ] — string not-equal test !=. The loop runs while the condition is true (not equal to bye) and stops when it becomes false (equal to bye). This is the direct dual of until.
  • read input_string updates the guard inside the body — every iteration asks for a fresh line. Forgetting to update the guard is how a guarded loop becomes accidentally infinite.
  • printf "%s\n" "\$input_string" echoes back what was read. The "%s" format is safe when the line may contain % or leading dashes.

The trace showed multiple lines like I am fine, how are you, then bye to quit:

Please type something in (bye to quit)
I am fine
I am fine
Please type something in (bye to quit)
how are you
how are you
Please type something in (bye to quit)
bye
bye
# loop stops — "bye" != "bye" is false

Crucially, the test handles a whole line including spaces — quoting "\$input_string" keeps it as one string for the test. Without quotes, I am fine would become three words and [ \$input_string != "bye" ] would be too many arguments.

Pitfalls:

  • Forgetting quotes around "\$input_string" when it may contain spaces or be empty — gives too many arguments or treats empty as missing argument.
  • Using -ne (integer) instead of != (string) to compare words — gives integer expression expected or silent wrong answer. Integer operators are for numbers; = and != are for strings.
  • Writing while [ \$input_string != bye ] without quoting bye is usually harmless, but quoting both sides is the safe habit.
  • Priming bug: reading before the test vs after changes whether the sentinel bye is processed. In this sketch bye is read, printed, then the test fails next time — so bye does get echoed. Some designs prefer to test immediately after read with break.

Q & A:

Q: What does -le mean inside [ \$i -le 5 ]? A: It means less than or equal. So the test is true when . Similarly: -lt is , -ge is , -gt is , -eq is , -ne is . For strings use =, !=, -z, -n instead.

Exam note: Expect to write the operator from its English name — "while i is less than or equal to five" → while [ "\$i" -le 5 ].

Recap + bridge: while is the test-true repeater: while [ condition ]; do commands; done, re-testing before each pass. while : never ends on its own (exit 130 when you Ctrl-C it); a guarded while [ "\$input_string" != "bye" ] that updates the guard each iteration ends cleanly when the sentence becomes false. This dual leads directly to the opposite form — until, which runs while the test is false and stops when it becomes true.

Exam note: Know the bracket spellings and that \$? after a normal run is 0. If you kill an infinite loop, \$? will not be 0. A typical exam task: fix a while that uses [ \$i = 5 ] when it meant [ \$i -eq 5 ], or add the missing i=\$(( i+1 )) that makes a while [ \$i -le 5 ] finite.

Real-world & domain connection: In systems programming, while with a string guard is exactly how interactive tools stay alive — a network daemon does while [ "\$cmd" != "quit" ]; do read cmd; case "\$cmd" in …; esac; done — and while IFS= read -r line is the standard way to process a log file line-by-line without loading it all into memory. The integer guard while [ \$attempts -le 3 ] implements retry logic for flaky curl or ssh calls, incrementing attempts and sleeping between tries. The colon form while :; do …; sleep 60; done appears in monitoring watchdogs that poll forever until an operator interrupts them.

11.6 The until Loop — Running Until a Condition Becomes True

Hook: You already read "while i is less than or equal to five, keep printing". How would you phrase the same intent as "keep printing until i equals ten"? Is until just a synonym for while, or does flipping the sentence flip the behaviour?

11.6.1 How until Differs from while

The until loop looks exactly like while but runs with the opposite sense:

until [ condition ]
do
  commands
done

Dual definition — the one line to memorise:

  • while → run while the test is true; stop when it becomes false.
  • until → run while the test is false; stop when it becomes true — in other words, keep running until the condition becomes true.

So the block inside until runs when the test is false and stops when the test becomes true. Formally, until [ condition ] is equivalent to while [ ! condition ] (the ! negates the test). The shell still re-tests before each iteration, just with inverted polarity.

This is close to the do-while idea in other languages, but written with a leading test, not a trailing one. In C you might see do { … } while (!done) — in shell you write until [ done ] with the test at the top.

Comparison table — while vs until for the same job:

Aspect while [ condition ] until [ condition ]
Enters when condition true condition false
Stops when condition false condition true
Equivalent with ! while [ ! condition ]
Reads as "while not at end, keep reading" "until at end, keep reading"
Common wording cue "while not at end of file" "until equal to …"

Scope — phrasing trick: Write the final state you want to reach in an until test, not the state you want to keep. until [ \$i -eq 10 ] says "stop when i hits 10" (so it prints 0…9). while [ \$i -le 9 ] says the same fact from the opposite side. Mixing the two phrasings — until [ \$i -le 9 ] when you meant until [ \$i -eq 10 ] — is the most common until bug and was demoed live (see 11.6.3).

Visual intuition: draw the same turnstile as for while, but now the sign is a stop sign that is red while and flips green at . For while [ \$i -le 5 ] the gate lifted on green; for until [ \$i -eq 10 ] the gate lifts on red and drops on green. Same machine, opposite colour rule.

11.6.2 Countdown Example and the Equality Trap

A file often called until1.sh held:

i=3
until [ \$i -eq 0 ]
do
  echo \$i
  i=\$(( i - 1 ))
done

Reading the test: until [ \$i -eq 0 ] means "keep looping until is true". In words: set ; while (test false), print and decrease by one. The loop prints 3, then 2, then 1; when i becomes 0, the test becomes true and the loop stops before printing 0. If you wanted 3 2 1 0, you would need the body to print after the decrement or use until [ \$i -lt 0 ].

Worked trace — until [ \$i -eq 0 ] with i=3:

Iteration i at test [ \$i -eq 0 ]? until action Prints i after i=\$(( i-1 ))
1 3 false enters body 3 2
2 2 false enters body 2 1
3 1 false enters body 1 0
4 0 true stops, skips body

Output:

3
2
1

The equality trap — until [ \$i -ne 0 ] or until [ \$i -ne 3 ]:

If the test is written as until [ \$i -ne 0 ], the logic flips. With i=3, 3 -ne 0 is true at once, so until (which runs while false) does not enter at all and prints nothing — the opposite of what while [ \$i -ne 0 ] would do. A run first tried until [ \$i -ne 3 ] with i=3. Since is false, until did enter once and printed 3 only, which matched the opposite sense but surprised students who expected a countdown. Changing the test to -eq 0 made the intent plain: run until zero is reached.

Sense-check: For any until test, plug in the starting value before running: if the starting value already makes the test true, the loop will run zero times. That quick mental substitution catches most until bugs.

If the test was until [ \$i -ne 3 ] with i=3, the trace is:

  • i=3: [ 3 -ne 3 ] → false → until enters → prints 3i decrements to 2.
  • i=2: [ 2 -ne 3 ] → true → until stops. Only 3 printed.

The lesson: phrase the final state you want to reach, not the state you want to keep, and test with the actual starting value.

11.6.3 Counting from Zero to Nine

A classroom exercise asked to count from 0 to 9 using until. This is the exercise that exposed three successive bugs.

Attempt 1 — until [ \$i -le 9 ] with i=0: prints nothing.

i=0
until [ \$i -le 9 ]
do
  echo \$i
  i=\$(( i + 1 ))
done

With i=0, the test 0 -le 9 i.e. is true at once. Since until runs only while the test is false, it does not enter at all and prints nothing. Mistake: until with a -le keeps the loop out when you start inside the range; you wanted the loop to run inside the range.

If the test was until [ \$i -eq 9 ] with the same start and an increment, the loop printed 0 many times but missed the increment in one version, giving nine zeros — a second bug where the state never advanced.

Working version — until [ \$i -eq 10 ] that prints 0 … 9:

i=0
until [ \$i -eq 10 ]
do
  echo \$i
  i=\$(( i + 1 ))
done

In words: start at , until print and add one.

Iteration i at test [ \$i -eq 10 ]? Action Output i after
1 0 false enter 0 1
2 1 false enter 1 2
false
10 9 false enter 9 10
11 10 true stop

Full output: 0 1 2 3 4 5 6 7 8 9.

Formally the loop computes: and stops when which is tested before the body, so 10 itself is never printed.

Sense-check: while [ \$i -le 9 ] and until [ \$i -eq 10 ] are equivalent — same ten numbers, opposite phrasing. Prefer whichever matches the English prompt ("while less-than" vs "until equal-to").

Q & A — deduplicated:

Q: Can we break an infinite for or while after a number of steps? A: Yes. Put a counter and use break or continue with a test inside the loop. break leaves the loop at that point; continue skips to the next iteration. Any of for, while, until can be controlled this way. For example, for i in 1 2 3 4 5; do if [ "\$i" = "3" ]; then break; fi; echo "\$i"; done stops at 3.

Q: Should we use a for here instead of until? A: For this exercise the ask was to practise until, so keep the until form. The same count 0…9 can be done with for i in 0 1 2 3 4 5 6 7 8 9 or while [ \$i -le 9 ], but the point is to see how the stopping test is phrased for until — as the terminal value (-eq 10), not the running range (-le 9).

Teaching moment — wording "until equal ten reaches nine":

Students often say "until i <= 9" meaning "keep going while i <= 9". That is a while sentence, not an until sentence. The professor stressed: say "until i equals ten" when you want 0…9. The phrase "le true trap" is the memory hook — if you write until [ \$i -le 9 ] and start at 0, the le is true immediately, so until does nothing. Trap avoided by writing the equality of the one-past-the-last value.

Recap + bridge: until [ condition ] keeps running while the condition is false and stops when it becomes true — the mirror image of while. until [ \$i -eq 0 ] counts 3 2 1; until [ \$i -eq 10 ] starting from 0 gives 0…9; the classic bug until [ \$i -le 9 ] with i=0 gives nothing because the test is already true.

Exam note: Expect to choose the right loop for the wording — "while not at end of file" suggests while; "until equal to …" suggests until. A frequent slip to grade is until [ \$i -le 9 ] when you meant until [ \$i -eq 10 ].

Pitfalls:

  • Off-by-one: until [ \$i -eq 9 ] with i=0 stops before printing 9 if the increment is placed after the echo; placing the test as -eq 10 is the safe one-past-end style.
  • Infinite until with a test that never becomes true — e.g., i=0; until [ \$i -eq 5 ]; do echo \$i; done without i=\$(( i+1 )) prints 0 forever (the "nine zeros" bug).
  • Mixing = (string) with -eq (integer) — [ \$i = 10 ] works by coincidence for canonical decimals but fails on 010 vs 10; use -eq for numbers.

Real-world & domain connection: until appears wherever a script waits for a condition produced outside the loop — until ping -c1 dbhost >/dev/null 2>&1; do echo "waiting for DB"; sleep 2; done keeps polling until the database answers, and until [ -f /tmp/ready ]; do sleep 1; done waits until another job creates a sentinel file. The countdown shape you just debugged — until [ \$i -eq 10 ] — is the same shape as until [ "\$status" = "ready" ] used to wait for a service to become ready in deployment scripts.

11.7 Loop Control with break and continue

Hook: A loop reading a payroll CSV hits a record where the essential column — days present — is empty. Should it quietly skip that employee and keep paying everyone else, or must it stop everything right now? Two sibling keywords give the two answers. Which one abandons the whole job, and which one abandons just one pass?

11.7.1 Definitions in the Payroll Scenario

Two keywords fine-tune loops: break leaves the current loop right away and does not come back; continue leaves the current iteration and resumes with the next iteration of the same loop. Both work inside for, while, or until.

Payroll story — the analogy that carries both definitions:

Suppose a CSV holds payroll records with columns including number of days present (essential to compute monthly pay) and other optional fields that are not essential (a free-text remark, a non-critical code).

While reading records line by line:

  • If the essential column is empty for a record, you may want to break — stop the whole run and take corrective action, because you cannot compute pay without it. Continuing would produce a wrong pay slip or silently pay zero.
  • If a non-essential column is empty or garbled, you may want to continue — skip the problem field but still compute pay for that employee and keep going to the next record. The current iteration ends early; the next iteration starts normally.

So break aborts the loop; continue skips one pass. You place either inside an if that checks the field, for example checking whether a name string is empty before using it.

while IFS=, read name days rate; do
  if [ -z "\$name" ]; then
    echo "name missing, stopping" >&2
    break          # essential field missing — halt the whole payroll
  fi
  if [ -z "\$remark" ]; then
    continue       # optional field missing — skip this line's remark, next record
  fi
  # compute pay using \$days and \$rate
done < payroll.csv

A bare break or continue affects the innermost loop that encloses it. With break n or continue n (e.g., break 2) you can leave n levels of nested loops — a detail useful in nested for scans but rarely needed at this stage.

Visual intuition: picture the loop as a circular track with a do gate and a done wall. break is a trapdoor that drops you outside the circle at done. continue is a shortcut chute that jumps from mid-lap back to the do gate for the next lap. break ends the race; continue skips the rest of this lap but stays in the race.

Scope — when each is safe:

  • Use break when continuing would produce silently wrong output (missing essential data, a sentinel that says "end of file was already reached").
  • Use continue when the current item is polluted but the job can proceed (optional column garbled, a file name you want to skip, a header line).
  • A common anti-pattern is break where continue was intended — one missing optional field then halts a thousand-record payroll. Read the column's role before choosing.

11.7.2 Demonstrating break Inside for

A test inside for1.sh inserted a guarded if before the body action:

for i in hello 1 "*" 2 goodbye 1 2 3 4 5
do
  if [ "\$i" = "1" ]
  then
    break
  fi
  echo "\$i"
done

Trace — break at the first 1, all steps:

Before the fix, the test used -eq (integer) which is for integers, giving integer expression expected when i held words like hello, and it also gave too many arguments when i expanded to * and then to many file names without quoting "\$i". Changing to the string test = and quoting "\$i" fixed the parsing — the guard must be:

true only when the string i is exactly 1.

Iteration-by-iteration with the corrected guard and quoted "*" (so * stays as one item, not a file list):

Iteration \$i [ "\$i" = "1" ]? Action
1 hello false echo hello → prints hello
2 1 true break → leave loop immediately
remaining *, 2, goodbye, 1…5 never reached

Output:

hello

It printed hello then met 1 and left the loop, so nothing after 1 appeared.

A second version with numbers only made the effect clearer:

for i in 1 2 3 4 5
do
  if [ "\$i" = "3" ]
  then
    break
  fi
  echo "\$i"
done

Result:

1
2

The loop stopped before printing 3. Everything from 3 onward was skipped, and the loop did not resume — control went to after done.

Sense-check: break is not "skip 3 and continue with 4". That is continue. The trapdoor vs chute distinction maps exactly to 1 2 vs 1 2 4 5.

Pitfalls — the two errors that appeared live before the fix:

  1. [ \$i -eq "1" ] with i=hellointeger expression expected, because -eq expects integers. For words use [ "\$i" = "1" ].
  2. [ \$i = "1" ] with i=* that globbed to file names → too many arguments, because unquoted \$i expanded to many words and [ saw more than three arguments. Fix: always quote the left side — [ "\$i" = "1" ].

A third frequent slip is placing break after the echo instead of before it when you intend to skip the triggering value. Order decides whether 3 is printed.

11.7.3 Demonstrating continue Inside for

Replacing break with continue at the same spot:

for i in 1 2 3 4 5
do
  if [ "\$i" = "3" ]
  then
    continue
  fi
  echo "\$i"
done

Trace — continue skips 3 but keeps 4 and 5:

Iteration \$i [ "\$i" = "3" ]? Action
1 1 false echo 1
2 2 false echo 2
3 3 true continue → jump to next iteration, skip echo
4 4 false echo 4
5 5 false echo 5

Result:

1
2
4
5

Here 3 was skipped but the loop carried on with 4 and 5.

In the mixed list version with if [ "\$i" = "1" ] and continue, the run printed hello, skipped 1, and then printed the rest (*, 2, goodbye, …). The earlier mixed run that still had an unquoted * also produced the file-list expansion and extra diagnostics like too many arguments until quoting was added — the same glob pitfall as 11.4.3.

Contrast side-by-side for i=1…5, trigger 3:

Keyword Output What happened to 3…5?
break 1 2 3 and everything after abandoned
continue 1 2 4 5 only 3 skipped, loop resumed

Sense-check: Choose break when the triggering record means "this whole job is invalid"; choose continue when it means "this record is noisy, next one may be fine".

A useful admin idiom combines continue with the glob loop:

for f in *; do
  if [ "\$f" = "tmp" ]; then continue; fi
  if [ ! -f "\$f" ]; then continue; fi   # skip non-regular files
  echo "\$f is a regular file"
done

Recap + bridge: break exits the loop; continue exits only the iteration. The payroll analogy — essential field missing → break, optional field garbled → continue — maps directly to real CSV handling. The demos drilled the mechanics: break at 1 left only hello; break at 3 gave 1 2; continue at 3 gave 1 2 4 5. Next, you will see where those records live — inside arrays — and how gaps and slices interact with the loops that walk them.

Exam note: Know the spelling break and continue and where they go. A question may ask to fix a loop that wrongly uses -eq for string comparison; the fix is = and quoting — the same fix that cured the too many arguments in this section's * trace.

Real-world & domain connection: In production, break guards against corrupt essential state — if [ -z "\$days" ]; then echo "missing days for \$name, aborting" >&2; break; fi stops a payroll before it emits wrong slips. continue guards against optional noise — if [ "\$log" = "*.tmp" ]; then continue; fi skips temporary files while archiving logs, and if [ ! -r "\$file" ]; then echo "cannot read \$file, skipping" >&2; continue; fi keeps a backup loop going past one unreadable file instead of failing the whole nightly job. The same two-keyword choice appears in every file-processing pipeline you touched with for f in *.

11.8 Arrays — Grouping Mixed Data

Hook: A script that handles one name stores it in one variable. What about ten host names, or a whole row of CSV fields you want to keep together and revisit by position? How does the shell group many values into one named box and let you pull out the second, the third, or "all of them" on demand?

11.8.1 What a Shell Array Is and How Indexing Works

An array (ordered collection) groups values in a systematic way so you can refer to each by its position. Unlike C where an array holds one declared type such as integers or characters, a shell array can hold a mix: a number, then a string, then a single character, then another number. By default every element inside is treated as a string — that is, text. That means 10 is the two-character string "10" until you use it in arithmetic, when the shell coerces it to the integer . This is the same "string until used as number" rule you saw with remainder=\$(( input_number % 2 )) in 11.3.

Indexes start at 0 — the first slot is index 0, the second is 1, and so on. So array[0] is the first element, array[2] the third. The index helps you refer to a particular place in the array later. Special forms let you ask for the whole array (\${array[@]}) or its length (\${#array[@]}).

Array mental model — pigeonholes with numbered labels:

Picture a row of pigeonholes numbered 0, 1, 2, 3 …. Each hole can hold any word — "hello", "10", "X", "mixed". The array name (AR, array, hosts) is the label on the whole bank. \${array[2]} means "open hole 2 and read its card". \${array[@]} means "tip the whole bank out left to right". \${#array[@]} counts how many holes are actually occupied, not how many labels exist.

Two facts that surprise C programmers: (1) shell arrays are sparse — you can fill AR[0] and AR[4] and leave AR[3] empty, and the length will be 2, not 5 (see 11.8.4). (2) Every hole holds text; the shell only interprets it as a number when you write \$(( AR[0] + 1 )).

Visual intuition: draw a horizontal strip divided into boxes 0|1|2|3|4. Fill 0:"error" 1:"zero" 2:"two" 4:"four", leave 3 blank with a dashed outline. An arrow labelled \${array[@]} sweeps left to right showing error zero two four (skipping the blank). An arrow labelled \${array[2]} points only at "two". A badge \${#array[@]} shows 4 — four occupied boxes, not five.

11.8.2 Three Ways to Assign Arrays

Three assignment styles were shown, all valid and interchangeable. All create indexed arrays (numeric keys). A fourth associative style (declare -A) exists in newer Bash but was not used in this lecture.

1. Direct indexed assignment, in any order:

array[0]=10
array[1]="hello"
array[2]=X
array[3]=42
array[4]="mixed"

Here array[0] holds 10, array[1] holds hello, and so on. The order of assignment does not need to be sorted — you can set array[4] before array[0] and the array still has the same contents.

2. Compound indexed assignment in one line — index given explicitly:

array=([0]=10 [1]=20 [2]=30 [3]="program")

Each bracket names the index for the following value. This form lets you create sparse sets in one line: array=([0]="a" [3]="d") leaves 1 and 2 empty.

3. Short form without naming indexes — position decides:

array=(0 10 20 program)

Here placement decides the index: 0 goes to index 0, 10 to index 1, 20 to index 2, program to index 3. A fourth example mixes both: first value goes to 0, second explicitly to 3, leaving a gap, then further values follow. Gaps are allowed: array=(a b [5]=c d) puts a at 0, b at 1, c at 5, d at 6.

Choosing a form: Use direct array[2]=… when building gradually in a loop; use array=( … ) when the whole list is known upfront; use array=([2]=… [5]=…) when you need explicit sparse indexes. All three produce the same internal sparse indexed array — the reading operations in 11.8.3 behave identically regardless of how the array was created.

11.8.3 Printing: All Elements, Single Element and Slices

To print the whole array in one line you can use either * or @ inside the expansion:

echo \${array[@]}
echo \${array[*]}

Whole-array expansions — subtle difference:

  • \${array[@]} — each element is a separate word. When quoted as "\${array[@]}", it expands to "\$array[0]" "\$array[1]" … — preserves spaces inside elements.
  • \${array[*]} — when quoted as "\${array[*]}", it joins all elements into one word with the first character of IFS (normally a space) between them.

In the unquoted demonstration echo \${array[@]} vs echo \${array[*]} both printed all elements separated by spaces, so the session noted both forms for that purpose. The important habit for loops is:

for item in "\${array[@]}"; do echo "\$item"; done   # safe, each element one turn

Using "\${array[*]}" in a for would give one iteration with the whole joined string — rarely what you want. With @ you visit each hole individually; with * you visit the concatenated banner.

Single element:

echo \${array[0]}   # hole 0

Or you can use the array name alone, because the bare name points to the first element:

echo \$array
# same as \${array[0]} in this shell

That bare-name shortcut is easy to misread — always prefer \${array[0]} in scripts you will share.

Particular element and slices: To print a particular element name its index, e.g. \${array[2]}. To print a range (slice) you give a start index and a count:

echo \${array[@]:1:2}   # starting at index 1, take 2 elements → holes 1 and 2
echo \${array[@]:2:2}   # starting at 2, take 2 → holes 2 and 3 (if 3 exists)
echo \${array[@]:2}     # from index 2 to the end

The form shown was \${array[@]:2:2} or similar, described as "print elements between indexes 2 and 4". The key point is that 2 and 4 are indexes, not values — you slice by position. Newer Bash also supports \${array[@]: -1} for the last element.

Concrete walkthrough — AR with a gap:

AR[0]=error
AR[1]=zero
AR[2]=two
# AR[3] left empty
AR[4]=four

Printing all:

echo \${AR[@]}
# → error zero two four
echo \${AR[*]}
# → error zero two four   (unquoted, looks the same)

The empty slot at index 3 does not appear as a word; it is simply absent — no extra word is produced for the missing index.

Single and slices:

echo \${AR[0]}          # → error
echo \$AR               # → error (same as \${AR[0]})
echo \${AR[2]}          # → two
echo \${AR[@]:1:2}      # → zero two   (holes 1 and 2)
echo \${AR[@]:2:2}      # → two four   (hole 2, then skip empty 3, then 4 — but count is by position, so with sparse arrays the result depends on Bash version's handling of gaps; the dense demo used contiguous data)

Quoted vs unquoted for elements with spaces:

arr=("hello world" "I am" fine)
echo "\${arr[@]}"   # three words: "hello world"  "I am"  "fine"
echo "\${arr[*]}"   # one word:  "hello world I am fine"

Sense-check: If you iterate with for x in \${AR[@]} (unquoted) and an element contains a space, it splits into two iterations. The safe loop is always for x in "\${AR[@]}".

The session printed a slice from index 1 and got the middle items, and printed from 2 and got a different window — both demonstrated that slicing is by index, the same index used in assignment.

11.8.4 Length and Gaps

Length is asked with #:

echo \${#array[@]}
echo \${#array[*]}

Both return the number of elements that are actually set. In the example with AR[0], AR[1], AR[2], AR[4] and nothing at AR[3], the length was 4, not 5.

Teaching moment — array gap: missing index not counted.

A missing index does not count as a null element; it is no element. The array AR[0]=error, AR[1]=zero, AR[2]=two, AR[4]=four has length 4, not 5, because AR[3] was never assigned. Bash does not create an empty string at the gap; it simply does not count the hole. This matches the rule that gaps do not create counted slots. Verifying live:

AR[0]=error; AR[1]=zero; AR[2]=two; AR[4]=four
echo \${#AR[@]}   # → 4
echo \${#AR[*]}   # → 4
echo \${AR[@]}    # → error zero two four  (no blank where AR[3] would be)

If you later set AR[3]="" (explicit empty string), then the length becomes 5 but element 3 prints as empty. The distinction between "never set" and "set to empty string" matters for \${#array[@]} vs iterating and testing [ -z "\${AR[3]}" ].

When gaps appear: Mixing forms like array=(a b [5]=c d) or assigning array[10]=x far ahead creates a sparse array intentionally — useful for mapping numeric IDs. The loop for i in "\${!array[@]}" (exclamation mark) iterates over the indexes that exist (here 0 1 5 6), not 0…6.

Real-world illustration: administrators store lists of hosts, file names, or payroll fields in arrays and then loop safely:

hosts=(web1 web2 db1)
# or read from a file:
# mapfile -t hosts < hostlist.txt
for h in "\${hosts[@]}"; do
  echo "Checking \$h"
  ssh "\$h" uptime
done

To act on each element by index and show the position:

for idx in "\${!AR[@]}"; do
  printf "AR[%s]=%s\n" "\$idx" "\${AR[idx]}"
done

Recap + bridge: A shell array is a numbered set of string slots (0 …), assignable as array[0]=…, array=([0]=…) or array=( … ), read as \${array[0]}, \${array[@]} (all holes) or slices \${array[@]:start:count}, and measured with \${#array[@]} counting only occupied holes — a gap is not a counted null. You have now seen every grouping tool: for walks a list, while/until guard it, break/continue trim it, and arrays store it.

Exam note: Practice the expansions exactly, including braces, #, @, *, and the slice : . A common error is to write \$array[0] without braces — the shell parses that as \$array followed by the literal [0]. The correct form is \${array[0]}. Also know when to quote: for item in "\${array[@]}" (correct) vs for item in \${array[*]} (collapses spaces).

Real-world & domain connection: In DevOps and data pipelines, arrays turn a repeated scalar variable into a vector you can loop, filter, and slice. A build matrix stores versions=(14 16 18) and iterates for v in "\${versions[@]}"; do nvm use \$v && npm test; done. A log-rotation array logs=(/var/log/app.log /var/log/nginx/access.log) is filtered with for log in "\${logs[@]}"; do [ -f "\$log" ] && [ -s "\$log" ] && gzip "\$log"; done — the combination of array, for, and file tests (-f, -s) from 11.10 is exactly what this lecture was building toward.

11.9 String Tests and Operators

Hook: You just saw words silently become 0 in arithmetic. How do you stop a script from even trying the arithmetic when the field that should hold a name comes back empty — before you divide, before you echo, before you compute pay?

11.9.1 Equality, Inequality and Emptiness Tests

When comparing strings you use single-bracket tests [ … ] with string operators — integer operators like -eq are for numbers and will mis-handle words.

String operators — the four you must be able to write from memory:

Operator Written as True when Example Formal
= = the two strings are byte-identical [ "\$a" = "\$b" ] as strings
!= ! = with a space the strings differ [ "\$a" != "\$b" ]
-z dash z (zero length) string has zero length (empty) [ -z "\$str" ]
-n dash n (non-zero) string has non-zero length (not empty) [ -n "\$str" ]
bare [ "\$str" ] string is not empty [ "\$str" ] same as -n

Return values follow the test sense: when the described condition holds, the test exits 0 (true); otherwise it exits non-zero (false). These are string comparisons — 10 and 010 are different strings even though 10 -eq 010 is true as integers.

The session stressed not to use -eq for string comparison: -eq expects integers and gives integer expression expected when given words. Similarly, do not write = inside \$(( … ))= there is assignment, not comparison.

Payroll motivation — guard before use:

A name field should not be assumed to be present. Before using it you pull the word, for example the third column of a CSV line, into a variable such as name, and then test:

if [ -z "\$name" ]; then
  echo "name missing, stopping" >&2
  break
fi

If the name is empty you stop (essential field → break from 11.7); otherwise you carry on. An optional-field variant uses continue instead of break. The test "\$name" alone would also work ([ "\$name" ]), but -z makes the emptiness intent explicit and is preferred in production for readability.

Quoting rule: always write "\$var" inside [ ]. Without quotes, an empty \$name disappears and [ -z \$name ] becomes [ -z ], which tests whether the string "-z" is non-empty — always true, the opposite of what you intended. With spaces, [ \$str = we ] where str="hello world" becomes [ hello world = we ] — four words, too many arguments.

Visual intuition: picture a ruler that measures the length of the string card "\$str". -z asks "is the ruler at zero?" (green if empty). -n asks "is the ruler past zero?" (green if any characters). = puts two cards side by side and asks "are they identical, character for character?".

Teaching moment — string = vs integer -eq:

The session used the payroll example to trigger the diagnostic integer expression expected live: [ "\$i" -eq "hello" ] where i=hello is a word comparison attempted with an integer operator — integer test on a word is an error. The fix is [ "\$i" = "hello" ] — string operator for string data, and quoting "\$i" so an empty value or a * that could glob does not break the test. This is the same fix that cured the break demo's too many arguments in 11.7 — = plus quotes is the safe pair for words.

Scope — bare string test [ "\$str" ] vs [ -n "\$str" ] vs [ -z "\$str" ]:

  • [ -z "\$str" ] — explicit emptiness check; best when the next line is an error message about missing data.
  • [ -n "\$str" ] — explicit non-emptiness; best when the next line processes the string.
  • [ "\$str" ] — shorthand for -n; compact but less self-documenting; avoid when teaching intent.
  • != needs a space between ! and =: != (correct) vs ! = is not the same tokenisation — write != adjacent.

All three emptiness forms require "\$str" quoted, otherwise empty and multi-word strings mis-parse.

11.9.2 Worked Walkthrough: string1.sh

A file named string1.sh built the checks step by step with STR="we":

STR="we"
printf "check -z\n"
if [ -z "\$STR" ]; then echo "empty"; else echo "not empty"; fi

printf "check -n\n"
if [ -n "\$STR" ]; then echo "not empty"; else echo "empty"; fi

printf "bare\n"
if [ "\$STR" ]; then echo "not empty"; else echo "empty"; fi

printf "equality\n"
if [ "\$STR" = "we" ]; then echo "strings are same"; else echo "strings are not same"; fi

Full trace — STR="we" then STR="hello":

With STR="we" (length 2, content we):

Test Evaluates as Mathematical reading Result Prints
[ -z "\$STR" ] [ -z "we" ] false (exit 1) not empty via else
[ -n "\$STR" ] [ -n "we" ] true (0) not empty
[ "\$STR" ] [ "we" ] true not empty
[ "\$STR" = "we" ] [ "we" = "we" ] true strings are same

The run recorded was:

Using operator -z
not empty
Using operator -n
not empty shown as non-zero length
bare test
not empty
we = we
strings are same

Changing only one line to STR="hello" and re-running the equality check:

STR="hello"
if [ "\$STR" = "we" ]; then echo "strings are same"; else echo "strings are not same"; fi
Test Evaluates as Result Prints
[ "hello" = "we" ] false strings are not same

Now the output is strings are not same, because the strings differ. The -z/-n/bare tests would still say not empty (length 5), only the = test changed.

Run-it-yourself check: set STR="" (empty). Then [ -z "\$STR" ] becomes true → empty; [ -n "\$STR" ] and [ "\$STR" ] become false → empty; [ "\$STR" = "we" ] becomes false. This is exactly the payroll guard shape.

Sense-check: Every branch above follows one rule — string operators compare text, not numeric value. "10" = "010" is false as strings even though 10 -eq 010 is true as integers.

Q & A:

Q: The last check seemed to print we everywhere — why did the session notes look uniform? A: Because the printf before each test printed the literal prompt (e.g., printf "equality\n"), while the test itself expanded "\$STR". With STR="we" the expanded value is we in each comparison ([ "we" = "we" ]), so every echo inside the then looked like it used we until the value was changed to hello, when the equality correctly became not same. Reading the script line-by-line — printf text vs "\$STR" expansion — clears the confusion.

Pitfalls:

  • Using -eq or -ne to compare words — gives integer expression expected; use = / != for words.
  • Forgetting quotes: [ \$STR = we ] with STR="" becomes [ = we ] — syntax error; always [ "\$STR" = "we" ].
  • Writing [ \$a !=\$b ] without a space before != or after — the shell sees a single word, not an operator; spaces around = and != are mandatory.
  • Confusing -z and -n: -z is true when zero length (empty, "missing"); -n is true when non-zero (present, "has content").

Recap + bridge: String tests are [ "\$a" = "\$b" ], [ "\$a" != "\$b" ], [ -z "\$str" ] (empty), [ -n "\$str" ] / [ "\$str" ] (not empty). They compare text; -eq and friends are for integers. The demo with STR="we" vs STR="hello" proved = is literal, and changing STR to "" drills the guard you need before any payroll arithmetic.

Exam note: Know the exact spelling -z, -n, =, != and the bare form. Expect a short script that decides empty vs not empty and a second branch same vs not same — and a fixing task where -eq was wrongly used for words.

Real-world & domain connection: Every robust shell pipeline starts with string guards. A deploy script does if [ -z "\$VERSION" ]; then echo "VERSION not set" >&2; exit 1; fi before touching production. An ETL job checks if [ "\$status" = "success" ]; then … else …; fi rather than [ \$status -eq … ]. The payroll CSV pattern this lecture used — [ -z "\$name" ]break — is literally how HR batch jobs prevent emitting pay slips with blank names while letting optional fields through with continue.

11.10 File Tests and System Administration Checks

Hook: A nightly job loops for f in * and must decide, for each name, "are you a directory I should descend into, a regular file I can compress, a device I must never truncate, or a link I should follow?" One-letter switches answer that — but -s does not mean "symbolic link" everywhere. How do you ask the file system the right question?

11.10.1 File Type Tests for Administrators

File tests are very useful for system and network administrators who must inspect many paths on a machine. The form is [ -letter "\$path" ] where the letter selects the test. The test runs the stat idea underneath and returns true (exit 0) if the path exists and has that file type.

Type tests — the set mentioned plus the portable clarification:

These tests are all single-bracket [ ] operators, always with a quoted "\$path":

Letter Portable meaning True when Typical paths
-f regular file a plain file (not directory, not device) /etc/passwd, ./script.sh
-d directory a directory /tmp, /home/user
-b block device a block special file (buffered, e.g., disk partition) /dev/sda, /dev/nvme0n1
-c character device a character special file (unbuffered, e.g., terminal) /dev/tty, /dev/null, /dev/sda is not -c
-e exists any form exists (any type) any existing path
-L / -h symbolic link (portable) a symlink, regardless of target /usr/bin/python → python3
-p named pipe (FIFO) a pipe special file /tmp/myfifo
-S socket a socket /var/run/docker.sock

Clarification on -s vs -L / -h: The session used -s in its local shorthand to mean "symbolic", but in standard test/[ ] the portable spellings for symlink are -L and -h (both mean the same), while *-s means size greater than zero (non-empty file)* — see 11.10.2. Mixing them silently asks the wrong question: [ -s "\$link" ] tests "is this file non-empty?" not "is this a symlink?". Always write [ -L "\$link" ] or [ -h "\$link" ] for symlinks and [ -s "\$file" ] for non-empty size. The table above records the portable contract so an exam answer using -L/-h is marked correct.

The idea in each case: [ -d "\$path" ] is true only when path is a directory; [ -b "\$path" ] is true only for a block device such as a disk partition entry; [ -c "\$path" ] is true only for a character device such as a terminal. If the path does not exist, all type tests are false (except -e for existence itself).

The earlier for f in * loop gains power when combined with these tests. You can visit every name with * and then sort them by type inside the loop:

for f in *; do
  if [ -f "\$f" ]; then echo "\$f is a regular file"
  elif [ -d "\$f" ]; then echo "\$f is a directory"
  elif [ -L "\$f" ]; then echo "\$f is a symlink"
  elif [ -b "\$f" ]; then echo "\$f is a block device"
  elif [ -c "\$f" ]; then echo "\$f is a character device"
  fi
done

Scope — quoting "\$path" is mandatory when testing names: A file named my report.txt with a space becomes two words without quotes, giving too many arguments. A variable that is empty makes [ -f \$file ] become [ -f ], which tests the string "-f" for non-emptiness — always true. Always write "\$file" or "\$path".

11.10.2 Permission and Special File Tests

Beyond type, tests check permissions and other properties — the questions an administrator asks during audits or before a destructive write.

Permission and property tests:

Letter True when Meaning for an admin
-r readable you can read the file (cat will work)
-w writable you can write to the file (may still fail due to ACL or read-only FS)
-x executable (or searchable for directories) you can run it as a program, or cd into it
-s size greater than zero (non-empty) file has content — distinguishes empty logs from active ones
-e exists path exists in any form — weakest test, use when type does not matter
-u / -g setuid / setgid bit set privilege-escalation check
-k sticky bit set common on /tmp

Distinction drill — -s vs -L / -h:

  • [ -s "\$file" ] → tests size — true for a non-empty regular file such as app.log with 10 bytes, false for a newly created empty touch empty.txt.
  • [ -L "\$file" ] → tests symlink — true for link → target even if the target is empty.

Testing [ -s "\$link" ] to mean "is symlink" confuses the two; testing [ -L "\$emptyfile" ] to mean "has content" reverses them.

In class the pattern was to start from a working file test such as -x for executable, then swap the letter to ask a different question — the same if [ -letter "\$file" ] skeleton works for many checks:

if [ -x "\$file" ]; then echo "executable"; fi
if [ -d "\$file" ]; then echo "directory"; fi
if [ -b "\$file" ]; then echo "block file"; fi
if [ -c "\$file" ]; then echo "character file"; fi
if [ -w "\$file" ]; then echo "writable"; fi
if [ -s "\$file" ]; then echo "has content"; fi

Each if prints only when its letter matches the real file. The session cycled through -d, -b, -c, -x, -w, and the symlink case to show that the same skeleton works for audits or setup checks.

Visual intuition: imagine each file as a card with five labelled holes (f, d, b, c, L) and three coloured stamps (r, w, x). Holding the card up to a light, the -letter test shines a beam through that hole — light comes through (true, 0) only when the hole is punched for that card. -s is a scale that tips only when the card has weight (size > 0).

11.10.3 Example Checks on Directories and Devices

A file named with a test for existence, often if_there.sh or similar, was used to ask about a path:

path="/dev/sda"
if [ -b "\$path" ]; then echo "block device"; else echo "not a block device"; fi

path="/dev/tty"
if [ -c "\$path" ]; then echo "character device"; else echo "not a character device"; fi

path="/tmp"
if [ -d "\$path" ]; then echo "directory"; else echo "not a directory"; fi

Trace — what each test sees on a typical Linux machine:

[ -b "/dev/sda" ] — block device? /dev/sda is the first SCSI/SATA disk; its entries are block-buffered (reads go through the buffer cache), so [ -b "/dev/sda" ] is true on a system with that disk → prints block device. On a VM without /dev/sda (e.g., nvme0n1 instead), the same test is false → not a block device — the path simply does not exist or is not block type.

[ -c "/dev/tty" ] — character device? /dev/tty is the controlling terminal — a character device (unbuffered, char-by-char), so [ -c "/dev/tty" ] is true → character device. /dev/null is also a character device; contrasting with /dev/sda highlights buffered vs unbuffered.

[ -d "/tmp" ] — directory? /tmp is a directory (often 1777 with the sticky bit), so [ -d "/tmp" ] is true → directory. If you tested [ -f "/tmp" ] it would be false — /tmp is not a regular file. That is exactly why for f in * combined with [ -f "\$f" ] skips /tmp when you iterate over a directory listing.

Negative cases (guarding before a destructive action):

Intended write Guard If omitted
cat "\$log" [ -f "\$log" ] — is it a regular file? You might cat a directory and get Is a directory
> "\$log" truncate [ -w "\$log" ] and [ -f "\$log" ] You might truncate a device file or a read-only mount
rm "\$f" [ -L "\$f" ] before rm vs unlink You might follow a symlink and remove the target unexpectedly
Skip empty logs [ -s "\$f" ] — non-empty? You archive thousands of empty log skeletons

Sense-check: The teaching point is not the specific names /dev/sda vs /dev/nvme0n1 but the method: pick the right letter, quote "\$path", branch on the result, and test the negated form if [ ! -f "\$path" ]; then … when you mean "if this is not a regular file, skip it".

Pitfalls:

  • Confusing -s (size > 0) with symlink — use -L / -h for symlinks, -s for non-empty. This was the session's warned shorthand and is a prime exam distractor.
  • Using [ -e "\$path" ] when you meant [ -f "\$path" ]-e is true for directories too, so a directory passes an "exists" test you intended to restrict to files.
  • Testing devices that do not exist on the lab machine — give the answer as "depends on the machine; the method is if [ -b "\$path" ]" rather than guessing the output for /dev/sda when the VM has no such entry.
  • Forgetting -r vs -x: a file can be -r (readable) without being -x (executable), and a directory that is -x is searchable, not "executable" — a frequent terminology slip.

Recap + bridge: File tests are [ -letter "\$path" ]-f file, -d directory, -b block, -c character, -L/-h symlink, -r/-w/-x permissions, -s size > 0, -e exists. They combine with for f in * to visit names and sort them by type — the capstone shape of this whole lecture: case dispatches, for/while/until repeat, break/continue trim, arrays store, string tests guard content, file tests guard the file system.

Exam note: Know how to spell the tests and to combine them with loops and string checks. A serviceable pattern you must be able to write from memory is:

for f in *; do
  if [ -f "\$f" ]; then echo "\$f is a regular file"; fi
done

Expect variants that ask for -d, -L, -x, or -s in the same skeleton, and expect a question that tests the -s vs -L distinction directly.

Real-world & domain connection: System and network administrators run these tests in nightly audit jobs that would be tedious by hand. A single loop for f in /var/log/*; do if [ -f "\$f" ] && [ -s "\$f" ] && [ ! -L "\$f" ]; then gzip "\$f"; fi; done archives only regular non-empty non-symlink logs. Before rotating a log you test [ -f "\$log" ] to be sure the log is a regular file; before writing you test [ -w "\$log" ]; before following a link you test [ -L "\$link" ] so you do not follow a symlink out of the intended directory. The permissions checks -r, -w, -x appear in hardening scripts that flag world-writable files ([ -w "\$f" ] plus mode checks) or non-executable scripts that should be executable.

Exam Guidance Summary

  • Assignment is the major assessment for this block. Expect shell-scripting problems that require combining commands with arguments inside loops and branches — a single clever one-liner will not suffice. Group size is two; form your own group and submit one joint solution. Marks weight scripting and command fluency. Keep notes from this and the previous class together; the assignment draws on both. Slides for both sessions will be uploaded; a separate announcement will collect the group list, statement, and presentation schedule.
  • Focus stays on shell control structures. No outside topics were introduced as examinable — keep effort on case (with ) , ;;, esac, * default, | alternation), for (list form for i in …; do … done and C-style for (( c=1; c<=5; c++ )) with Bash/sh portability), while (while [ \$i -le 5 ], while : infinite with 130 on Ctrl-C), until (until [ \$i -eq 10 ] for 0…9, the -le true-trap), break/continue (payroll essential→break, optional→continue, fix -eq= and quoting), arrays (\${array[0]}, \${array[@]}, \${#array[@]}, slices \${array[@]:2:2}, gaps not counted), string tests (-z empty, -n/bare not-empty, =/!= with quotes), and file tests (-f, -d, -b, -c, -L/-h symlink vs -s non-empty, -r/-w/-x, -e).
  • Question patterns practised and likely to recur:
  • Fix a missing ;; or esac or ) in a case; predict output of case "\$input" in hello|hi) vs hello) vs *) vs "*") literal star.
  • Compute a remainder with \$(( n % 2 )) versus ` expr \$n % 2 and branch on it with case \$remainder in 0) — and explain why a word input gives 0 (non-numeric → 0`, not ASCII) and therefore "even".
  • Predict output of a for over a mixed list with * expansion (expands to file list when unquoted, stays * when '*'/"*"/\*), and of for i in 1 2 hello with \$(( i+1 )) (words → 1).
  • Count from 0…9 with for, while, and until; the until bug until [ \$i -le 9 ] with i=0 gives nothing; the fix is until [ \$i -eq 10 ].
  • Add a string emptiness test [ -z "\$name" ] before using a CSV field, choosing break vs continue by whether the column is essential.
  • Write a file-type loop: for f in *; do if [ -f "\$f" ]; then echo "\$f is a regular file"; fi; done and variants with -d, -L, -x, -w, -s — with the -s vs -L distinction tested directly.
  • Run echo \$? after a normal exit (0) vs after killing an infinite while : loop (130).
  • Study advice — run, break, fix, re-run: Revisit every script from this and the previous class — case.sh, case_odd_num.sh, for1.sh, for2.sh, while guarded and infinite variants, until1.sh, the array demo, string1.sh, and the file-test example. Run them yourself, change one thing at a time, and read the error messages. The diagnostics that already appeared are your revision checklist: syntax error near unexpected token (missing ;;/)), integer expression expected (-eq on a word → use =), too many arguments (unquoted "\$var" with spaces or * → many words), and recursion level exceeded (i=i with \$(( \$i+1 )) → rename variable).
  • Presentation advice — what graders reward: Start every script with a shebang #!/bin/bash on its own first line. Quote variables that hold strings ("\$var", "\$f", "\$name"). Show steps in comments. Put do and done (or then/fi, case/esac) on distinct lines rather than dense one-liners — clear flow is easier to grade. Make scripts executable (chmod +x) and test with both bash script.sh and sh script.sh when you rely on for (( )).
  • Logistics and next quiz: Slides for today and the previous class will be uploaded shortly. Expect a separate announcement for group-list collection, assignment statement, and presentation schedule. A second quiz will come a little later after a few more topics are covered, so keep notes on loops, arithmetic, and tests fresh rather than cramming at the last minute.

Key Industry Applications

  • Payroll and HR pipelines — while + CSV + case + break/continue: A production payroll job does while IFS=, read name days rate remark; do if [ -z "\$name" ]; then break; fi; case "\$days" in ''|*[!0-9]*) continue;; esac; pay=\$(( days * rate )); printf "%s,%s\n" "\$name" "\$pay"; done < payroll.csv. Essential fields (name, days) trigger break or validation with case patterns before arithmetic — because a word silently becomes 0 (hello→0→1 after +1 as seen in 11.3/11.4), so guarding before \$(( )) prevents a silent zero-pay error. Optional fields trigger continue. The case-dispatched odd-even logic and the string guards (-z, = ) are the same shapes you debugged in class.
  • File-system and permission auditing — for f in * + file tests: System and network admins loop with for f in * (glob expands to names before the loop, Section 11.4.3) and sort inside with [ -f "\$f" ], [ -d "\$f" ], [ -b "\$f" ], [ -c "\$f" ], [ -L "\$f" ], [ -x "\$f" ], [ -w "\$f" ], [ -s "\$f" ], [ -e "\$f" ] — the exact set from 11.10. Examples: for f in /var/log/*; do if [ -f "\$f" ] && [ -s "\$f" ] && [ ! -L "\$f" ]; then gzip "\$f"; fi; done archives only regular non-empty non-symlink logs; if [ -w "\$log" ] guards a truncate before rotate; if [ -L "\$link" ] avoids following a symlink out of the intended directory; while :; do check_disk; sleep 60; done monitors forever until Ctrl-C (exit 130).
  • Host-list and config vectors — arrays + quoted for: Data-prep and deployment scripts store hosts=(web1 web2 db1) or versions=(14 16 18) and iterate safely with for h in "\${hosts[@]}"; do ssh "\$h" uptime; done (Section 11.8). The \${#hosts[@]} count drives progress bars, \${hosts[@]:1:2} slices a rolling window, and gaps (hosts[0]=a hosts[4]=b) are handled by \${!hosts[@]} — the sparse-array lesson from AR[0]=error … AR[4]=four with length 4. Quoting "\${array[@]}" preserves spaces inside elements, the safe habit from 11.8.3.
  • Input validation as a gate before arithmetic — string tests + case patterns: Treating a word as 0 when testing n % 2 (Section 11.3.4) is a known shell pitfall class. Production scripts precede remainder=\$(( n % 2 )) with case "\$n" in ''|*[!0-9]*) echo "not a number: \$n" >&2; continue;; esac or [ -z "\$n" ] guards — the same -z/-n/= operators from 11.9. Without that gate, empty CSV fields and stray words produce silently wrong branches in case \$remainder in 0) .
  • Literal vs pattern handling at scale — quoting *: Log processing and file handling often need the literal character * or ? in searches; quoting or escaping (\*, "*", '*') keeps the character literal instead of expanding it to a file list (Section 11.4.4 and Q&A on "*") literal-star). The reverse — unquoted * as glob — is intentional when scanning a directory. Knowing which spelling to use is the difference between a regex that searches for *.log and a shell that expands *.log before grep even starts.
  • Choosing the right loop form — list vs C-style vs test-driven: Both the classic list form for i in 1 2 3 4 5 and the C-like for (( i=1; i<=5; i++ )) appear in DevOps tooling (Section 11.4.5); the C-like form (c+=2 for steps) is common in Bash-heavy benchmark loops while the list form is the portable way to process arbitrary word lists and * glob results. Test-driven while [ "\$input" != "bye" ] and until [ \$i -eq 10 ] (Sections 11.5/11.6) implement interactive REPLs, until ping -c1 dbhost wait loops, and end-of-file readers — the triad you now choose from by wording: "while not …" → while, "until …" → until, "for each …" → for.

SP Lecture 11 notes · Shell Scripting: Control Structures, Loops, Arrays and String Handling

Systems Programming· postgraduate· 2026-08-20

Sections Breakdown

1Assignment Context and Recap of Previous Shell Concepts

Recap of wildcards, command substitution, special variables ($?, $#, $@, $0, $$), shebang, variables, and if — the foundations for case and loops.

2The case Statement — Many-Way Branching

Many-way branch case ... in pattern) ;; ... esac; first-match-wins, ;; is the break, * is catch-all, patterns are globs with | alternation and literal "*" via quotes.

3Arithmetic Forms and the Odd-Even Program with case

Integer arithmetic via $(( )) and expr; remainder = n mod 2 routes via case 0) even 1) odd; words coerce to 0 and falsely appear even.

4The for Loop — List and C-Style Forms

List form for var in list; do done walks words left-to-right (mixed types, glob * to files); C-style for ((c=1;c<=5;c++)) counts with init/test/step; brace expansion {1..5}.

5The while Loop — Test-Driven Repetition

while [ condition ]; do done repeats while test true; integer tests -le -lt -ge -gt -eq -ne; while : is forever; guarded while [ "$str" != "bye" ] reads until sentinel.

6The until Loop — Running Until a Condition Becomes True

until [ condition ]; do done runs while test false until true; dual to while [ ! condition ]; pitfalls with -le true-trap and off-by-one for 0..9 via until eq 10.

7Loop Control with break and continue

break aborts the loop, continue skips one iteration; payroll analogy essential->break optional->continue; demos at 3 give 1 2 vs 1 2 4 5; need string = and quotes.

8Arrays — Grouping Mixed Data

Sparse indexed arrays: array[0]=, array=( ) and array=([0]= ); read via ${array[0]}, ${array[@]}, slices ${array[@]:s:n}, length ${#array[@]} counts occupied holes only.

9String Tests and Operators

String tests: = same, != different, -z zero length empty, -n non-zero not empty, bare [ "$str" ] true if not empty; string1.sh demo with STR we vs hello.

10File Tests and System Administration Checks

File tests [ -letter "$path" ]: -f regular, -d dir, -b block, -c char, -L/-h symlink, -r/-w/-x perms, -s non-empty, -e exists; loop for f in * sorts by type.

11Exam Guidance Summary

Assignment heavy on scripting; focus areas case/for/while/until/break/arrays/string/file tests; study by running and fixing scripts and reading diagnostics.

12Key Industry Applications

Payroll CSV, file auditing, host arrays, input validation, literal vs glob, loop choice — production mappings of every construct.

Postgraduate students in Systems Programming

Exam Revision Notes

Below is the distilled, exam-ready core. Every entry comes from the full explanation above. Use this section for rapid review; return to the main notes when a point needs more context.

Assignment Context and Recap of Previous Shell Concepts

Must-know: Wildcards glob before commands; $? is 0 on success; variables are name=value without spaces; shebang #!/bin/bash selects interpreter.

⚠️ Top pitfall: Forgetting quotes around "var"; writing name = value with spaces; reading $? after wrong command.

Self-check: What does echo $? show after a successful command vs after Ctrl-C on while : ?

Connects to: 11.2, 11.4

The case Statement — Many-Way Branching

Must-know: case $var in pattern) commands ;; ... *) default ;; esac; ;; is the break; * last is default; first match wins.

⚠️ Top pitfall: Forgetting ) after pattern or ;; at block end; using bare * for literal star; expecting C fall-through.

Self-check: What happens when case input is hello me with patterns hello) and * )?

Connects to: 11.3

Arithmetic Forms and the Odd-Even Program with case

Must-know: remainder = input_number mod 2 via remainder in 0) even ;; 1) odd ;; esac; non-numeric → 0 → even.

⚠️ Top pitfall: No guard before $(( )) so words become 0 and test even; missing ;; gives syntax error.

Self-check: What remainder does a give under $(( a % 2 )) and which case branch runs?

Connects to: 11.4, 11.9

The for Loop — List and C-Style Forms

Must-know: for i in 1 2 3 do done walks list; * globs to files unless quoted; for ((c=1;c<=5;c++)) is Bash C-style.

⚠️ Top pitfall: Unquoted * expands to file list; "I am fine" is three words; i+1 )) with i=i causes recursion.

Self-check: For i in hello 1 * 2 what does * become and how to keep it literal?

Connects to: 11.5, 11.10

The while Loop — Test-Driven Repetition

Must-know: while [ ((i+1)); done; : always true so while : is infinite; Ctrl-C gives exit 130; quote "$str" in string tests.

⚠️ Top pitfall: Missing i=str" with spaces gives too many arguments.

Self-check: Why does echo $? show 130 after Ctrl-C on while : ?

Connects to: 11.6

The until Loop — Running Until a Condition Becomes True

Must-know: until [ i -le 9 ] with i=0 prints nothing.

⚠️ Top pitfall: Writing until [ i -eq 10 ]; off-by-one missing increment gives repeated zeros.

Self-check: Why does i=0; until [ i; done print nothing?

Connects to: 11.5, 11.7

Loop Control with break and continue

Must-know: break leaves the loop; continue skips iteration; for i in 1 2 3 4 5 break at 3 gives 1 2, continue at 3 gives 1 2 4 5.

⚠️ Top pitfall: Using -eq for words gives integer expected; unquoted $i with * gives too many arguments.

Self-check: For i in 1 2 3 4 5 with if [ "$i" = "3" ]; then continue; fi what prints?

Connects to: 11.8, 11.9

Arrays — Grouping Mixed Data

Must-know: array=(a b) or array[0]=a; echo {array[0]} first; ${#array[@]} length counts only set indexes; gap not counted.

⚠️ Top pitfall: Writing {array[@]}" splits spaces; * vs @ quoting.

Self-check: AR[0]=a AR[1]=b AR[4]=c length is 3 or 5 and why?

Connects to: 11.4, 11.10

String Tests and Operators

Must-know: [ "b" ] same strings; [ -z "str" ] not empty; never -eq for words.

⚠️ Top pitfall: Using -eq for words gives integer expected; forgetting quotes around "$str"; missing space around =.

Self-check: What does [ -z "we" ] return and what does it print in if then else?

Connects to: 11.7, 11.10

File Tests and System Administration Checks

Must-know: [ -f "$path" ] file, -d dir, -b block, -c char, -L symlink, -s non-empty, -r/-w/-x perms; use -L not -s for symlink.

⚠️ Top pitfall: Confusing -s size>0 with -L symlink; using -e when -f needed; forgetting quotes around "$path".

Self-check: Write loop for f in * that prints only regular files.

Connects to: 11.4, 11.8

Exam Guidance Summary

Must-know: Be ready to fix ;; esac, predict glob, explain word->0, count 0..9 with until eq 10.

⚠️ Top pitfall: Missing ;; or quotes; using -eq for strings.

Self-check: Which loop form matches wording until equal to vs while not at end?

Connects to: 11.2, 11.6

Key Industry Applications

Must-know: while read CSV + break/continue, for f in * + file tests, arrays + for, guard before $(( )).

⚠️ Top pitfall: No validation before arithmetic gives silent 0; unquoted * expands unexpectedly.

Self-check: How to guard n%2 when input may be a word?

Connects to: 11.3, 11.10

Was this lecture useful?

Loading comments…
🤖

BitsNotes AI Assistant

Subject Notes Assistant

Configure AI Chat

Choose how to access the chatbot
Have your own API key?

Switch to "Bring Your Own Key" tab above for unlimited access with any OpenAI-compatible provider.

🔑 Enter API key above to fetch live models from provider, or enter model name manually.
OpenAI-Compatible API Support

Choose any provider preset (Gemini, DeepSeek, Kimi, GLM, MiniMax, Qwen, OpenAI, Groq, Ollama, etc.) or enter a custom endpoint URL.

Security & Privacy First

Your API key is sent directly from your browser to your specified provider. BitsNotes servers never store or see your key.