When I write a PowerShell module, it typically includes more than one export function. Where you store your module functions is a great discussion topic and I don't think there is necessarily one best practice for everyone. I think it might depend on the number and complexity of the functions. Are other people contributing code to the module? How are you using Pester? These are just a few questions to consider. In my work, sometimes the functions are in the .psm1 file. Some projects have a public.ps1 file with several functions. In more complex projects, like my PSScriptTools module, each function gets a separate file.
ManageEngine ADManager Plus - Download Free Trial
Exclusive offer on ADManager Plus for US and UK regions. Claim now!
My challenge arises when I decide to separate out all the functions defined in a single file. I want each function to reside in its own file, using the function name as the file name. I got tired of doing this manually, so I wrote a PowerShell function to do the work for me.
Using the AST
PowerShell uses a coding concept called the "abstract syntax tree", or AST. The AST makes it possible to parse a PowerShell expression or even a file. Using the AST is definitely advanced PowerShell scripting juju.
New-Variable astTokens -Force
New-Variable astErr -Force
$AST = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$astTokens, [ref]$astErr)
Depending on what you are abstracting, you may be able to get exactly what you want from the immediate parsing. In my case, I needed to search within the AST result for a specific type of command element.
$functions = $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)
In my example, I found these functions in the file.
I'll point out that the function names that don't follow the Verb-Noun naming convention are class constructors. Although they could also be private helper functions with typically use non-standard names since they are never exposed publically. More on how I handle this in a bit.
Each function element is its own object.
As you can see, it is pretty easy to get the function text. It is even easier if I use the ToString() method.
Set-Content -path "d:\temp\$($functions[-1].name).ps1" -Value $functions[-1].ToString()
Testing Function Names
As I was building my export tool, I realized I needed a way to skip functions that probably will never be publicly exposed. So I wrote a simple function to test a function name.
Function Test-FunctionName {
[CmdletBinding()]
[OutputType("boolean")]
Param(
[Parameter(Position = 0,Mandatory,HelpMessage = "Specify a function name.")]
[ValidateNotNullOrEmpty()]
[string]$Name
)
Write-Verbose "Validating function name $Name"
#Function name must first follow Verb-Noun pattern
if ($Name -match "^\w+-\w+$") {
#validate the standard verb
$verb = ($Name -split "-")[0]
Write-Verbose "Validating detected verb $verb"
if ((Get-Verb).verb -contains $verb ) {
$True
}
else {
Write-Verbose "$($Verb.ToUpper()) is not an approved verb."
$False
}
}
else {
Write-Verbose "$Name does not match the regex pattern ^\w+-\w+$"
$False
}
}
I know this could have been a one-line command, but I wanted to add an explanation on why a function name would fail. The function takes a name and first tests if it matches the "Word-Word" pattern. If that passes, then the function tests if the "verb" is a standard verb. If so, the name passes the test.
Exporting Functions
Time to put this all together.
Function Export-FunctionFromFile {
[cmdletbinding(SupportsShouldProcess)]
[alias("eff")]
[OutputType("None", "System.IO.FileInfo")]
Param(
[Parameter(Position = 0, Mandatory, HelpMessage = "Specify the .ps1 or .psm1 file with defined functions.")]
[ValidateScript({
If (Test-Path $_ ) {
$True
}
Else {
Throw "Can't validate that $_ exists. Please verify and try again."
$False
}
})]
[ValidateScript({
If ($_ -match "\.ps(m)?1$") {
$True
}
Else {
Throw "The path must be to a .ps1 or .psm1 file."
$False
}
})]
[string]$Path,
[Parameter(HelpMessage = "Specify the output path. The default is the same directory as the .ps1 file.")]
[ValidateScript({ Test-Path $_ })]
[string]$OutputPath,
[Parameter(HelpMessage = "Export all detected functions.")]
[switch]$All,
[Parameter(HelpMessage = "Pass the output file to the pipeline.")]
[switch]$Passthru
)
Write-Verbose "Starting $($MyInvocation.MyCommand)"
#always create these variables
New-Variable astTokens -Force -WhatIf:$False
New-Variable astErr -Force -WhatIf:$False
if (-Not $OutputPath) {
#use the parent path of the file unless the user specifies a different path
$OutputPath = Split-Path -Path $Path -Parent
}
Write-Verbose "Processing $path for functions"
#the file will always be parsed regardless of WhatIfPreference
$AST = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$astTokens, [ref]$astErr)
#parse out functions using the AST
$functions = $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)
if ($functions.count -gt 0) {
Write-Verbose "Found $($functions.count) functions"
Write-Verbose "Creating files in $outputpath"
Foreach ($item in $functions) {
Write-Verbose "Detected function $($item.name)"
#only export functions with standard namees or if -All is detected.
if ($All -OR (Test-FunctionName -name $item.name)) {
$newfile = Join-Path -Path $OutputPath -ChildPath "$($item.name).ps1"
Write-Verbose "Creating new file $newFile"
Set-Content -Path $newFile -Value $item.ToString() -Force
if ($Passthru -AND (-Not $WhatIfPreference)) {
Get-Item -Path $newfile
}
}
else {
Write-Verbose "Skipping $($item.name)"
}
} #foreach item
}
else {
Write-Warning "No functions detected in $Path."
}
Write-Verbose "Ending $($MyInvocation.MyCommand)"
} #end function
By default, the function will only export functions with standard names.
If you look closely at the Verbose output you'll see that the detected non-standard functions are skipped. Although I can include all detected functions if I need to.
The function allows me to specify an alternate location for the new files, although the default is the source file's parent directory.
The source file is unaffected, although I suppose I could modify the function to delete code from the file. For now, I'll manually update the source file and any other module files to reflect the new function files.
Try It
I have both functions in the same file which I dot source. Although I might add these functions to the PSScriptTools module at some point in the future. The export function assumes the file is a .ps1 or .psm1 file. You can see the parameter validation I'm using in the code. Give it a try and let me know what you think. The code should work in Windows PowerShell and PowerShell 7.x.
Thanks for sharing. I can see this logic being useful in main different instances.
I tweaked the function to only list the functions when the OutputPath isn’t specified. I didn’t want to inadvertently generate a bunch of files in the wrong path.
I’m working on an updated version that will let you specify functions by name. I also am working on a feature that will delete the functions from the file if you are in the PowerShell ISE or VS Code.
This script apparent to work from Linux too.
PS /home/cadayton/git-keybase/PowerShell> ./Export-FunctionFromFile.ps1 /home/cadayton/git-keybase/PowerShell/psConsole/psConsole.ps1
Found Function: Start-Interface
Found Function: Get-UserName
Found Function: Get-DomainName
Found Function: Get-IdentityFile