﻿# PowerShell scripts: parameters, error handling and text files

> The frame around every useful script: the skeleton that takes parameters, checks them and reads a file; typed parameters versus a string checked with a regular expression; $args and array parameters; try/catch; the lines of a file as an array; and rewriting text with -replace. Thirteen tasks to practise on.

Source: https://laszlonemes.com/university/computer-systems/powershell-practice · Author: László Nemes

Session 9 · Computer Systems

The frame around every useful script: the skeleton that takes parameters, checks them and reads a file; typed parameters versus a string checked with a regular expression; $args and array parameters; try/catch; the lines of a file as an array; and rewriting text with -replace. Thirteen tasks to practise on.

20 min read `The script skeleton` `Typed parameter or string + regex` `$args and array parameters` `try / catch and -ErrorAction Stop` `@(Get-Content …) as an array` `-replace, \b and -like`

A useful script is mostly not about its task. It takes parameters and refuses the wrong ones, it finds out whether the file it was given exists, it survives the thing that goes wrong half-way – and somewhere in the middle are the few lines that do the actual work. This session is about that frame: how a PowerShell script receives and checks its input, how it reports and handles errors, and how it works with the lines of a text file.

The language itself – variables, operators, loops, functions, `Get-Content` – is covered in [PowerShell: objects, cmdlets and scripts](https://laszlonemes.com/university/computer-systems/powershell-basics). Here those pieces are put to work, and thirteen tasks at the end let you practise each of them.

After this session you can

- write the opening of a script from memory: parameters, usage message, file check, the reading loop
- choose between a typed parameter and a `[string]` checked with a regular expression – and say what each gives you
- take any number of parameters with `$args` or with an array parameter
- catch an error with `try`/`catch`, and make a cmdlet's error catchable
- treat the lines of a file as an array: index it, walk it backwards, split a line into words
- rewrite text with `-replace` and a regular expression, and test "contains" with `-like`

> **Every script is the same script with a different middle.** Take the parameters, refuse bad input with `Write-Error` and `exit 1`, read the file with one well-known loop – and then write the three to ten lines that are actually about the task.

## The skeleton of every script

A script that processes a file has three parts before it does anything interesting: it takes its parameters, it checks them, and it reads the file line by line. Written out once, this is the opening of almost every script below – worth knowing by heart.

```powershell
param(
    [string]$File
)

if (-not $File) {
    Write-Error "Usage: $($MyInvocation.MyCommand.Name) <file>"
    exit 1
}

if (-not (Test-Path -Path $File -PathType Leaf)) {
    Write-Error "File '$File' does not exist"
    exit 1
}

foreach ($line in Get-Content -Path $File -Encoding UTF8) {
    $line = $line.Trim()
    if ($line -eq '') {
        continue
    }
    Write-Output $line
}
```

| Part | What it takes care of |
| --- | --- |
| `param( [string]$File )` | The parameter of the script, with a name and a type. It must be the first statement of the script. |
| `if (-not $File)` | The parameter is missing (empty). With several parameters: `$PSBoundParameters.Count -ne 2`. |
| `Write-Error` + `exit 1` | The message goes to the error stream, not into the results, and the script ends with a non-zero exit code. |
| `Test-Path -PathType Leaf` | It exists and it is a file. For a directory: `-PathType Container`. |
| `Get-Content … -Encoding UTF8` | The lines of the file. CRLF endings and a missing final newline are handled for you; `-Encoding UTF8` is there for accented text in Windows PowerShell 5.1. |
| `.Trim()` + skipping empty lines | Stray spaces, tabs and empty lines are the two things `Get-Content` doesn't clean up for you. |

In your assignment

**The first minute of every script**

Type the skeleton first and run it once with no parameter and once with a file name that doesn't exist. Only when both give your error message do you start on the task itself. It costs a minute, and wrong parameters and a missing file are exactly what a checker tries first.

## Checking parameters: two ways

A script that expects a number can make PowerShell do the checking, or do it itself (Fig. 1).

> **Fig. 1** · A typed parameter is checked by PowerShell before your first line runs; a string parameter is checked by you.
>
> *Diagram:* Two ways to reject the parameter abc. With a typed parameter, [int]$Number, PowerShell tries to convert the text before the script starts and prints its own error message. With a [string] parameter the script starts, a regular expression checks the value, and the error message is the one the script writes.

|  | Typed parameter | String + regular expression |
| --- | --- | --- |
| Declaration | `param( [int]$Number )` | `param( [string]$Number )` |
| Who checks? | PowerShell, before the script starts | you: `if ($Number -notmatch '^[0-9]+$')` |
| Error message | PowerShell's: *Cannot convert value "abc" to type "System.Int32"* | your own |
| Was it given? | `$PSBoundParameters.ContainsKey('Number')` – an `[int]` defaults to 0! | `-not $Number` |

typed

```powershell
param(
    [int]$Number
)

if (-not $PSBoundParameters.ContainsKey('Number')) {     # was it given at all? 0 is a valid value
    Write-Error "Usage: $($MyInvocation.MyCommand.Name) <integer>"
    exit 1
}
```

```text
PS> ./square.ps1 abc
square.ps1: Cannot process argument transformation on parameter 'Number'. Cannot convert value "abc" to type "System.Int32". Error: "The input string 'abc' was not in a correct format."
```

string + regex

```powershell
param(
    [string]$Number
)

if (-not $Number) {
    Write-Error "Usage: $($MyInvocation.MyCommand.Name) <integer>"
    exit 1
}

if ($Number -notmatch '^-?[0-9]+$') {
    Write-Error "'$Number' is not an integer"
    exit 1
}

[int]$n = $Number            # from here on it is a number
```

```text
PS> ./square.ps1 abc
Write-Error: 'abc' is not an integer
```

Both are good solutions. The first is shorter; with the second the message is yours, and you decide exactly what counts as valid – `'^[0-9]+$'` refuses `-5`, which an `[int]` would happily accept.

Common mistake

**if ($Number) doesn't tell you whether an [int] was given**

An `[int]` parameter that wasn't passed is `0`, and `0` is false – but `0` can also be a perfectly valid value the user typed. Ask `$PSBoundParameters` instead: it holds exactly the parameters that were passed. `.ContainsKey('Number')` for one, `.Count` for how many.

Common mistake

**Don't name a parameter Count**

With `param([int]$Count)`, the expression `$PSBoundParameters.Count` no longer means "how many parameters": it returns the *value* of the parameter called `Count`. Pick another name – `$Times`, `$Length`.

## Any number of parameters

Some scripts take a list: numbers to add up, files to delete. There are two ways to receive "however many there are". The simple one: leave out the `param()` block, and every parameter lands in the `$args` array.

```powershell
# no param() block: every parameter lands in the $args array
if ($args.Count -eq 0) {
    Write-Error "Usage: $($MyInvocation.MyCommand.Name) <number> [number ...]"
    exit 1
}

foreach ($arg in $args) {
    Write-Output "Parameter: $arg"
}
```

The other is a typed **array parameter**, which PowerShell validates like any typed parameter. Its elements are separated by **commas** on the command line:

```powershell
param(
    [double[]]$Numbers
)

# ./sum.ps1 1,2,3,4,5              $Numbers has five elements
# ./sum.ps1 -Numbers 1,2,3,4,5     the same, by name
# ./sum.ps1 1 2 3 4 5              $Numbers has ONE element; 2 3 4 5 end up in $args
```

> **Fig. 2** · The same three numbers, three outcomes. The last row is the classic mistake: spaces where commas were needed.
>
> *Diagram:* Where the values 1, 2 and 3 land. Without a param block, ./sum.ps1 1 2 3 puts all three into $args. With an array parameter [int[]]$Numbers, the comma form 1,2,3 puts all three into $Numbers, but the space form 1 2 3 puts only 1 into $Numbers and leaves 2 and 3 in $args.

Good to know

**Values in $args already have a type**

PowerShell parses unquoted parameters: in `./sum.ps1 1 2.5 abc` the first element of `$args` is an integer, the second a decimal, the third a string. To validate them with a regular expression, turn each into text first: `"$arg" -notmatch …`.

## Numbers and yes/no functions

PowerShell has several number types, and a toolbox – `[math]` – for roots, rounding and absolute values:

```powershell
10 / 4                      # 2.5 – division keeps the fraction
[math]::Truncate(10 / 4)    # 2   – drop the fraction
[math]::Round(2.123456, 4)  # 2.1235
[math]::Sqrt(16)            # 4
[math]::Abs(-5)             # 5
7 % 2                       # 1   – the remainder

"1" + "2"                   # 12  – two strings are glued together
[double]"1" + [double]"2"   # 3
```

| Type | Holds | Use it for |
| --- | --- | --- |
| `[int]` | whole numbers up to about ±2 billion | counters, indexes, most parameters |
| `[long]` | whole numbers up to about ±9 · 10¹⁸ | file sizes in bytes |
| `[double]` | decimal numbers | anything with a fraction; what `/` returns |
| `[bigint]` | whole numbers of any size | factorials and other results that outgrow 64 bits |

An ordinary integer has a largest value. When a calculation outgrows it, PowerShell quietly switches to a decimal number, which keeps the magnitude and **loses the last digits**. When every digit matters – a factorial, say – ask for `[bigint]`:

```powershell
$result = 1
for ($i = 2; $i -le 25; $i++) { $result = $result * $i }
$result                     # 1.5511210043331E+25 – silently became a decimal, digits lost

[bigint]$result = 1
for ($i = 2; $i -le 25; $i++) { $result = $result * $i }
$result                     # 15511210043330985984000000 – every digit
```

### A function that returns true or false

A check you need more than once – is it prime, is it even, is the code valid – belongs in a function that returns `$true` or `$false`. By convention its name starts with `Test-`. It then goes directly into an `if`, or into `Where-Object` as a filter:

```powershell
function Test-Prime {
    param([int]$N)

    if ($N -lt 2) {
        return $false
    }
    for ($i = 2; $i * $i -le $N; $i++) {     # divisors up to the square root are enough
        if ($N % $i -eq 0) {
            return $false
        }
    }
    return $true
}

if (Test-Prime 97) { Write-Output "97 is a prime number" }
10..50 | Where-Object { Test-Prime $_ }      # the same function as a filter in a pipeline
```

## Error handling: try and catch

Some failures can't be ruled out in advance. For those there is `try`/`catch`: an error inside the `try` block doesn't stop the script – execution jumps to the `catch` block, where the error is in the variable `$_`:

```powershell
try {
    $result = $a / $b                             # integers: dividing by zero is an error
    Remove-Item -Path $file -ErrorAction Stop     # a cmdlet: catchable only with -ErrorAction Stop
} catch {
    Write-Error "Error: $($_.Exception.Message)"  # $_ is the error that was caught
    exit 1
}
```

> **Fig. 3** · Two kinds of error. Only the terminating kind reaches `catch` – and `-ErrorAction Stop` makes a cmdlet's error that kind.
>
> *Diagram:* Inside a try block, a terminating error such as an integer division by zero jumps to the catch block, where the error is in $_. A cmdlet error, such as Remove-Item on a missing file, is non-terminating by default: its message is printed, the script goes on and catch never runs. Adding -ErrorAction Stop turns it into a terminating error that catch does receive.

Common mistake

**Without -ErrorAction Stop, catch never sees a cmdlet's error**

The errors of cmdlets – `Remove-Item`, `Get-Content`, `Copy-Item` … – are only *reported* by default: the message appears, and the script continues with the next line (Fig. 3).

```powershell
try {
    Remove-Item -Path nope.tmp           # no -ErrorAction Stop
} catch {
    Write-Output "caught"                # never runs
}
Write-Output "still running"             # runs – after the red error message of Remove-Item
```

Good to know

**Dividing by zero is only an error for integers**

A division by zero is caught when both numbers are `[int]`. With decimals there is nothing to catch – the result is infinity – so a script that calculates with `[double]` has to test the divisor itself.

```powershell
10 / 0             # ERROR: Attempted to divide by zero.   (integers – catch sees it)
[double]10 / 0     # ∞ – no error at all                   (decimals – check $b -eq 0 yourself)
```

When should you use which? **Check first when you can** – `Test-Path` before opening a file, a regular expression before converting – because a check gives the clearest message. Use `try`/`catch` for what can't be known in advance: a file that exists but may not be deleted, a disk that is full, a division whose divisor arrives at run time.

## The lines of a file as an array

Reading a file line by line is one way to process it. The other is to take all the lines at once: what `Get-Content` returns is an array, and an array can be counted, indexed and walked in any direction.

```powershell
$lines = @(Get-Content -Path $File -Encoding UTF8)    # @( ): an array even for a one-line file

$lines.Count                 # the number of lines
$lines[0]                    # the first line
$lines[-1]                   # the last line
$lines[$lines.Count - 1]     # the last line again, written out

for ($i = $lines.Count - 1; $i -ge 0; $i--) {         # from the back to the front
    Write-Output $lines[$i]
}
```

> **Fig. 4** · The reason for `@( )`: a one-line file comes back as a string, and a string can be indexed too.
>
> *Diagram:* For a file with a single line, Get-Content returns one string, not an array, so index 0 is the first character of the line. Wrapping the call in @( ) always gives an array, and index 0 is the whole first line.

The `@( )` is not decoration. With a file of a single line, `Get-Content` returns one string, and indexing a string gives a character (Fig. 4). A script that works on the five-line sample and prints one letter on a one-line file is a classic bug. A `foreach` loop over `Get-Content` is not affected – only indexing and `.Count`.

### Collect, then write once

When the result goes into a file, collect the lines in an array and write the array at the end – one element per line, a newline after the last. Nothing has to be emptied before the loop, and a second run gives the same file. With two outputs, use two arrays:

```powershell
$oddLines = @()
$evenLines = @()
…
    $oddLines += $line                 # inside the loop: collect, don't write
…
Set-Content -Path odd_lines.txt -Value $oddLines -Encoding UTF8      # after the loop: write once
```

### Splitting a line into words

`-split '\s+'` cuts a line at every run of whitespace and returns the words as an array. Trim the line first, or a leading space produces an empty first element.

```powershell
$words = $line -split '\s+'           # "asd1   asd2" -> two words, whatever the gap
$words.Count                          # 2
$word1, $word2 = $line -split '\s+'   # or straight into two variables

"a  b" -split ' '                     # WRONG: three elements – "a", "" and "b"
```

## Replacing and matching text

`-replace` rewrites text: the pattern is a **regular expression**, **every** occurrence is replaced, and – like every PowerShell text operator – it ignores case. (It does what `sed 's/pattern/replacement/g'` does in a Unix shell.)

```powershell
"gipsz kukac jakab" -replace 'kukac', '@'        # gipsz @ jakab     every occurrence
"gipsz KUKAC jakab" -replace 'kukac', '@'        # gipsz @ jakab     case is ignored
"gipsz kukac jakab" -replace '\s*kukac\s*', '@'  # gipsz@jakab       the spaces are part of the match
"a1b22c333" -replace '[0-9]+', ''                # abc               replacing with nothing deletes
```

A worked example. [emails_encoded.txt](https://laszlonemes.com/files/computer-systems/powershell-practice/emails_encoded.txt) holds e-mail addresses written so that address harvesters don't recognise them, in several spellings. (*kukac* is the Hungarian name of the @ sign, *pont* means dot.)

emails_encoded.txt

```text
gipsz kukac jakab pont hu
gipsz(kukac)jakab(pont)hu
gipsz [at] jakab [dot] hu

gipsz KUKAC jakab PONT hu
teszt.elek kukac inf pont elte pont hu
  kovacs.anna at example dot com
```

One pattern has to cover `kukac`, `(kukac)`, `[at]` and `KUKAC`, with or without spaces around them. Build it from the inside out (Fig. 5): the word itself, a word boundary on each side, an optional bracket on each side, and any spaces on each side – all of which should vanish together.

> **Fig. 5** · Seven pieces, read left to right. Whatever the pattern matches – brackets and spaces included – becomes a single `@`.
>
> *Diagram:* The pattern that finds an encoded at sign, read left to right: any spaces, an optional opening parenthesis or bracket, a word boundary, the word kukac or at, a word boundary, an optional closing parenthesis or bracket, any spaces. Everything it matches is replaced by @, so gipsz kukac jakab, gipsz(kukac)jakab and gipsz [at] jakab all become gipsz@jakab.

```powershell
$email = $line -replace '\s*[\(\[]?\b(kukac|at)\b[\)\]]?\s*', '@'
$email = $email -replace '\s*[\(\[]?\b(pont|dot)\b[\)\]]?\s*', '.'
```

The `\b` looks optional and isn't. It marks a **word boundary**: the place between a letter and a non-letter. Without it, `at` also matches in the middle of a word:

```powershell
"kata kukac x" -replace '\s*(kukac|at)\s*', '@'       # k@a@x    – the "at" inside "kata" matched too
"kata kukac x" -replace '\s*\b(kukac|at)\b\s*', '@'   # kata@x   – \b: only as a whole word
```

### Contains: -like

For "does the line contain this text" no regular expression is needed. `-like` compares with a wildcard pattern, and the stars on both sides do the "anywhere":

```powershell
"ASD32B" -like "*as*"        # True  – a wildcard pattern; case is ignored
"ASD32B" -clike "*as*"       # False – the c prefix makes it case-sensitive
"ASD32B" -like "as"          # False – without the stars the whole string must be "as"

if ($line -like "*$Text*") { … }     # contains the text of a variable: double quotes
```

## Practice

Thirteen tasks in three groups. Solve each one yourself first and open the hint only when you have something that runs. Work inside `pwsh`, in the folder where you put the input files.

Downloads

**Input files for the text-file tasks**

Small on purpose, and with the usual traps: `lines.txt` has no newline after its last line, `emails_encoded.txt` and `neptun_codes.txt` have Windows (CRLF) line endings, an empty line and stray spaces.

[lines.txt](https://laszlonemes.com/files/computer-systems/powershell-practice/lines.txt) · [emails_encoded.txt](https://laszlonemes.com/files/computer-systems/powershell-practice/emails_encoded.txt) · [word_pairs.txt](https://laszlonemes.com/files/computer-systems/powershell-practice/word_pairs.txt) · [neptun_codes.txt](https://laszlonemes.com/files/computer-systems/powershell-practice/neptun_codes.txt)

Or all at once, from the terminal – the files land in the current directory:

Linux, macOS, the course server

```shell
curl -O "https://laszlonemes.com/files/computer-systems/powershell-practice/{lines.txt,emails_encoded.txt,word_pairs.txt,neptun_codes.txt}"
```

Windows (PowerShell, cmd)

```powershell
curl.exe -O "https://laszlonemes.com/files/computer-systems/powershell-practice/{lines.txt,emails_encoded.txt,word_pairs.txt,neptun_codes.txt}"
```

Look at the data files freely, but don't save them from an editor: their line endings and last bytes are part of the exercise, and an editor may "fix" them.

For every task:

- the script checks its parameters, and the file if it reads one
- error messages go through `Write-Error`, and the script stops with `exit 1`
- files are read with the skeleton at the top of this page

### Numbers and functions

Task 1

**Factorial**

Compute the factorial of the number given as a parameter. A function does the calculation. The result must be exact for large numbers too.

```text
PS> ./factorial.ps1 5
Factorial of 5 is 120
PS> ./factorial.ps1 -Number 25
Factorial of 25 is 15511210043330985984000000
```

**Approach:**

A `[string]` parameter checked with `'^[0-9]+$'`, then a function with a `for` loop. Without a type the result turns into an inexact decimal on the way – 25! comes out as `1.5511210043331E+25`; with `[bigint]` every digit is there.

```powershell
function Get-Factorial {
    param([int]$N)

    [bigint]$result = 1               # 0! and 1! are right without a special case
    for ($i = 2; $i -le $N; $i++) {
        $result = $result * $i
    }
    return $result
}

$factorial = Get-Factorial $Number    # called like a command: no parentheses
```

Task 2

**The largest parameter**

Print the largest of the script's parameters. It can get any number of them; a parameter that isn't a number is an error.

```text
PS> ./max_parameters.ps1 3 17 5
Largest of the 3 parameter(s): 17
PS> ./max_parameters.ps1 -2.5 -7 -3
Largest of the 3 parameter(s): -2.5
PS> ./max_parameters.ps1 4 x
Write-Error: 'x' is not a number
```

**Approach:**

No `param()` block on purpose, so everything lands in `$args`. Validate each element as text, convert it to `[double]`, and keep the largest so far – starting from the first element, not from 0, or a list of negative numbers gives the wrong answer.

```powershell
$first = $true
foreach ($arg in $args) {
    if ("$arg" -notmatch '^[+-]?[0-9]*\.?[0-9]+$') {
        Write-Error "'$arg' is not a number"
        exit 1
    }
    [double]$number = "$arg"

    if ($first -or $number -gt $largest) {
        $largest = $number
        $first = $false
    }
}
```

**With an array parameter:**

Declare `param( [double[]]$Numbers )` and call the script with commas: `./max_parameters.ps1 -Numbers 3,17,5`. PowerShell then does the validation, and `($Numbers | Measure-Object -Maximum).Maximum` the search. Write this version too; Fig. 2 shows what happens if you call it with spaces.

Task 3

**Quadratic equation**

Solve `ax² + bx + c = 0`. Read the three coefficients from the keyboard.

```text
a: 1
b: -3
c: 2
Two real roots: x1 = 2, x2 = 1
```

**Approach:**

Three `Read-Host` calls, each converted to `[double]` (validate the text first if you want your own error message). Refuse `a = 0`. The sign of the discriminant decides between two roots, one root and no real root; `[math]::Round(…, 4)` keeps the output tidy.

```powershell
[double]$a = Read-Host "a"            # Read-Host returns text – convert before you calculate

$discriminant = $b * $b - 4 * $a * $c

if ($discriminant -gt 0) {
    $x1 = (-$b + [math]::Sqrt($discriminant)) / (2 * $a)
    …
    Write-Output "Two real roots: x1 = $([math]::Round($x1, 4)), x2 = $([math]::Round($x2, 4))"
} elseif ($discriminant -eq 0) { … } else { … }
```

Try the other two cases as well: `1, 2, 1` has one root, `-1`; `1, 0, 4` has no real root.

Task 4

**Is it prime?**

Decide whether an integer given as a parameter is a prime number.

```text
PS> ./is_prime.ps1 97
97 is a prime number
PS> ./is_prime.ps1 91
91 is not a prime number
```

**Approach:**

The `Test-Prime` function from the section on numbers, called in an `if`. Divisors up to the square root are enough – the loop condition `$i * $i -le $N` says that without a square root. 91 looks prime and isn't: 7 · 13.

Task 5

**Greatest common divisor**

Find the greatest common divisor of two integers.

```text
PS> ./gcd.ps1 24 36
The greatest common divisor of 24 and 36 is 12
PS> ./gcd.ps1 -First 17 -Second 5
The greatest common divisor of 17 and 5 is 1
```

**Approach:**

This time the parameters are typed, so PowerShell validates them – try `./gcd.ps1 abc 4`. Count the passed parameters with `$PSBoundParameters.Count`. The calculation is Euclid's algorithm; take absolute values first so negative input works too.

```powershell
param(
    [int]$First,
    [int]$Second
)

if ($PSBoundParameters.Count -ne 2) { … }      # [int] defaults to 0, so -not $First can't tell

while ($B -ne 0) {                             # Euclid: replace the pair by (B, A mod B)
    $remainder = $A % $B
    $A = $B
    $B = $remainder
}
```

Task 6

**Primes in an interval**

Print the prime numbers of an interval given by two parameters, and how many there are. The prime test is a function.

```text
PS> ./primes_in_interval.ps1 10 50
Prime numbers between 10 and 50: 11 13 17 19 23 29 31 37 41 43 47
Count: 11
```

**Approach:**

Reuse `Test-Prime`, collect the hits in an array, and print them on one line with `-join`. Refuse an interval whose start is greater than its end. Note the braces in `${To}:` – a colon follows the variable name.

```powershell
$primes = @()
for ($n = $From; $n -le $To; $n++) {
    if (Test-Prime $n) {
        $primes += $n
    }
}

Write-Output "Prime numbers between $From and ${To}: $($primes -join ' ')"
```

As a pipeline it is one line: `$From..$To | Where-Object { Test-Prime $_ }`.

### Text files

Task 7

**Odd and even lines**

Copy the odd-numbered lines of a file into `odd_lines.txt` and the even-numbered ones into `even_lines.txt`. Check that the file exists. Input: [lines.txt](https://laszlonemes.com/files/computer-systems/powershell-practice/lines.txt) – five lines, the last without a newline.

```text
PS> ./odd_even_lines.ps1 lines.txt
Odd lines:  odd_lines.txt (3 line(s))
Even lines: even_lines.txt (2 line(s))
PS> Get-Content odd_lines.txt
asd
asd3
asd5
```

**Approach:**

Two arrays and your own line counter, written out after the loop. The last line arrives although no newline follows it, and since the arrays are written in one go, the output files need no emptying first. No `.Trim()` here: the task is to copy the lines as they are.

```powershell
$lineNumber = 1
foreach ($line in Get-Content -Path $File -Encoding UTF8) {
    if ($lineNumber % 2 -eq 1) {
        $oddLines += $line
    } else {
        $evenLines += $line
    }
    $lineNumber++
}
```

Task 8

**Encoded e-mail addresses**

Turn every encoded address in a file into a real one, print them, and write them to a second file as well. Both file names are parameters. Input: [emails_encoded.txt](https://laszlonemes.com/files/computer-systems/powershell-practice/emails_encoded.txt).

```text
PS> ./decode_emails.ps1 emails_encoded.txt emails_decoded.txt
gipsz@jakab.hu
gipsz@jakab.hu
gipsz@jakab.hu
gipsz@jakab.hu
teszt.elek@inf.elte.hu
kovacs.anna@example.com
6 address(es) written to 'emails_decoded.txt'
```

**Approach:**

The skeleton, then the two `-replace` lines of Fig. 5 – one for the @, one for the dots. The file has an empty line and a line wrapped in spaces; `.Trim()` and the empty-line check deal with both before the pattern sees the line.

```powershell
$decoded = @()
…
    $email = …                 # the two -replace lines above
    Write-Output $email        # to the terminal …
    $decoded += $email         # … and remembered for the file
…
Set-Content -Path $OutFile -Value $decoded -Encoding UTF8
```

Task 9

**The last lines of a file**

Print the last *n* lines of a file, each with its line number. The file is the first parameter, *n* the second, with 3 as its default. If the file has fewer lines, print them all. Input: [lines.txt](https://laszlonemes.com/files/computer-systems/powershell-practice/lines.txt).

```text
PS> ./last_lines.ps1 lines.txt 2
4: asd4
5: asd5
PS> ./last_lines.ps1 lines.txt
3: asd3
4: asd4
5: asd5
```

**Approach:**

`@(Get-Content …)` gives the array, `.Count` its length. Compute the index to start from and don't let it go below 0; the line number is the index plus one.

```powershell
param(
    [string]$File,
    [int]$Lines = 3
)
…
$all = @(Get-Content -Path $File -Encoding UTF8)

$start = $all.Count - $Lines
if ($start -lt 0) {
    $start = 0
}

for ($i = $start; $i -lt $all.Count; $i++) {
    Write-Output "$($i + 1): $($all[$i])"
}
```

Task 10

**Swap two words**

Every line of a file has two words. Print each line with its words swapped. Input: [word_pairs.txt](https://laszlonemes.com/files/computer-systems/powershell-practice/word_pairs.txt).

```text
PS> ./swap_words.ps1 word_pairs.txt
asd2 asd1
asd4 asd3
ketto egy
```

**Approach:**

Split at whitespace and print the two elements in the other order. A line that doesn't have exactly two words gets a warning and is skipped – one odd line shouldn't stop the script.

```powershell
$words = $line -split '\s+'

if ($words.Count -ne 2) {
    Write-Warning "Line skipped (not exactly two words): '$line'"
    continue
}

Write-Output "$($words[1]) $($words[0])"
```

The last task of this group works on Neptun codes – the six-character student identifiers. This is what `cat -et` shows of the input: CRLF endings, an empty line, a code wrapped in spaces, two invalid codes, and no newline at the end.

cat -et neptun_codes.txt

```text
RVR55V^M$
ASD32B^M$
abc123^M$
^M$
GFGDDD^M$
  AS55VF  ^M$
TOOLONG1^M$
ab-12c^M$
X1Y2Z3
```

Task 11

**Filter the Neptun codes**

From a file of Neptun codes, copy those that contain a given text into another file. Parameters: the input file, the text, the output file. Input: [neptun_codes.txt](https://laszlonemes.com/files/computer-systems/powershell-practice/neptun_codes.txt).

```text
PS> ./filter_neptun.ps1 neptun_codes.txt 55 matches.txt
2 identifier(s) containing '55' copied to 'matches.txt'
PS> Get-Content matches.txt
RVR55V
AS55VF
```

**Approach:**

The skeleton with three parameters, `-like "*$Text*"` for the test, an array for the hits. `AS55VF` sits between spaces in the file – without `.Trim()` they would be copied along. Since `-like` ignores case, searching for `as` finds `ASD32B` and `AS55VF`.

```powershell
$line = $line.Trim()                 # "  AS55VF  " -> "AS55VF"
if ($line -eq '') { continue }

if ($line -like "*$Text*") {
    $found += $line
}
```

### Errors you can't rule out

Task 12

**Division with error handling**

Divide the first parameter by the second. Check that you got two integers. Guard the division by zero with `try`/`catch`.

```text
PS> ./divide.ps1 10 4
10 / 4 = 2.5
PS> ./divide.ps1 10 0
Write-Error: Cannot divide 10 by 0: Attempted to divide by zero.
PS> ./divide.ps1 10 abc
Write-Error: 'abc' is not an integer
```

**Approach:**

Two `[string]` parameters validated with `'^-?[0-9]+$'` – a `foreach` over both saves writing the check twice – then converted to `[int]`. The result is not an integer division: `10 / 4` is `2.5`.

```powershell
[int]$a = $Dividend
[int]$b = $Divisor

try {
    $result = $a / $b
    Write-Output "$a / $b = $result"
} catch {
    Write-Error "Cannot divide $a by ${b}: $($_.Exception.Message)"
    exit 1
}
```

Task 13

**Delete a list of files**

Delete the files given as a list. The script must finish without an error even if one of the files doesn't exist, and report what it did.

```text
PS> New-Item a.tmp, b.tmp, c.tmp -ItemType File
PS> ./delete_files.ps1 a.tmp,b.tmp,missing.tmp,c.tmp
Deleted: a.tmp
Deleted: b.tmp
Skipped (not an existing file): missing.tmp
Deleted: c.tmp
3 file(s) deleted, 1 skipped
```

**Approach:**

An array parameter, `[string[]]$Files`, passed with commas. Check each file with `Test-Path` and skip the missing ones; wrap the deletion in `try`/`catch` for whatever else can go wrong (a file in use, no permission) – with `-ErrorAction Stop`, or the `catch` is dead code.

```powershell
param(
    [string[]]$Files
)
…
foreach ($file in $Files) {
    if (-not (Test-Path -Path $file -PathType Leaf)) {
        Write-Output "Skipped (not an existing file): $file"
        $skipped++
        continue
    }

    try {
        Remove-Item -Path $file -ErrorAction Stop
        $deleted++
    } catch {
        Write-Warning "Could not delete '$file': $($_.Exception.Message)"
        $skipped++
    }
}
```

The list can come from a file too: `./delete_files.ps1 (Get-Content delete_list.txt)`. The output of the command in parentheses becomes the parameter.

## Common mistakes

| Wrong | Right | Why |
| --- | --- | --- |
| `./script.ps1 1 2 3` for an `[int[]]$Numbers` parameter | `./script.ps1 1,2,3` | Array elements are separated by commas; with spaces only the first lands in the array. |
| `Get-Factorial(5)` | `Get-Factorial 5` | A function is called without parentheses. |
| `if ($Number)` to see whether an `[int]` was given | `$PSBoundParameters.ContainsKey('Number')` | `0` is false too, and it can be a valid value. |
| `param([int]$Count)`, then `$PSBoundParameters.Count` | another name, e.g. `$Times` | A parameter called `Count` hides the `Count` property. |
| `$sum = $a + $b` on what `Read-Host` returned | `[double]$a = Read-Host "a"` | For strings `+` glues: `"1" + "2"` is `"12"`. |
| `try { Remove-Item $f } catch { … }` | `Remove-Item $f -ErrorAction Stop` | Without it `catch` doesn't see the cmdlet's error. |
| `$lines = Get-Content $f`, then `$lines[0]` or `$lines.Count - 1` | `$lines = @(Get-Content $f)` | A one-line file comes back as a string, not an array. |
| `if ($line -like $Text)` for "contains" | `$line -like "*$Text*"` | Without the stars only an identical line matches. |
| `$line -split ' '` | `$line -split '\s+'` | With several spaces in a row, empty elements end up in the array. |
| `-replace 'at', '@'` for the word *at* | `-replace '\bat\b', '@'` | Without the word boundaries it also matches inside *kata*. |
| `[int]$result` for a factorial | `[bigint]$result` | A typed integer stops with an error at 13!; an untyped variable silently turns into an inexact decimal. |
| `… > out.txt` in a script | `… \| Set-Content -Path out.txt -Encoding UTF8` | In Windows PowerShell 5.1, `>` writes a UTF-16 file. |

## Summary

- **`$PSBoundParameters`** – the parameters that were actually passed: `.Count`, `.ContainsKey('Name')`
- **`$args`** – the parameters not named in `param()` – all of them if there is no `param()` block
- **array parameter** – `[int[]]$Numbers`; passed with commas: `1,2,3`
- **terminating error** – an error that jumps to `catch`; a cmdlet's error becomes one with `-ErrorAction Stop`
- **`@( )`** – forces an array, even from one value or none
- **word boundary** – `\b` in a regular expression: the edge between a word and what surrounds it

- Every script opens the same way: `param()`, usage on a missing parameter, `Test-Path`, then the `foreach` over `Get-Content`.
- A typed parameter is validated by PowerShell; a `[string]` plus a regular expression gives you your own message.
- An `[int]` defaults to 0 – ask `$PSBoundParameters` whether it was given.
- `/` is a real division, `Read-Host` returns text, and `[bigint]` keeps every digit.
- `catch` only receives a cmdlet's error with `-ErrorAction Stop`; check with `Test-Path` where you can.
- `@(Get-Content …)` is an array you can index and walk backwards; collect output lines in an array and write once.
- `-replace` takes a regular expression and replaces every match; `-like "*text*"` tests "contains". Both ignore case.

### Further reading

- [about_Try_Catch_Finally](https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_try_catch_finally) – `try`, `catch` and `finally`
- [about_Functions_Advanced_Parameters](https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_functions_advanced_parameters) – parameter types, defaults, switches
- [about_Regular_Expressions](https://learn.microsoft.com/powershell/module/microsoft.powershell.core/about/about_regular_expressions) – regular expressions in PowerShell
- [regex101](https://regex101.com/) – try a pattern before you put it into a script (".NET (C#)" flavour)
