Session 6 · Computer Systems

Text processing and a key–value store

A practice block for the practical test: anchored regular expressions, glob tests, string slicing, tr and sed, arrays – and associative arrays for counting per key, with a worked example on messy data.

19 min readAnchored regex with =~Regex vs globString slicing and cuttingsed with a temporary filedeclare -A countingComposite keys

These two sessions are practice. The tasks prepare you for the practical test, and almost all of them use the same handful of tools again and again: the line-reading loop from last session, regular expressions, string operations and arrays. One tool is new – the associative array, a key–value store, which turns "count how often each thing occurs in this file" into a few lines.

The way to use this page: read the toolkit sections once, then try each task yourself before you open its hint. The hint shows the approach and the lines that matter; the rest of the script is yours to write.

The pattern behind every task: read the file safely, clean each line, decide with a pattern, then print, count or store. Only the middle two steps change from task to task.

The toolkit: one loop, split inside

Every file-reading solution starts from the universal pattern of the previous session. Paste it and rewrite only the processing part:

while IFS= read -r line || [ -n "$line" ]; do
    line=${line%$'\r'}             # drop a Windows \r
    if [ -z "$line" ]; then         # skip empty lines
        continue
    fi

    # --- process the line ---
done < "$file"

This time we don't split fields in the while header with IFS=',' read. Instead we split the line inside the loop with a here string. The loop header then stays identical in every script, and the cleaning (\r, empty lines) happens once, before the split.

IFS=',' read -r name date illness <<< "$line"   # split at the commas
read -r word1 word2 rest <<< "$line"            # split at spaces

read puts one field into each variable, and the rest of the line into the last one. That is why the swap-words task uses a third, unused rest variable: without it, a line with three words would leave two of them in word2.

$ read -r first second <<< "one two three four"; echo "[$second]"
[two three four]
$ read -r first second rest <<< "one two three four"; echo "[$second]"
[two]

Regular expressions in a script

[[ $line =~ pattern ]] is true when the line matches the extended regular expression; [[ ! $line =~ pattern ]] negates it. These are the elements the tasks need:

PatternMeaningUsed in
^ … $Start and end of the line: the whole line has to match, not just a part of itevery task
[0-9]+At least one digittasks 8–11
[0-9]{2}Exactly two digitstask 6
[0-9]{4}-[0-9]{2}-[0-9]{2}A date like YYYY-MM-DDtask 13
[+-]?Optional sign – ? means 0 or 1 of the previous itemtask 1
[0-9]*\.?[0-9]+Decimal number. \. is a literal dot; a bare . matches any charactertask 1
[[:alpha:]], [[:space:]]POSIX classes: any letter, any whitespace. Combined: [[:alpha:][:space:]]task 2

The anchors are the part beginners drop, and the result is a check that lets almost everything through (Fig. 1).

line[[ $line =~ [0-9]+ ]][[ $line =~ ^[0-9]+$ ]]23match: 23matchasd2match: 2 – wrongno match12.2match: 12 – wrongno matchx 33 ymatch: 33 – wrongno matchWithout ^ and $ one digit anywhere is enough. With them, the whole line has to be digits.
Fig. 1 · Without anchors, =~ asks "is there a match somewhere in the line?"

A decimal-number check, read piece by piece (Fig. 2):

^[+-]?[0-9]*\.?[0-9]+$^line start[+-]?optional sign[0-9]*0 or more digits\.?optional dot[0-9]+1 or more digits$line endmatches23-12.2+7.50.25no match33.3o12.asd21.2.3+\. is a dot; a bare .means any character
Fig. 2 · A regex is a sequence of small claims about the line, read left to right.
if [[ $line =~ ^[+-]?[0-9]*\.?[0-9]+$ ]]; then    # 23, -12.2, +7, .5 – yes
    echo "$line"                                  # 33.3o, 12., asd2 – no
fi

Regex or glob?

To decide whether a word is somewhere in the line you don't need a regex. With == and !=, [[ ]] uses glob patterns – the same wildcards as for file names, where * is any text:

if [[ $line == *"$word"* ]]; then                    # the line contains $word
if [[ $line != *alma* && $line != *körte* ]]; then  # contains neither

"$word" stays quoted inside the pattern, so special characters in it match literally.

The same with grep

Every loop-based filter has a one-line grep version. The loop wins when you need to do more per line (count, transform, write to two files); grep wins when filtering is all you do.

