src/core.ps1
function ConvertTo-PowershellSyntax { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'DataVariableName')] [OutputType([String])] Param( [Parameter(Mandatory=$true, Position=0, ValueFromPipeline=$true)] [String] $Value, [String] $DataVariableName = 'Data' ) Write-Output $Value | ForEach-Object { $_ -replace '(?<!(}}[\w\s]*))(?<!{{#[\w\s]*)\s*}}', ')' } | ForEach-Object { $_ -replace '{{(?!#)\s*', "`$(`$$DataVariableName." } } function Invoke-FireEvent { [CmdletBinding()] [Alias('trigger')] Param( [Parameter(Mandatory=$true, Position=0, ValueFromPipeline=$true)] [String] $Name, [PSObject] $Data ) New-Event -SourceIdentifier $Name -MessageData $Data | Out-Null } function Find-FirstIndex { <# .SYNOPSIS Helper function to return index of first array item that returns true for a given predicate (default predicate returns true if value is $true) .EXAMPLE Find-FirstIndex -Values $false,$true,$false # Returns 1 .EXAMPLE Find-FirstIndex -Values 1,1,1,2,1,1 -Predicate { $args[0] -eq 2 } # Returns 3 .EXAMPLE 1,1,1,2,1,1 | Find-FirstIndex -Predicate { $args[0] -eq 2 } # Returns 3 Note the use of the unary comma operator .EXAMPLE 1,1,1,2,1,1 | Find-FirstIndex -Predicate { $args[0] -eq 2 } # Returns 3 #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Predicate')] [CmdletBinding()] [OutputType([Int])] Param( [Parameter(Mandatory=$true, Position=0, ValueFromPipeline=$true)] [Array] $Values, [ScriptBlock] $Predicate = { $args[0] -eq $true } ) End { if ($Input.Length -gt 0) { $Values = $Input } $Indexes = @($Values | ForEach-Object { if (& $Predicate $_) { [Array]::IndexOf($Values, $_) } }) $Indexes.Where({ $_ }, 'First') } } function Invoke-InsertString { [CmdletBinding()] [Alias('insert')] [OutputType([String])] Param( [Parameter(Mandatory=$true, ValueFromPipeline=$true)] [String] $Value, [Parameter(Mandatory=$true)] [String] $To, [Parameter(Mandatory=$true)] [Int] $At ) if ($At -le $To.Length -and $At -ge 0) { $To.Substring(0, $At) + $Value + $To.Substring($At, $To.length - $At) } else { $To } } function Invoke-ListenTo { <# .SYNOPSIS Create an event listener ("subscriber"). Basically a wrapper for Register-EngineEvent. .PARAMETER Path Path to file or folder that will be watched for changes .PARAMETER Exit Set event source identifier to Powershell.Exiting .PARAMETER Idle Set event source identifier to Powershell.OnIdle. Warning: It is not advised to write to console in callback of -Idle listeners. .EXAMPLE { Write-Color 'Event triggered' -Red } | on 'SomeEvent' Expressive yet terse syntax for easy event-driven design. .EXAMPLE Invoke-ListenTo -Name 'SomeEvent' -Callback { Write-Color "Event: $($Event.SourceIdentifier)" } Callbacks hae access to automatic variables such as $Event .EXAMPLE $Callback | on 'SomeEvent' -Once Create a listener that automatically destroys itself after one event is triggered .EXAMPLE $Callback = { $Data = $args[1] "Name ==> $($Data.Name)" | Write-Color -Magenta "Event ==> $($Data.ChangeType)" | Write-Color -Green "Fullpath ==> $($Data.FullPath)" | Write-Color -Cyan } $Callback | listenTo -Path . Watch files and folders for changes (create, edit, rename, delete) .EXAMPLE # Declare a value for boot $boot = 42 # Create a callback $Callback = { $Data = $Event.MessageData say "$($Data.Name) was changed from $($Data.OldValue), to $($Data.Value)" } # Start the variable listener $Callback | listenTo 'boot' -Variable # Change the value of boot and have your computer tell you what changed $boot = 43 .EXAMPLE { 'EVENT - EXIT' | Out-File ~\dev\MyEvents.txt -Append } | on -Exit Execute code when you exit the powershell terminal #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars', '', Scope='Function')] [CmdletBinding(DefaultParameterSetName = 'custom')] [Alias('on', 'listenTo')] Param( [Parameter(ParameterSetName='custom', Position=0)] [Parameter(ParameterSetName='variable', Position=0)] [String] $Name, [Parameter(ParameterSetName='custom')] [Parameter(ParameterSetName='variable')] [Switch] $Once, [Parameter(ParameterSetName='custom')] [Switch] $Exit, [Parameter(ParameterSetName='custom')] [Switch] $Idle, [Parameter(ParameterSetName='custom', Mandatory=$true, ValueFromPipeline=$true)] [Parameter(ParameterSetName='variable', Mandatory=$true, ValueFromPipeline=$true)] [Parameter(ParameterSetName='filesystem', Mandatory=$true, ValueFromPipeline=$true)] [scriptblock] $Callback, [Parameter(ParameterSetName='custom')] [Parameter(ParameterSetName='filesystem')] [Switch] $Forward, [Parameter(ParameterSetName='filesystem', Mandatory=$true)] [String] $Path, [Parameter(ParameterSetName='filesystem')] [Switch] $IncludeSubDirectories, [Parameter(ParameterSetName='filesystem')] [Switch] $Absolute, [Parameter(ParameterSetName='variable')] [Switch] $Variable ) $Action = $Callback if ($Path.Length -gt 0) { # file system watcher events if (-not $Absolute) { $Path = Join-Path (Get-Location) $Path -Resolve } Write-Verbose "==> Creating file system watcher object for `"$Path`"" $Watcher = New-Object System.IO.FileSystemWatcher $Watcher.Path = $Path $Watcher.Filter = '*.*' $Watcher.EnableRaisingEvents = $true $Watcher.IncludeSubdirectories = $IncludeSubDirectories Write-Verbose '==> Creating file system watcher events' 'Created','Changed','Deleted','Renamed' | ForEach-Object { Register-ObjectEvent $Watcher $_ -Action $Action } } elseif ($Variable) { # variable change events $VariableNamespace = New-Guid | Select-Object -ExpandProperty Guid | ForEach-Object { $_ -replace "-", "_" } $Global:__NameVariableValue = $Name $Global:__VariableChangeEventLabel = "VariableChangeEvent_$VariableNamespace" $Global:__NameVariableLabel = "Name_$VariableNamespace" $Global:__OldValueVariableLabel = "OldValue_$VariableNamespace" New-Variable -Name $Global:__NameVariableLabel -Value $Name -Scope Global Write-Verbose "Variable name = $Global:__NameVariableValue" if ((Get-Variable | Select-Object -ExpandProperty Name) -contains $Name) { New-Variable -Name $Global:__OldValueVariableLabel -Value (Get-Variable -Name $Name -ValueOnly) -Scope Global Write-Verbose "Initial value = $(Get-Variable -Name $Name -ValueOnly)" } else { Write-Error "Variable not found in current scope ==> `"$Name`"" } $UpdateValue = { $Name = Get-Variable -Name $Global:__NameVariableLabel -Scope Global -ValueOnly $NewValue = Get-Variable -Name $Global:__NameVariableValue -Scope Global -ValueOnly $OldValue = Get-Variable -Name $Global:__OldValueVariableLabel -Scope Global -ValueOnly if (-not (Test-Equal $NewValue $OldValue)) { Invoke-FireEvent $Global:__VariableChangeEventLabel -Data @{ Name = $Name; Value = $NewValue; OldValue = $OldValue } Set-Variable -Name $Global:__OldValueVariableLabel -Value $NewValue -Scope Global } } $UpdateValue | Invoke-ListenTo -Idle | Out-Null $Action | Invoke-ListenTo $Global:__VariableChangeEventLabel | Out-Null } else { # custom and Powershell engine events if ($Exit) { $SourceIdentifier = ([System.Management.Automation.PsEngineEvent]::Exiting) } elseif ($Idle) { $SourceIdentifier = ([System.Management.Automation.PsEngineEvent]::OnIdle) } else { $SourceIdentifier = $Name } if ($Once) { Write-Verbose "==> Creating one-time event listener for $SourceIdentifier event" $_Event = Register-EngineEvent -SourceIdentifier $SourceIdentifier -MaxTriggerCount 1 -Action $Action -Forward:$Forward } else { Write-Verbose "==> Creating event listener for `"$SourceIdentifier`" event" $_Event = Register-EngineEvent -SourceIdentifier $SourceIdentifier -Action $Action -Forward:$Forward } $_Event } } function Invoke-Once { <# .SYNOPSIS Higher-order function that takes a function and returns a function that can only be executed a certain number of times .PARAMETER Times Number of times passed function can be called (default is 1, hence the name - Once) .EXAMPLE $Function:test = Invoke-Once { 'Should only see this once' | Write-Color -Red } 1..10 | ForEach-Object { test } .EXAMPLE $Function:greet = Invoke-Once { "Hello $($args[0])" | Write-Color -Red } greet 'World' # no subsequent greet functions are executed greet 'Jim' greet 'Bob' Functions returned by Invoke-Once can accept arguments #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Scope='Function')] [CmdletBinding()] Param( [Parameter(Mandatory=$true, Position=0)] [ScriptBlock] $Function, [Int] $Times = 1 ) { if ($Script:Count -lt $Times) { & $Function @Args $Script:Count++ } }.GetNewClosure() } function Invoke-StopListen { <# .SYNOPSIS Remove event subscriber(s) .EXAMPLE $Callback | on 'SomeEvent' 'SomeEvent' | Invoke-StopListen Remove events using the event "source identifier" (Name) .EXAMPLE $Callback | on -Name 'Namespace:foo' $Callback | on -Name 'Namespace:bar' 'Namespace:' | Invoke-StopListen Remove multiple events using an event namespace .EXAMPLE $Listener = $Callback | on 'SomeEvent' Invoke-StopListen -EventData $Listener Selectively remove a single event by passing its event data #> [CmdletBinding()] Param( [Parameter(ValueFromPipeline=$true)] [String] $Name, [PSObject] $EventData ) if ($EventData) { Unregister-Event -SubscriptionId $EventData.Id } else { if ($Name) { $Events = Get-EventSubscriber | Where-Object { $_.SourceIdentifier -match "^$Name" } } else { $Events = Get-EventSubscriber } $Events | ForEach-Object { Unregister-Event -SubscriptionId $_.SubscriptionId } } } function Join-StringsWithGrammar { <# .SYNOPSIS Helper function that creates a string out of a list that properly employs commands and "and" .EXAMPLE Join-StringsWithGrammar @('a', 'b', 'c') Returns "a, b, and c" #> [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'Delimiter')] [CmdletBinding()] [OutputType([String])] Param( [Parameter(Mandatory=$true, Position=0, ValueFromPipeline=$true)] [String[]] $Items, [String] $Delimiter = ',' ) Begin { function Join-StringArray { Param( [Parameter(Mandatory=$true, Position=0)] [AllowNull()] [AllowEmptyCollection()] [String[]] $Items ) $NumberOfItems = $Items.Length if ($NumberOfItems -gt 0) { switch ($NumberOfItems) { 1 { $Items -join '' } 2 { $Items -join ' and ' } Default { @( ($Items[0..($NumberOfItems - 2)] -join ', ') + ',' 'and' $Items[$NumberOfItems - 1] ) -join ' ' } } } } Join-StringArray $Items } End { Join-StringArray $Input } } function New-Template { <# .SYNOPSIS Create render function that interpolates passed object values .PARAMETER Data Pass template data to New-Template when using New-Template within pipe chain (see examples) .EXAMPLE $Function:render = New-Template '<div>Hello {{ name }}!</div>' render @{ name = 'World' } # '<div>Hello World!</div>' Use mustache template syntax! Just like Handlebars.js! .EXAMPLE $Function:render = 'hello {{ name }}' | New-Template @{ name = 'world' } | render # 'hello world' New-Template supports idiomatic powershell pipeline syntax .EXAMPLE $Function:render = New-Template '<div>Hello $($Data.name)!</div>' render @{ name = 'World' } # '<div>Hello World!</div>' Or stick to plain Powershell syntax...this is a little more verbose ($Data is required) .EXAMPLE $title = New-Template -Template '<h1>{{ text }}</h1>' -DefaultValues @{ text = 'Default' } & $title # '<h1>Default</h1>' & $title @{ text = 'Hello World' } # '<h1>Hello World</h1>' Provide default values for your templates! .EXAMPLE $div = New-Template -Template '<div>{{ text }}</div>' $section = New-Template "<section> <h1>{{ title }}</h1> $(& $div @{ text = 'Hello World!' }) </section>" Templates can even be nested! .EXAMPLE '{{#green Hello}} {{ name }}' | tpl -Data @{ name = 'World' } | Write-Color Use -Data parameter cause template to return formatted string instead of template function #> [CmdletBinding(DefaultParameterSetName='template')] [Alias('tpl')] [OutputType([ScriptBlock], ParameterSetName='template')] [OutputType([String], ParameterSetName='inline')] Param( [Parameter(Mandatory=$true, Position=0, ValueFromPipeline=$true)] [String] $Template, [Parameter(ParameterSetName='inline')] [PSObject] $Data, [Parameter(ValueFromPipelineByPropertyName=$true)] [PSObject] $DefaultValues ) $Script:__template = $Template # This line is super important $Script:__defaults = $DefaultValues # This line is also super important $Renderer = { Param( [Parameter(Position=0, ValueFromPipeline=$true)] [PSObject] $Data, [Switch] $PassThru ) if ($PassThru) { $StringToRender = $__template } else { $DataVariableName = Get-Variable -Name Data | ForEach-Object { $_.Name } $StringToRender = $__template | ConvertTo-PowershellSyntax -DataVariableName $DataVariableName } if (-not $Data) { $Data = $__defaults } $StringToRender = $StringToRender -replace '"', '`"' $ImportDataVariable = "`$Data = '$(ConvertTo-Json ([System.Management.Automation.PSObject]$Data))' | ConvertFrom-Json" $Powershell = [Powershell]::Create() [Void]$Powershell.AddScript($ImportDataVariable).AddScript("Write-Output `"$StringToRender`"") $Powershell.Invoke() [Void]$Powershell.Dispose() } if ($Data) { & $Renderer $Data } else { $Renderer } } function Remove-Character { [CmdletBinding()] [Alias('remove')] [OutputType([String])] Param( [Parameter(Mandatory=$true, ValueFromPipeline=$true)] [String] $Value, [Int] $At, [Switch] $First, [Switch] $Last ) $At = if ($First) { 0 } elseif ($Last) { $Value.Length - 1 } else { $At } if ($At -lt $Value.Length -and $At -ge 0) { $Value.Substring(0, $At) + $Value.Substring($At + 1, $Value.length - $At - 1) } else { $Value } } function Test-Equal { <# .SYNOPSIS Helper function meant to provide a more robust equality check (beyond just integers and strings) .EXAMPLE Test-Equal 42 43 # False Test-Equal 0 0 # True Also works with booleans, strings, objects, and arrays .EXAMPLE $a = @{a = 1; b = 2; c = 3} $b = @{a = 1; b = 2; c = 3} Test-Equal $a $b # True #> [CmdletBinding()] [Alias('equal')] [OutputType([Bool])] Param( [Parameter(Position=0, ValueFromPipeline=$true)] $Left, [Parameter(Position=1)] $Right ) if ($null -ne $Left -and $null -ne $Right) { try { $Type = $Left.GetType().Name switch -Wildcard ($Type) { 'String' { $Left -eq $Right } 'Int*' { $Left -eq $Right } 'Double' { $Left -eq $Right } 'Object*' { $Every = { $args[0] -and $args[1] } $Index = 0 $Left | ForEach-Object { Test-Equal $_ $Right[$Index]; $Index++ } | Invoke-Reduce -Callback $Every -InitialValue $true } 'PSCustomObject' { $Every = { $args[0] -and $args[1] } $LeftKeys = $Left.psobject.properties | Select-Object -ExpandProperty Name $RightKeys = $Right.psobject.properties | Select-Object -ExpandProperty Name $LeftValues = $Left.psobject.properties | Select-Object -ExpandProperty Value $RightValues = $Right.psobject.properties | Select-Object -ExpandProperty Value $Index = 0 $HasSameKeys = $LeftKeys | ForEach-Object { Test-Equal $_ $RightKeys[$Index]; $Index++ } | Invoke-Reduce -Callback $Every -InitialValue $true $Index = 0 $HasSameValues = $LeftValues | ForEach-Object { Test-Equal $_ $RightValues[$Index]; $Index++ } | Invoke-Reduce -Callback $Every -InitialValue $true $HasSameKeys -and $HasSameValues } 'Hashtable' { $Every = { $args[0] -and $args[1] } $Index = 0 $RightKeys = $Right.GetEnumerator() | Select-Object -ExpandProperty Name $HasSameKeys = $Left.GetEnumerator() | ForEach-Object { Test-Equal $_.Name $RightKeys[$Index]; $Index++ } | Invoke-Reduce -Callback $Every -InitialValue $true $Index = 0 $RightValues = $Right.GetEnumerator() | Select-Object -ExpandProperty Value $HasSameValues = $Left.GetEnumerator() | ForEach-Object { Test-Equal $_.Value $RightValues[$Index]; $Index++ } | Invoke-Reduce -Callback $Every -InitialValue $true $HasSameKeys -and $HasSameValues } Default { $Left -eq $Right } } } catch { Write-Verbose '==> Failed to match type of -Left item' $false } } else { Write-Verbose '==> One or both items are $null' $Left -eq $Right } } |