The correct way to write a PowerShell function that works with file paths from the pipeline requires some effort, but it will make your PowerShell scripts work more reliably.
Avatar

Suppose we want to write a function that counts the number of lines in a text file, and we want to pipe those files to our function. Cmdlets like Get-Item, Get-ChildItem, and Get-Content all accept input from the pipeline, but how do we write a function that behaves similarly to the core cmdlets? Let's see how we would go about creating our own function that supports this workflow.

Example directory

The directory below has some example files that we can test with. Notice that one of the files is named [file].txt using brackets []. This is a valid file name but illustrates the problem we must solve.

Test folder contents

Test folder contents

Using Get-Item and the -Path parameter does not return a result when attempting to get this file, but using the LiteralPath parameter does.

Using Get Item on file with wildcard characters

Using Get Item on file with wildcard characters

Basic function

We'll start off with the basic function below that returns the number of lines in a file.

function Get-Lines {
    param(
        [string]$Path
    )

    $content = Get-Content -Path $Path
    $content.Count
}

Testing this function shows that it behaves as expected when passing in a file name via the -Path parameter.

Getting the number of lines of a file

Getting the number of lines of a file

As you can see, we run into trouble when trying to pass that file via the pipeline.

Error when piping filename to Get Lines

Error when piping filename to Get Lines

Improved function

We can make some improvements to this function so it accepts input from the pipeline. The improved example below shows a more complete function that works with accepting input in all types of scenarios.

function Get-Lines {
    <#
    .SYNOPSIS
    Counts the number of lines in a file.
    #>
    [cmdletbinding(DefaultParameterSetName = 'Path')]
    param(
        [parameter(
            Mandatory,
            ParameterSetName  = 'Path',
            Position = 0,
            ValueFromPipeline,
            ValueFromPipelineByPropertyName
        )]
        [ValidateNotNullOrEmpty()]
        [SupportsWildcards()]
        [string[]]$Path,

        [parameter(
            Mandatory,
            ParameterSetName = 'LiteralPath',
            Position = 0,
            ValueFromPipelineByPropertyName
        )]
        [ValidateNotNullOrEmpty()]
        [Alias('PSPath')]
        [string[]]$LiteralPath
    )

    process {
        # Resolve path(s)
        if ($PSCmdlet.ParameterSetName -eq 'Path') {
            $resolvedPaths = Resolve-Path -Path $Path | Select-Object -ExpandProperty Path
        } elseif ($PSCmdlet.ParameterSetName -eq 'LiteralPath') {
            $resolvedPaths = Resolve-Path -LiteralPath $LiteralPath | Select-Object -ExpandProperty Path
        }

        # Process each item in resolved paths
        foreach ($item in $resolvedPaths) {
            $fileItem = Get-Item -LiteralPath $item
            $content = $fileItem | Get-Content
            [pscustomobject]@{
                Path  = $fileItem.Name
                Lines = $content.Count
            }
        }
    }
}

Parameters

This new function has quite a bit going on so let's break it down. To start, we have two parameters here called Path and LiteralPath in two separate parameter sets, meaning we can only use parameters belonging to a particular set at a time.

[cmdletbinding(DefaultParameterSetName = 'Path')]
param(
    [parameter(
        Mandatory,
        ParameterSetName  = 'Path',
        Position = 0,
        ValueFromPipeline,
        ValueFromPipelineByPropertyName
    )]
    [ValidateNotNullOrEmpty()]
    [SupportsWildcards()]
    [string[]]$Path,

    [parameter(
        Mandatory,
        ParameterSetName = 'LiteralPath',
        Position = 0,
        ValueFromPipelineByPropertyName
    )]
    [ValidateNotNullOrEmpty()]
    [Alias('PSPath')]
    [string[]]$LiteralPath
)

LiteralPath is there so we can have support for file paths that contain special characters usually interpreted as wildcards. Both of these parameters accept a collection of strings, are mandatory, and are at position 0. The Path parameter accepts values from the pipeline, and you can see that both parameters accept values from the pipeline by property name.

We've also noted wildcard support on the Path parameter via the [SupportsWildcards()] attribute. This attribute is purely cosmetic. PowerShell's help system uses it to tell the user that the parameter accepts wildcards. We are also not accepting null or empty strings for either of these parameters via the [ValidateNotNullOrEmpty()] attribute.

Process Block

Now that we have our parameters defined, let's look at the process block of the function.

process {
    # Resolve path(s)
    if ($PSCmdlet.ParameterSetName -eq 'Path') {
        $resolvedPaths = Resolve-Path -Path $Path | Select-Object -ExpandProperty Path
    } elseif ($PSCmdlet.ParameterSetName -eq 'LiteralPath') {
        $resolvedPaths = Resolve-Path -LiteralPath $LiteralPath | Select-Object -ExpandProperty Path
    }

    # Process each item in resolved paths
    foreach ($item in $resolvedPaths) {
        $fileItem = Get-Item -LiteralPath $item
        $content = $fileItem | Get-Content
        [pscustomobject]@{
            Path  = $fileItem.Name
            Lines = $content.Count
        }
    }
}

We have our code defined in the process block because it will run for every item it receives from the pipeline. Inside the process block, the first thing we need to do is resolve the paths passed in.

We check the parameter set name used because depending on whether we received a Path or LiteralPath parameter, we need to resolve those paths differently using Resolve-Path (which interprets the wildcard characters). Once we have an array of resolved paths for the current item in the pipeline, we need to loop over them because each item sent down the pipeline could be an array of paths.

Now we finally get to the point of the function, to count the number of lines in a file. We'll first get the file with Get-Item and make sure to use the -LiteralPath parameter to ensure we can also get file names that may include characters normally considered wildcards. Once we have the file, we'll pipe it to Get-Content. We'll then create a custom object that includes the name of the file as well as the number of lines.

Using the improved function

This improved function handles pipeline input much better. It works correctly with System.IO.FileInfo objects returned from Get-ChildItem and Get-Item as well as normal strings. It even supports literal paths.

Subscribe to 4sysops newsletter!

Piping files to the function

Piping files to the function

Conclusion

Adding proper support for files sent via the pipeline involves a fair amount of code, but it really does make the function bulletproof. It's worth the effort to provide the best user experience possible.

1 Comment
  1. Avatar
    Mark Heath 3 years ago

    How does the script tell the difference between literal path names coming from get-childitems and from a wildcard string.  eg gci | get-lines vs "*.txt" | get-lines.  How does it know to use -literalpath for gci and -path for "*.txt" ?

Leave a reply

Your email address will not be published. Required fields are marked *

*

© 4sysops 2006 - 2023

CONTACT US

Please ask IT administration questions in the forums. Any other messages are welcome.

Sending

Log in with your credentials

or    

Forgot your details?

Create Account