With a loopWith grep
[[ $line =~ re ]] + echogrep -E 're' file
[[ ! $line =~ re ]] + echogrep -v -E 're' file
[[ $line =~ re ]] + countergrep -c -E 're' file
[[ $line != *"$word"* ]] + echogrep -v "$word" file

String operations

Bash can cut strings without calling any external program. The ones these tasks use:

ExpressionMeaningExample (s="1234")
${#s}Length4
${s:i:1}The character at index i (from 0)${s:0:1} → 1, ${s:3:1} → 4
${s//a/b}Replace every a with b (one /: only the first)${s//1/X} → X234
${s%$'\r'}Remove a trailing \r
${key%%:*}The part before the first colon (cuts the longest :* from the end)10:headache → 10
${key#*:}The part after the first colon (cuts the shortest *: from the start)10:headache → headache

Walking the digits of a number combines the length with one-character slices (Fig. 3). Treat the number as a string: this is also how you reverse it without any extra command.

number="1234"i=01i=12i=23i=34forward: sum 1+2+3+4 = 10backward: builds "4321"${number:i:1}one character, starting at index i (from 0)${#number}the length: 4, so the last index is 3for (( i=${#number}-1; i>=0; i-- ))
Fig. 3 · Indexes run from 0 to ${#number} − 1; the direction of the loop decides the task.
for (( i=0; i<${#number}; i++ )); do
    digit=${number:i:1}
    sum=$(( sum + digit ))
done

tr works on whole character classes; sed on patterns:

CommandEffect
tr -d '[:space:]'Delete every space, tab and \r – for numbers
tr -d '[:punct:]'Delete punctuation (. , ! ? ; …)
tr '[:upper:]' '[:lower:]'Lowercase (reliable for unaccented letters)
sed 's/^[[:space:]]*//; s/[[:space:]]*$//'Trim only the ends; inner spaces stay (" Hanna Molnar " → Hanna Molnar)
sed "s/$old/$new/g"Replace in every line; g = every occurrence in the line, not just the first

sed walks the file by itself, so replacing a word needs no loop. It does need care with the output file:

sed "s/$old/$new/g" "$file" > "$file.tmp"
mv "$file.tmp" "$file"
sed "s/a/b/g" data.txt > data.txtshell truncates data.txtbefore sed even startssed reads data.txt0 bytes – nothing to dodata.txt stays emptythe content is lostsed "s/a/b/g" data.txt > data.txt.tmp && mv data.txt.tmp data.txtsed reads data.txtthe file is intactwrites data.txt.tmpthe changed copymv replaces the originalone rename at the end
Fig. 4 · The shell sets up > before the command runs – that is why the temporary file is not optional.

The same applies to a loop that rewrites its own input: done < "$file" > "$file.tmp", then mv.

Arrays

An array keeps all the lines (or words) in memory, which you need whenever the output order differs from the input order – reversing a file, for instance.

lines=()                          # empty array
lines+=("$line")                  # append
echo "${#lines[@]}"               # number of elements
echo "${lines[0]}"                # first element
read -r -a words <<< "$line"      # the words of a line into an array

for (( i=${#lines[@]}-1; i>=0; i-- )); do   # back to front
    echo "${lines[i]}"
done

The last index is ${#lines[@]}-1, because indexing starts at 0. Starting the backwards loop at ${#lines[@]} prints an empty element first.

Key–value store: associative arrays

An ordinary array is indexed by numbers. An associative array – called a map, dictionary or hash in other languages – is indexed by any string, and each key has one value. For counting it is ideal: the key is what you count, the value is how many.

indexed array – keys 0, 1, 2 …[0]apple[1]pear[2]appledeclare -A counts – keys are strings["apple"]3["pear"]2["plum"]1key → value (the count)words: apple pear apple plum pear apple+1 per word
Fig. 5 · Same idea, different index: numbers for an array, strings for declare -A.
declare -A counts                      # create it – the -A is required

counts["apple"]=3                      # set a value
counts["apple"]=$(( ${counts["apple"]:-0} + 1 ))   # increment; a missing key starts at 0

echo "${counts["apple"]}"              # one value:        4
echo "${!counts[@]}"                   # all KEYS (the !), in no particular order
echo "${#counts[@]}"                   # number of keys

${counts["apple"]:-0} reads "the value, or 0 if the key doesn't exist yet" – that is what makes the first increment work. Counting words then looks like this:

declare -A counts
for word in "${words[@]}"; do
    counts["$word"]=$(( ${counts["$word"]:-0} + 1 ))
done

for word in "${!counts[@]}"; do
    echo "$word: ${counts["$word"]}"
done

output – note the order

plum: 1
pear: 2
apple: 3

Finding the maximum means walking the keys and remembering the best one so far:

max_count=0
max_word=""
for word in "${!counts[@]}"; do
    if [ "${counts["$word"]}" -gt "$max_count" ]; then
        max_count=${counts["$word"]}
        max_word=$word
    fi
done

Composite keys

To count by two things at once – month and illness – glue them into one key with a separator, key="$month:$illness", and take it apart later with the two cutting expansions.

key="10:headache"10:headache${key%%:*} → 10cut the longest :* from the end${key#*:} → headachecut the shortest *: from the start
Fig. 6 · %% cuts from the end, # cuts from the start; the pattern says where.

Worked example: messy data

Task 13 below puts everything together, on a file that is dirty in every way a real export is: a header line, spaces around the commas, punctuation after the illness (headache., headache!), mixed case, an empty and a broken line, CRLF line endings and no final newline. The goal: for every month, the most common illness and how many patients had it. (The file itself is in Hungarian – fejfájás is headache – the figure uses English.)

raw lineAnn Kiss, 2023-10-13, Headache.\rsplit at commasname="Ann Kiss" date=" 2023-10-13" illness=" Headache.\r"clean the fieldsdate="2023-10-13" illness="headache"validate the date^[0-9]{4}-[0-9]{2}-[0-9]{2}$ – the header fails herecountkey="10:headache" counts[$key] → 3max per monthMonth 10: headache (3 patients)
Fig. 7 · Clean first, then count. Without cleaning, headache, headache. and Headache are three different keys.

The cleaning step is two lines – spaces and punctuation out, everything lowercase:

date=$(echo "$date" | tr -d '[:space:]')
illness=$(echo "$illness" | tr -d '[:space:][:punct:]' | tr '[:upper:]' '[:lower:]')

The solution has three phases: clean and count per month:illness key; find the maximum per month by walking the keys and keeping two more associative arrays, max_count[month] and max_illness[month]; and print sorted, because the key order is random.

Practice

For every task: check the arguments and that the file exists, error messages go to standard error, and the script stops with exit 1. Read files with the universal pattern. Tasks 5 and 12 rewrite their input file – try them on a copy (cp replace.txt test.txt).

Task 1

Lines that are numbers

Print the lines of a file that contain only a number. Think of real numbers too: sign and decimal point. Input: numbers_mixed.txt.

23
11
12.2
-12.2
Approach

The decimal pattern from Fig. 2. 33.3o and asd2 fail because of the anchors. -12.2 is the last line, without a newline – if it's missing, check your loop header.

if [[ $line =~ ^[+-]?[0-9]*\.?[0-9]+$ ]]; then
    echo "$line"
fi

Task 2

Count the lines made of letters

Count how many lines contain only letters (and spaces). Input: letters.txt.

There are 2 lines in the file that contain only letters.
Approach

One bracket expression with two classes; asd3asd fails because of the digit.

if [[ $line =~ ^[[:alpha:][:space:]]+$ ]]; then
    count=$(( count + 1 ))
fi
# one line: grep -c -E '^[[:alpha:][:space:]]+$' letters.txt

Task 3

Copy, leaving out a word

Copy a file to another file, leaving out every line that contains a given word. Arguments: source, destination, word. If the destination doesn't exist, create it (and say so).

Approach

A glob test, the output emptied before the loop. One-line version: grep -v "$word" "$source" > "$destination".

> "$destination"
while ...; do
    if [[ $line != *"$word"* ]]; then
        echo "$line" >> "$destination"
    fi
done < "$source"

Task 4

Exclude several words

Print the lines that contain neither alma (apple) nor körte (pear), nor the word given as an argument. Input: fruits.txt.

Approach

Three != conditions joined with &&.

if [[ $line != *alma* && $line != *körte* && $line != *"$word"* ]]; then
    echo "$line"
fi

Task 5

Replace a word in the file

Replace every occurrence of a word with another one, in the file itself. Arguments: file, old word, new word.

$ ./replace.sh test.txt alma barack
Word 'alma' replaced with 'barack' in 'test.txt'.
Approach

The only task that needs no loop: sed with a temporary file and mv (Fig. 4). Plain sed also replaces inside words (almafa becomes barackfa); with GNU sed, \b limits it to whole words:

sed "s/\b$old\b/$new/g"     # GNU sed: whole words only, "almafa" stays

Task 6

Reverse two-digit numbers

A file has two-digit numbers, one per line. Print each with its digits swapped.

12 → 21
21 → 12
56 → 65
90 → 09
Approach

Validate with ^[0-9]{2}$, then glue the two characters in the other order. Linux has rev for this, but Git Bash doesn't – slicing works everywhere.

if [[ $number =~ ^[0-9]{2}$ ]]; then
    echo "${number:1:1}${number:0:1}"
fi

Task 7

Swap two words

Every line of a file has two words. Swap them in every line.

asd1 asd2  →  asd2 asd1
egy ketto  →  ketto egy
Approach

Split with a here string; the third variable catches anything extra.

read -r word1 word2 rest <<< "$line"
echo "$word2 $word1"

Task 8

Sum the numbers in a file

A file has one number per line. Add them up; warn about lines that aren't numbers.

Warning: skipping invalid number: 'asd'
Sum of numbers in the file: 20
Approach

Validate, warn to standard error, continue; add up the rest.

if [[ ! $line =~ ^[0-9]+$ ]]; then
    echo "Warning: skipping invalid number: '$line'" >&2
    continue
fi
sum=$(( sum + line ))

Task 9

Copy the non-numeric lines

A file mixes numbers and text. Copy every line that is not purely a number to a new file.

Approach

The mirror image of task 1: the same kind of pattern with !. One line: grep -v -E '^[0-9]+$' in.txt > out.txt.

Task 10

Reverse a number

Read a number from the keyboard with read and print it reversed.

Enter a number: 1234
Reversed number: 4321
Approach

Handle it as a string: walk the characters from the back and append them to a new string. That also keeps leading zeros – 1200 becomes 0021. Validate the input first.

read -r -p "Enter a number: " number
for (( i=${#number}-1; i>=0; i-- )); do
    reversed="$reversed${number:i:1}"
done

Task 11

Digit sum

Read a number and print the sum of its digits. It should also work with piped input (echo 1234 | ./digit_sum.sh).

Enter a number: Sum of digits: 10
Approach

The forward loop of Fig. 3. Check the input is all digits, or the arithmetic fails on a letter.

Task 12

Reverse the lines of a file

Reverse a file in place – the last line becomes the first. Use an array as temporary storage.

Approach

Read every line into an array, build the reversed array (or print backwards), and only write the file when all lines have been read.

printf '%s\n' "${reversed[@]}" > "$file"   # only after ALL lines are read

Task 13

Most common illness per month

Each line of patients_messy.txt is Name, YYYY-MM-DD, illness – with every kind of dirt described in the worked example. For every month, print the most common illness and the number of patients. Warn about invalid lines.

Warning: invalid line skipped: 'Név, Dátum, Betegség'
Warning: invalid line skipped: 'Hibás Sor'
Month 05: lábfájás (3 patient(s))
Month 09: gyomorpanaszok (2 patient(s))
Month 10: fejfájás (3 patient(s))
Month 11: megfázás (3 patient(s))
Approach

Follow Fig. 7. The month is ${date:5:2}. The header and the broken line fail the date check, so they need no special case.

key="$month:$illness"
counts["$key"]=$(( ${counts["$key"]:-0} + 1 ))
...
for key in "${!counts[@]}"; do
    month=${key%%:*}
    illness=${key#*:}
    if [ "${counts["$key"]}" -gt "${max_count["$month"]:-0}" ]; then
        max_count["$month"]=${counts["$key"]}
        max_illness["$month"]=$illness
    fi
done

More practice

The inputs contain the usual traps – CRLF, no final newline, empty lines, extra spaces. Start every solution from the universal pattern.

Task 14

Palindromes

Print the lines of palindromes.txt that read the same backwards, ignoring spaces and case (Never odd or even). Expected: 6 lines, the last one without a newline in the file.

Approach

Normalise first, then reuse the reversing loop of task 10.

clean=$(echo "$line" | tr -d ' ' | tr '[:upper:]' '[:lower:]')
# reverse $clean into $reversed (task 10), then:
[ "$clean" == "$reversed" ] && echo "$line"

Task 15

Reverse the words in every line

Task 7 for any number of words: in every line of word_lines.txt, reverse the word order. Expected first lines: harom ketto egy, korte alma, e d c b a.

Approach

Split the line into an array with read -r -a, then walk it backwards.

read -r -a words <<< "$line"
out=""
for (( i=${#words[@]}-1; i>=0; i-- )); do
    out="$out${words[i]} "
done
echo "${out% }"                     # drop the trailing space

Task 16

Censor banned words

banned_words.txt has one banned word per line (with Windows line endings). Replace every banned word in story.txt with ***, write the result to censored.txt, and print how many lines changed. Expected: 3.

Approach

Read the banned words into an array – stripping the \r, or not a single replacement happens. Then replace each word in each line, and compare with the original line.

for word in "${banned[@]}"; do
    line=${line//"$word"/***}
done
[ "$line" != "$original" ] && changed=$(( changed + 1 ))

Task 17

Digit sums from a file

numbers_digits.txt has one number per line, with spaces, invalid and empty lines. Write number: digit sum for each valid number to digit_sums.txt, then the number with the largest digit sum. Warn about invalid lines with their line number. Expected: 6 valid numbers, 2 invalid lines, Max: 99 (18).

Approach

Clean, validate, then the digit loop of task 11 plus a running maximum.

number=$(echo "$line" | tr -d '[:space:]')
[[ $number =~ ^[0-9]+$ ]] || { echo "Warning: line $n: '$number'" >&2; continue; }

Task 18

Longest line, longest word

Print the longest line and the longest word of text.txt, with their lengths. Expected: a 59-character line and a 22-letter word.

Approach

Two running maximums: one over lines, one over the words of each line (read -r -a).

if [ "${#line}" -gt "$max_len" ]; then
    max_len=${#line}
    longest=$line
fi

Task 19

Filter with options

Filter a file (first argument) by an option: -n numeric lines only, -t text lines only, -w <word> lines containing the word. Unknown option: usage text and exit 1. Expected on numbers_and_text.txt: -n → 2 lines, -t → 5, -w asd → 4.

Approach

Wrap tasks 1, 9 and 3 into functions and dispatch with case.

case "$2" in
    -n) numeric_lines "$file" ;;
    -t) text_lines "$file" ;;
    -w) lines_with_word "$file" "$3" ;;
    *)  usage; exit 1 ;;
esac

Task 20

The most frequent patient

Lines of visits.txt are Name, visits, last date; a name can appear in several lines, and its visits add up. Print who visited most, and how many times. Watch the spaces and the CRLF endings. Expected: Kiss Lajos - 12.

Approach

Sum per name in an associative array, then find the maximum. Trim only the ends of the name – the space inside it belongs to it.

IFS=',' read -r name count date <<< "$line"
name=$(echo "$name" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')   # trim the ends only
count=$(echo "$count" | tr -d '[:space:]')
visits["$name"]=$(( ${visits["$name"]:-0} + count ))

Common mistakes

WrongRightWhy
[[ $line =~ [0-9]+ ]][[ $line =~ ^[0-9]+$ ]]Without anchors asd2 matches too.
[[ $line =~ "^[0-9]+$" ]][[ $line =~ ^[0-9]+$ ]]Quoted, the pattern is literal text.
[[ $line == "$word" ]] for "contains"[[ $line == *"$word"* ]]Without * only an identical line matches.
regex on a Windows filefirst line=${line%$'\r'}The \r stops $ from matching.
sed … "$file" > "$file"temporary file, then mv> empties the file before sed starts.
for (( i=${#a[@]}; i>=0; i-- ))for (( i=${#a[@]}-1; i>=0; i-- ))The last index is length − 1.
counting by string key without declare -Adeclare -A counts firstOtherwise it is a numeric array and every string key lands on index 0.
for key in ${!counts[@]}for key in "${!counts[@]}"Unquoted, Lajos Kiss splits into two keys.
echo "$x" | rev${x:i:1} in a looprev isn't available everywhere (Git Bash).
anchor
^ and $ – the start and end of the line in a regex.
glob
A wildcard pattern (*, ?, […]), as in file names; used by == inside [[ ]].
here string
<<< "$line" – feeds one string to a command such as read.
associative array
An array indexed by strings, created with declare -A.
composite key
Two values glued into one key, e.g. 10:headache.

Summary

  • Anchor every validating regex: ^…$. Never quote it inside [[ =~ ]].
  • "Contains a word" is a glob: [[ $line == *"$word"* ]].
  • Strings are arrays of characters: ${#s} and ${s:i:1} cover reversing and digit sums.
  • Never redirect into the file you are reading – temporary file, then mv.
  • Count with declare -A: clean the key first, ${counts[$k]:-0} + 1, walk "${!counts[@]}" for the maximum, sort for ordered output.