Recently, I shared some PowerShell code to export a function to a file. It was a popular post. My friend Richard Hicks (no relation) thought we was joking when he asked about converting files to functions. His thought was to take a bunch of PowerShell scripts, turn them into a group of functions which could then be organized into a module. This is not that far-fetched. So I spent some time the last few days and came up with a PowerShell function to take an existing PowerShell script file and turn it into a PowerShell function.
ManageEngine ADManager Plus - Download Free Trial
Exclusive offer on ADManager Plus for US and UK regions. Claim now!
Practically speaking, there is no difference between running the code inside a function and a script. A function is at its core a scriptblock with a name that makes it easier to run. The code inside the scriptblock is no different than what you might have in a stand-alone script. Depending on your script, you could simply wrap your script code in a Function declaration. Of course, the best practice is for functions to do a single task and write a single type of object to the pipeline so you might still need to edit your resulting function. What I came up with was a PowerShell tool using the AST to accelerate the process.
Basic Example
Here is an extremely simple PowerShell script.
#requires -version 3.0
#this is a sample script
Write-Host "this is a sample script that doesn't do anything but write a random number" -ForegroundColor Yellow
Get-Random -Minimum 1 -Maximum 1000
I want to turn this into a PowerShell function, so I'll use my new tool.
My Convert-ScriptToFunction command obviously needs the path to file. I also need to specify a name for the new function. The conversion generates a new function definition complete with comment-based help. The original script didn't have defined parameters, so the conversion defined them. If I wanted to save this to a file all I need to do is run the command and pipe to Out-File. I can then open the file in my scripting editor to polish it.
Getting Requirements
You'll notice that the new code included #requires statements from the original file. I wrote a separate function, also using the AST to get any requirements. In my New-SystemReport.ps1 script, I have these requirements.
#requires -version 5.1
#requires -module CimCmdlets
I can use this function to discover them.
Function Get-PSRequirements {
[cmdletbinding()]
Param(
[Parameter(Position = 0, Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
[string]$Path
)
Begin {
Write-Verbose "Starting $($MyInvocation.MyCommand)"
New-Variable astTokens -Force
New-Variable astErr -Force
}
Process {
$Path = Convert-Path $path
Write-Verbose "Processing $path"
$AST = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$astTokens, [ref]$astErr)
#add the Path as a property
if ($ast.ScriptRequirements) {
$ast.ScriptRequirements | Add-Member -MemberType NoteProperty -Name "Path" -Value $Path-Force -PassThru
}
else {
Write-Verbose "No requirements detected in $Path."
}
}
End {
Write-Verbose "Ending $($MyInvocation.MyCommand)"
}
}
Comment-Based Help
I also wanted to be able to generate comment-based help if it wasn't already defined. Using the AST ParamBlock I create such a block. I'm always telling people to use the HelpMessage attribute, and if you do, it will be used for the .Parameter definition.
Function New-CommentHelp {
[cmdletbinding()]
[OutputType("string")]
Param([System.Management.Automation.Language.ParamBlockAst]$ParamBlock)
$h = [System.Collections.Generic.List[string]]::new()
$h.Add("<#")
$h.Add("`t.Synopsis")
$h.Add("`t <short description>")
$h.add("`t.Description")
$h.add("`t <long description>")
foreach ($p in $ParamBlock.Parameters) {
$paramName = $p.name.variablepath.userpath
$h.Add("`t.Parameter $paramName")
$paramHelp = $p.Attributes.namedArguments.where({ $_.argumentname -eq 'helpmessage' })
if ($paramHelp) {
$h.add("`t $($paramHelp.argument.value)")
}
else {
$h.Add("`t <enter a parameter description>")
}
}
$h.add("`t.Example")
$h.Add("`t PS C:\> $Name")
$h.Add("`t <output and explanation>")
$h.Add("`t.Link")
$h.Add("`t <enter a link reference>")
$h.Add("#>")
$h
}
Here's a sample script.
#requires -version 3.0
#this is a sample script
Param (
[Parameter(Position = 0,HelpMessage = "How many numbers do you want?")]
[int]$Count = 1,
[string]$Name,
[switch]$Demo
)
Write-Host "this is a sample script that doesn't do anything but write a random number" -ForegroundColor Yellow
#get numbers
1..$count | Foreach-Object {
Get-Random -Minimum 1 -Maximum 1000
}
Write-Host "Ending script" -ForegroundColor yellow
#eof
Using my conversion function I get this output.
#requires -version 3.0
# Function exported from C:\scripts\SampleScript3.ps1
Function Invoke-Sample {
<#
.Synopsis
<short description>
.Description
<long description>
.Parameter Count
How many numbers do you want?
.Parameter Name
<enter a parameter description>
.Parameter Demo
<enter a parameter description>
.Example
PS C:\> Invoke-Sample
<output and explanation>
.Link
<enter a link reference>
#>
[cmdletbinding()]
Param (
[Parameter(Position = 0,HelpMessage = "How many numbers do you want?")]
[int]$Count = 1,
[string]$Name,
[switch]$Demo
)
Write-Host "this is a sample script that doesn't do anything but write a random number" -ForegroundColor Yellow
1..$count | Foreach-Object {
Get-Random -Minimum 1 -Maximum 1000
}
Write-Host "Ending script" -ForegroundColor yellow
} #close Invoke-Sample
Function Names
Because I have to define a new function name, I wanted this to be as simple as possible. I use this helper function to format the name to the proper case. I'm assuming a Verb-Noun naming convention.
Function Format-FunctionName {
[cmdletbinding()]
Param (
[ValidateScript({
if ($_ -match "^\w+-\w+$") {
$true
}
else {
Throw "Your function name should have a Verb-Noun naming convention"
$False
}
})]
[string]$Name
)
$split = $name -split "-"
"{0}{1}-{2}{3}" -f $split[0][0].ToString().ToUpper(), $split[0].Substring(1).Tolower(),$split[1][0].ToString().ToUpper(), $split[1].Substring(1).ToLower()
}
It isn't always perfect, especially if your noun is complex like this example. The function also doesn't validate that you are using a standard verb. However, I have an argument completer that will insert a standard verb.
Register-ArgumentCompleter -CommandName Convert-ScriptToFunction -ParameterName Name -ScriptBlock {
param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter)
#PowerShell code to populate $wordtoComplete
Get-Verb | Where-Object {$_.verb -match "^$wordToComplete"} |
ForEach-Object {
#this will autocomplete with Verb-
[System.Management.Automation.CompletionResult]::new("$($_.verb)-", $_.verb, 'ParameterValue', $_.Group)
}
}
All I need to do is begin typing the verb name and hit Tab. The completion will insert the verb and the dash. All I need to type is the noun.
Working with Output
My function does exactly what it says it does. It converts. It is up to you to decide how you want to use the output. You can pipe to Out-File or Set-Clipboard. But since I expect you will be immediately editing your output, I added a dynamic parameter (use my New-PSDynamicParameterForm function) called ToEditor. This parameter will be defined if you run the function in the PowerShell ISE or VS Code. The output will be opened in a new, unsaved file.
Again, here's the source file. These are script files I created merely to test my function.
#requires -version 4.0
#requires -runasAdministrator
#this is a sample script
Param (
[Parameter(Position = 0,HelpMessage = "How many numbers do you want?")]
[ValidateRange(1,100)]
[int]$Count = 1
)
DynamicParam {
#this is a sample dynamic parameter
If ($True) {
$paramDictionary = New-Object -Type System.Management.Automation.RuntimeDefinedParameterDictionary
# Defining parameter attributes
$attributeCollection = New-Object -Type System.Collections.ObjectModel.Collection[System.Attribute]
$attributes = New-Object System.Management.Automation.ParameterAttribute
$attributes.ParameterSetName = '__AllParameterSets'
$attributeCollection.Add($attributes)
# Defining the runtime parameter
$dynParam1 = New-Object -Type System.Management.Automation.RuntimeDefinedParameter('Demo', [String], $attributeCollection)
$paramDictionary.Add('Demo', $dynParam1)
return $paramDictionary
} # end if
} #end DynamicParam
Begin {
Write-Host "this is a sample script that doesn't do anything but write a random number" -ForegroundColor Yellow
}
Process {
#get numbers
1..$count | Foreach-Object {
Get-Random -Minimum 1 -Maximum 1000
}
}
End {
write-host "Ending script" -ForegroundColor yellow
}
#eof
I'll run this code in the integrated PowerShell terminal in VS Code to create a new file. The function will also get a new alias.
Convert-ScriptToFunction .\SampleScript4.ps1 -Name Invoke-Sample -Alias ins -ToEditor
I can then use VSCode to clean up the file such as formatting, expanding aliases, and converting tabs to spaces. Then I can save the file. The output includes a reference to the original source file.
Convert-ScriptToFunction
Here is the conversion function.
Function Convert-ScriptToFunction {
<#
.Synopsis
Convert a script file to a PowerShell function.
.Description
This command takes the body of a script file and wraps it in a function
declaration. The command will insert missing elements like [cmdletbinding()]
and comment-based help. You will most likely need to edit and clean up the
result in your scripting editor.
If you run this command in the PowerShell ISE or the VS Code PowerShell
integrated terminal, you can use the dynamic parameter ToEditor to open a
new file with with the output. You can edit and save the file manually.
It is assumed that your script file is complete and without syntax errors.
.Parameter Path
Enter the path to your PowerShell script file.
.Parameter Name
What is the name of your new function? It should have a Verb-Noun name.
.Parameter Alias
Define an optional alias for your new function.
.Parameter ToEditor
If you run this command in the PowerShell ISE or the VS Code PowerShell
integrated terminal, you can use this dynamic parameter to open a new
file with with the output. You can edit and save the file manually.
.Example
PS C:\> Convert-ScriptToFunction c:\scripts\Daily.ps1 -name Invoke-DailyTask | Set-Clipboard
Convert Daily.ps1 to a function called Invoke-DailyTask and copy the
results to the Windows clipboard. You can then paste the results into
scripting editor.
.Example
PS C:\> Convert-ScriptToFunction c:\scripts\systemreport.ps1 -name New-SystemReport | Out-File c:\scripts\New-SystemReport.ps1
Convert the SystemReport.ps1 script file to a function called
New-SystemReport and save the results to a file.
.Example
PS C:\> Convert-ScriptToFunction c:\scripts\systemreport.ps1 -name New-System -alias nsr | Tee-Object -variable f
Convert the script to a function called New-System and tee the output to $f.
This will also define an function alias of nsr.
#>
[cmdletbinding()]
[Outputtype("System.String")]
[alias('csf')]
Param(
[Parameter(
Position = 0,
Mandatory,
ValueFromPipelineByPropertyName,
HelpMessage = "Enter the path to your PowerShell script file."
)]
[ValidateScript({Test-Path $_ })]
[ValidatePattern("\.ps1$")]
[string]$Path,
[Parameter(
Position = 1,
Mandatory,
ValueFromPipelineByPropertyName,
HelpMessage = "What is the name of your new function?")]
[ValidateScript({
if ($_ -match "^\w+-\w+$") {
$true
}
else {
Throw "Your function name should have a Verb-Noun naming convention"
$False
}
})]
[string]$Name,
[Parameter(ValueFromPipelineByPropertyName,HelpMessage = "Specify an optional alias for your new function. You can define multiple aliases separated by commas.")]
[ValidateNotNullOrEmpty()]
[string[]]$Alias
)
DynamicParam {
<#
If running this function in the PowerShell ISE or VS Code,
define a ToEditor switch parameter
#>
If ($host.name -match "ISE|Code") {
$paramDictionary = New-Object -Type System.Management.Automation.RuntimeDefinedParameterDictionary
# Defining parameter attributes
$attributeCollection = New-Object -Type System.Collections.ObjectModel.Collection[System.Attribute]
$attributes = New-Object System.Management.Automation.ParameterAttribute
$attributes.ParameterSetName = '__AllParameterSets'
$attributeCollection.Add($attributes)
# Defining the runtime parameter
$dynParam1 = New-Object -Type System.Management.Automation.RuntimeDefinedParameter('ToEditor', [Switch], $attributeCollection)
$paramDictionary.Add('ToEditor', $dynParam1)
return $paramDictionary
} # end if
} #end DynamicParam
Begin {
Write-Verbose "Starting $($MyInvocation.MyCommand)"
Write-Verbose "Initializing"
New-Variable astTokens -Force
New-Variable astErr -Force
$new = [System.Collections.Generic.list[string]]::new()
} #begin
Process {
#normalize
$Path = Convert-Path $path
$Name = Format-FunctionName $Name
Write-Verbose "Processing $path"
$AST = [System.Management.Automation.Language.Parser]::ParseFile($Path, [ref]$astTokens, [ref]$astErr)
if ($ast.extent) {
Write-Verbose "Getting any comment based help"
$ch = $astTokens | Where-Object { $_.kind -eq 'comment' -AND $_.text -match '\.synopsis' }
if ($ast.ScriptRequirements) {
Write-Verbose "Adding script requirements"
if($ast.ScriptRequirements.RequiredPSVersion) {
$new.Add("#requires -version $($ast.ScriptRequirements.RequiredPSVersion.ToString())")
}
if ($ast.ScriptRequirements.RequiredModules) {
Foreach ($m in $ast.ScriptRequirements.RequiredModules) {
#test for version requirements
$ver = $m.psobject.properties.where({$_.name -match 'version' -AND $_.value})
if ($ver) {
$new.Add("#requires -module @{ModuleName = '$($m.name)';$($ver.Name) = '$($ver.value)'}")
}
else {
$new.add("#requires -module $($m.Name)")
}
}
}
if ($ast.ScriptRequirements.IsElevationRequired) {
$new.Add("#requires -RunAsAdministrator")
}
If ($ast.ScriptRequirements.requiredPSEditions) {
$new.add("#requires -PSEdition $($ast.ScriptRequirements.requiredPSEditions)")
}
$new.Add("`n")
}
else {
Write-Verbose "No script requirements found"
}
$head = @"
# Function exported from $Path
Function $Name {
"@
$new.add($head)
if ($ch) {
$new.Add($ch.text)
$new.Add("`n")
}
else {
Write-Verbose "Generating new comment based help from parameters"
New-CommentHelp -ParamBlock $ast.ParamBlock | Foreach-Object { $new.Add("$_")}
$new.Add("`n")
}
[regex]$rx = "\[cmdletbinding\(.*\)\]"
if ($rx.Ismatch($ast.Extent.text)) {
Write-Verbose "Using existing cmdletbinding"
#use the first match
$cb = $rx.match($ast.extent.text).Value
$new.Add("`t$cb")
}
else {
Write-Verbose "Adding [cmdletbinding()]"
$new.Add("`t[cmdletbinding()]")
}
if ($alias) {
Write-Verbose "Adding function alias definition $($alias -join ',')"
$new.Add("`t[Alias('$($alias -join "','")')]")
}
if ($ast.ParamBlock) {
Write-Verbose "Adding defined Param() block"
[void]($ast.ParamBlock.tostring().split("`n").Foreach({$new.add("`t$_")}) -join "`n")
$new.Add("`n")
}
else {
Write-Verbose "Adding Param() block"
$new.add("`tParam()")
}
if ($ast.DynamicParamBlock) {
#assumes no more than 1 dynamic parameter
Write-Verbose "Adding dynamic parameters"
[void]($ast.DynamicParamBlock.tostring().split("`n").Foreach({$new.Add($_)}) -join "`n")
}
if ($ast.BeginBlock.Extent.text) {
Write-Verbose "Adding defined Begin block"
[void]($ast.BeginBlock.Extent.toString().split("`n").Foreach({$new.Add($_)}) -join "`n")
$UseBPE = $True
}
if ($ast.ProcessBlock.Extent.text) {
Write-Verbose "Adding defined Process block"
[void]($ast.ProcessBlock.Extent.ToString().split("`n").Foreach({$new.add($_) }) -join "`n")
}
if ($ast.EndBlock.Extent.text) {
if ($UseBPE) {
Write-Verbose "Adding opening End{} block"
$new.Add("`tEnd {")
}
Write-Verbose "Adding the remaining code or defined endblock"
[void]($ast.Endblock.Statements.foreach({ $_.tostring() }).Foreach({ $new.Add($_)}))
if ($UseBPE) {
Write-Verbose "Adding closing End {} block"
$new.Add("`t}")
}
}
else {
$new.Add("End { }")
}
Write-Verbose "Closing the function"
$new.Add( "`n} #close $name")
if ($PSBoundParameters.ContainsKey("ToEditor")) {
Write-Verbose "Opening result in editor"
if ($host.name -match "ISE") {
$newfile = $psise.CurrentPowerShellTab.Files.add()
$newfile.Editor.InsertText(($new -join "`n"))
$newfile.editor.select(1,1,1,1)
}
elseif ($host.name -match "Code") {
$pseditor.Workspace.NewFile()
$ctx = $pseditor.GetEditorContext()
$ctx.CurrentFile.InsertText($new -join "`n")
}
else {
$new -join "`n" | Set-Clipboard
Write-Warning "Can't detect the PowerShell ISE or VS Code. Output has been copied to the clipboard."
}
}
else {
Write-Verbose "Writing output [$($new.count) lines] to the pipeline"
$new -join "`n"
}
} #if ast found
else {
Write-Warning "Failed to find a script body to convert to a function."
}
} #process
End {
Write-Verbose "Ending $($MyInvocation.mycommand)"
}
}
The function assumes your script file is syntactically complete and without error. Understand that not every script file and can be converted into a PowerShell function that you can immediately use. The convert function is making the best effort possible. You should look at this as a tool to accelerate your scripting work. I assume I will need to edit the new file. But this is a good start.
I can take an old PowerShell script like this:
#requires -version 3.0
#Basic-HotFixReport.ps1
Param([string[]]$Computername = $env:COMPUTERNAME)
$ErrorActionPreference = "SilentlyContinue"
Get-Hotfix -ComputerName $Computername |
Select-Object -Property PSComputername,HotFixID,Description,InstalledBy,InstalledOn,
@{Name="Online";Expression={$_.Caption}}
And create a new function:
csf .\Basic-HotfixReport.ps1 -Name Get-HotFixReport -Alias ghfr | out-file c:\scripts\get-hotfixreport.ps1
My function has an alias of csf. After editing the file to bring it up-to-date, I have something like this.
#requires -version 5.1
# Function exported from C:\scripts\Basic-HotfixReport.ps1
Function Get-HotfixReport {
<#
.Synopsis
Get a hotfix report
.Description
Use this command to get a report of installed hotfixes on a computer.
.Parameter Computername
Enter the name of a computer.
.Example
PS C:\scripts> Get-HotfixReport thinkp1 | format-table
Computername HotFixID Description InstalledBy InstalledOn Online
------------ -------- ----------- ----------- ----------- ------
THINKP1 KB5006363 Update NT AUTHORITY\SYSTEM 11/6/2021 12:00:00 AM http://support.microsoft.com/?kbid=5006363
THINKP1 KB5004567 Update NT AUTHORITY\SYSTEM 7/4/2021 12:00:00 AM https://support.microsoft.com/help/5004567
THINKP1 KB5008295 Update NT AUTHORITY\SYSTEM 11/6/2021 12:00:00 AM https://support.microsoft.com/help/5008295
THINKP1 KB5007262 Update NT AUTHORITY\SYSTEM 11/22/2021 12:00:00 AM https://support.microsoft.com/help/5007262
THINKP1 KB5007414 Update NT AUTHORITY\SYSTEM 11/13/2021 12:00:00 AM
.Link
Get-HotFix
#>
[cmdletbinding()]
[Alias('ghfr')]
Param([string[]]$Computername = $env:COMPUTERNAME)
Try {
Get-HotFix -ComputerName $Computername -ErrorAction Stop |
Select-Object -Property @{Name = "Computername"; Expression = { $_.CSName } },
HotFixID, Description, InstalledBy, InstalledOn,
@{Name = "Online"; Expression = { $_.Caption } }
}
Catch {
Throw $_
}
} #close Get-Hotfixreport
I'd probably continue to refine the function.
Building a Module
I'll wrap this up with a proof-of-concept. Assuming I know the script files I want to use and the function names I want to assign, I could use a PowerShell script to quickly build a module.
#a proof of concept to convert scripts to a new module
#dot source the conversion functions
. C:\scripts\dev-scripttofunction.ps1
$NewModuleName = "PSMagic"
$Description = "A sample module"
$ParentPath = "C:\work"
$path = New-Item -Name $NewModuleName -Path $ParentPath -ItemType Directory -Force
#create the module structure
"docs", "functions", "en-us", "formats" |
ForEach-Object { New-Item -Path $path -Name $_ -ItemType Directory }
#file data
$data = @"
"Path","Name"
"C:\scripts\SampleScript.ps1","Get-Foo"
"C:\scripts\SampleScript2.ps1","Set-Foo"
"C:\scripts\SampleScript3.ps1","Invoke-Foo"
"C:\scripts\SampleScript4.ps1","Remove-Foo"
"C:\scripts\SampleScript5.ps1","Test-Foo"
"@
$csv = $data | ConvertFrom-Csv
foreach ($item in $csv) {
$out = Join-Path $path\functions "$($item.name).ps1"
$item | Convert-ScriptToFunction | Out-File -FilePath $out
Get-Item $out
} #foreach item
#create the root module
$psm1 = @"
Get-Childitem `$psscriptroot\functions\*.ps1 |
Foreach-Object {
. `$_.FullName
}
"@
$psm1 | Out-File "$path\$newmodulename.psm1"
#create the module manifest
$splat = @{
Path = "$path\$newmodulename.psd1"
RootModule = "$path\$newmodulename.psm1"
ModuleVersion = "0.1.0"
Author = "Jeff Hicks"
Description = $Description
FunctionsToExport = $csv.name
PowerShellVersion = "5.1"
CompatiblePSEditions = "Desktop"
}
New-ModuleManifest @splat
Get-ChildItem $path
Running this script quickly builds my module.
Naturally, there would still be editing and revisions, but this gives me a huge jump start on the process.
Next Steps
I hope some of you will give this code a spin and let me know what you think. Remember, it probably won't generate perfect PowerShell functions. I think I now have enough commands that I might bundle all of this together into a new module. In fact, I can use the tools themselves to build the module. Talk about meta!
2 thoughts on “Converting PowerShell Scripts to Functions”
Comments are closed.