Session 9 · Computer Systems

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.

20 min readThe script skeletonTyped parameter or string + regex$args and array parameterstry / 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. Here those pieces are put to work, and thirteen tasks at the end let you practise each of them.

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.

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
}
PartWhat 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 1The message goes to the error stream, not into the results, and the script ends with a non-zero exit code.
Test-Path -PathType LeafIt exists and it is a file. For a directory: -PathType Container.
Get-Content … -Encoding UTF8The 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 linesStray spaces, tabs and empty lines are the two things Get-Content doesn't clean up for you.

Checking parameters: two ways

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

./square.ps1 abc[int]$Numbertyped parameterPowerShell converts "abc"before the script startsPowerShell's error messageCannot convert value "abc" …[string]$Numbertext, checked by youthe script starts-notmatch '^-?[0-9]+$'your error message'abc' is not an integerBoth stop the script on bad input. The typed form is shorter; with a string, the wording is yours.
Fig. 1 · A typed parameter is checked by PowerShell before your first line runs; a string parameter is checked by you.
Typed parameterString + regular expression
Declarationparam( [int]$Number )param( [string]$Number )
Who checks?PowerShell, before the script startsyou: if ($Number -notmatch '^[0-9]+$')
Error messagePowerShell'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

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
}
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

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
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.

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.

# 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:

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
./sum.ps1 1 2 3no param() block$args = 1, 2, 3./sum.ps1 1,2,3param( [int[]]$Numbers )$Numbers = 1, 2, 3./sum.ps1 1 2 3param( [int[]]$Numbers )$Numbers = 1$args = 2, 3Spaces separate parameters; commas build one array. The comma form only works when started from inside PowerShell.
Fig. 2 · The same three numbers, three outcomes. The last row is the classic mistake: spaces where commas were needed.

Numbers and yes/no functions

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

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
TypeHoldsUse it for
[int]whole numbers up to about ±2 billioncounters, indexes, most parameters
[long]whole numbers up to about ±9 · 10¹⁸file sizes in bytes
[double]decimal numbersanything with a fraction; what / returns
[bigint]whole numbers of any sizefactorials 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]:

$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:

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 $_:

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
}
try { … }$result = $a / $bRemove-Item $f -ErrorAction StopRemove-Item $ferror: jumpcatch { … }$_ is the error; write your message, exit 1error: printed onlythe script goes oncatch never runsA cmdlet's error reaches catch only with -ErrorAction Stop.
Fig. 3 · Two kinds of error. Only the terminating kind reaches catch – and -ErrorAction Stop makes a cmdlet's error that kind.

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.

$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]
}
a file with a single line:asd1 asd2$lines = Get-Content $fone string$lines[0] → athe first character$lines = @(Get-Content $f)an array of one string$lines[0] → asd1 asd2the first line
Fig. 4 · The reason for @( ): a one-line file comes back as a string, and a string can be indexed too.

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:

$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.

$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.)

"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 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

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.

\s*any spaces[\(\[]?an optional ( or [\bword edge(kukac|at)kukac or at\bword edge[\)\]]?an optional ) or ]\s*any spacesThe whole match – spaces and brackets included – is replaced by @:gipsz kukac jakabgipsz(kukac)jakabgipsz [at] jakabgipsz@jakab
Fig. 5 · Seven pieces, read left to right. Whatever the pattern matches – brackets and spaces included – becomes a single @.
$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:

"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":

"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.txtemails_encoded.txtword_pairs.txtneptun_codes.txt

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

Linux, macOS, the course server

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

Windows (PowerShell, cmd)

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.

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.

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.

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.

$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.

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.

[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.

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.

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.

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.

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.

$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 – five lines, the last without a newline.

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.

$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.

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.

$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.

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.

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.

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.

$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

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.

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.

$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.

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.

[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.

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.

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

WrongRightWhy
./script.ps1 1 2 3 for an [int[]]$Numbers parameter./script.ps1 1,2,3Array elements are separated by commas; with spaces only the first lands in the array.
Get-Factorial(5)Get-Factorial 5A 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.Countanother name, e.g. $TimesA 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 StopWithout 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]$resultA 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 UTF8In 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