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
  $Values = 1,1,1,2,1,1
  Find-FirstIndex -Values $Values -Predicate { $args[0] -eq 2 }
  # Returns 3
  .EXAMPLE
  $Values = 1,1,1,2,1,1
  ,$Values | 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 }
  )
  $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-Reduce
{
  <#
  .SYNOPSIS
  Functional helper function intended to approximate some of the capabilities of Reduce (as used in languages like JavaScript and F#)
  .PARAMETER InitialValue
  Starting value for reduce. The type of InitialValue will change the operation of Invoke-Reduce.
  .PARAMETER FileInfo
  The operation of combining many FileInfo objects into one object is common enough to deserve its own switch (see examples)
  .EXAMPLE
  1,2,3,4,5 | Invoke-Reduce -Callback { $args[0] + $args[1] } -InitialValue 0

  Compute sum of array of integers
  .EXAMPLE
  "a","b","c" | reduce -Callback { $args[0] + $args[1] } -InitialValue ""

  Concatenate array of strings
  .EXAMPLE
  Get-ChildItem -File | Invoke-Reduce -FileInfo | Show-BarChart

  Combining directory contents into single object and visualize with Show-BarChart - in a single line!
  #>

  [CmdletBinding()]
  [Alias('reduce')]
  Param(
    [Parameter(Mandatory=$true, Position=0, ValueFromPipeline=$true)]
    [Array] $Items,
    [ScriptBlock] $Callback = { $args[0] },
    [Switch] $FileInfo,
    $InitialValue = @{}
  )
  Begin {
    $Result = $InitialValue
    if ($FileInfo) {
      $Callback = { Param($Acc, $Item) $Acc[$Item.Name] = $Item.Length }
    }
  }
  Process {
    $Items | ForEach-Object {
      if ($InitialValue -is [Int] -or $InitialValue -is [String] -or $InitialValue -is [Bool] -or $InitialValue -is [Array]) {
        $Result = & $Callback $Result $_
      } else {
        & $Callback $Result $_
      }
    }
  }
  End {
    $Result
  }
}
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
  )
  if ($First) {
    $At = 0
  } elseif ($Last) {
    $At = $Value.Length - 1
  }
  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
  }
}