private/Core.ps1

${Script:/} = [IO.Path]::DirectorySeparatorChar
$Script:esc = [char]27 # for PS <7
$Script:undoStack = [Collections.Stack]::new()
$Script:redoStack = [Collections.Stack]::new()

$Script:recent = @{}
$Script:logger = { Write-Verbose ($args[0] | ConvertTo-Json) }

function DefaultIfEmpty([scriptblock] $default) {
  Begin { $any = $false }
  Process { if ($_) { $any = $true; $_ } }
  End { if (!$any) { &$default } }
}

filter Truncate([int] $maxLength = $cde.MaxMenuLength) {
  if (!$_ -or $_.Length -le $maxLength) { return $_ }

  if ($_.StartsWith($esc)) {
    TruncatedColoured $_ $maxLength
  }
  else {
    $_.Substring(0, $maxLength - 1) + [char]0x2026 # ellipsis
  }
}

function TruncatedColoured([string]$string, $maxLen) {
  $textStart = $string.IndexOf('m') + 1
  $startFinalEscapeSequence = $string.LastIndexOf($esc)
  $textEnd = if ($startFinalEscapeSequence -gt $textStart) { $startFinalEscapeSequence } else { $string.Length - 1 }
  $text = $string.Substring($textStart, $textEnd - $textStart)

  if ($text.Length -le $maxLen) {
    $string
  }
  else {
    $string.Substring(0, $textStart) + ($text | Truncate) + "$esc[0m"
  }
}

filter IsRootedOrRelative {
  ($_ | IsRooted) -or ($_ | IsRelative)
}

filter IsRooted {
  [System.IO.Path]::IsPathRooted($_) -or
  $_ -match '~(/|\\)*' # also consider the path rooted if it's relative to home
}

filter IsRelative {
  $_ -match '^+\.' # e.g. starts with ./, ../, ...
}

filter IsDescendedFrom($maybeAncestor) {
  ($_ | Get-Ancestors).Path -contains ($maybeAncestor | Resolve-Path)
}

filter NormaliseAndEscape {
  $_ | Normalise | Escape
}

filter Normalise {
  $_ -replace '/|\\', ${/}
}

filter Escape {
  [regex]::Escape($_)
}

filter RemoveSurroundingQuotes {
  ($_ -replace "^'", '') -replace "'$", ''
}

filter SurroundAndTerminate($trailChar) {
  if ($_ -notmatch ' |\[|\]') { "$_$trailChar" }
  else { "'$_$trailChar'" }
}

filter RemoveTrailingSeparator {
  if ($_ -match '[/\\].*?([/\\])$') { $_.TrimEnd('/', '\') } else { $_ }
}

filter EscapeWildcards {
  [WildcardPattern]::Escape($_)
}

function GetBestIndex([array]$array, [string]$namepart) {
  (
    $items = $array -eq ($namepart | Normalise | RemoveTrailingSeparator) # full path match
  ) -or (
    $items = $array.Where{ ($_ | Split-Path -Leaf) -eq $namepart } # full leaf match
  ) -or (
    $items = $array.Where{ ($_ | Split-Path -Leaf) -Match "^$($namepart | NormaliseAndEscape)" } # leaf starts with
  ) -or (
    $items = $array -match ($namepart | NormaliseAndEscape) # anything...
  ) | Out-Null

  [array]::indexOf($array, ($items | select -First 1))
}

function IndexedComplete([bool] $IndexedCompletion = $cde.IndexedCompletion) {
  Begin { $items = @() }
  Process { $items += $_ }
  End {
    $items | % {

      $completionText =
      if ($IndexedCompletion -and @($items).Count -gt 1) { "$($_.n)" }
      else { $_.path | SurroundAndTerminate }

      $listItemText = "$($_.n). $($_.name)"
      $tooltip =
      if ($_.name -ne $_.path) { "$($_.n). $($_.path)" }
      else { "$($_.n). ($($_.path))" }

      [Management.Automation.CompletionResult]::new(
        $completionText,
        $listItemText,
        'ParameterValue',
        $tooltip
      )
    }
  }
}

function IndexPaths(
  [array]$xs,
  $rootLabel = 'root' # this on happens on *nix
) {
  $xs = $xs -ne '' | Select -Unique
  if (!$xs) { return @() }

  $i = 0
  $xs.ForEach{
    [IndexedPath] @{
      n    = ++$i
      Name = $_ | Split-Path -Leaf | DefaultIfEmpty { $rootLabel }
      Path = $_
    } }
}

function RegisterCompletions([string[]] $commands, $param, $target) {
  Register-ArgumentCompleter -CommandName $commands -ParameterName $param -ScriptBlock $target
}

function InvokeWithRecentLock([scriptblock] $action) {
  $hasMutex = $false

  try {
    try {
      $hasMutex = $cde.mutex.WaitOne(1000)
    }
    catch [Threading.AbandonedMutexException] {
      $hasMutex = $true
    }

    if (!$hasMutex) {
      throw "Timed out waiting to update recent directories file '$($cde.RECENT_DIRS_FILE)'."
    }

    &$action
  }
  finally {
    if ($hasMutex) { $cde.mutex.ReleaseMutex() }
  }
}

function ThrowInvalidRecentStore($row, [int] $rowNumber, [string] $reason) {
  $message = "Recent directories file '$($cde.RECENT_DIRS_FILE)' row $rowNumber $reason."
  throw [Management.Automation.ErrorRecord]::new(
    [IO.InvalidDataException]::new($message),
    'InvalidRecentStore',
    [Management.Automation.ErrorCategory]::InvalidData,
    $row
  )
}

function ReadRecentStore() {
  $store = @{}

  if ($cde.RECENT_DIRS_FILE -and (Test-Path -LiteralPath $cde.RECENT_DIRS_FILE)) {
    $header = Get-Content -LiteralPath $cde.RECENT_DIRS_FILE -TotalCount 1 -ErrorAction Stop
    if ([string]::IsNullOrWhiteSpace($header)) {
      ThrowInvalidRecentStore $header 1 'has an empty header'
    }

    $schema = @($header, 'x,x,x,x') | ConvertFrom-Csv -ErrorAction Stop | select -First 1
    $columns = @($schema.PSObject.Properties.Name)
    foreach ($requiredColumn in 'Path', 'LastEntered', 'EnterCount', 'Favour') {
      if ($columns -notcontains $requiredColumn) {
        ThrowInvalidRecentStore $header 1 "is missing required column '$requiredColumn'"
      }
    }

    $rowNumber = 1
    foreach ($row in @(Import-Csv -LiteralPath $cde.RECENT_DIRS_FILE -ErrorAction Stop)) {
      $rowNumber++

      if ([string]::IsNullOrWhiteSpace($row.Path)) {
        ThrowInvalidRecentStore $row $rowNumber 'has an empty Path'
      }

      $lastEntered = [uint64]0
      if (![uint64]::TryParse($row.LastEntered, [ref]$lastEntered)) {
        ThrowInvalidRecentStore $row $rowNumber "has invalid LastEntered value '$($row.LastEntered)'"
      }

      $enterCount = [uint32]0
      if (![uint32]::TryParse($row.EnterCount, [ref]$enterCount)) {
        ThrowInvalidRecentStore $row $rowNumber "has invalid EnterCount value '$($row.EnterCount)'"
      }

      $favour = $false
      if (![bool]::TryParse($row.Favour, [ref]$favour)) {
        ThrowInvalidRecentStore $row $rowNumber "has invalid Favour value '$($row.Favour)'"
      }

      if ($store.ContainsKey($row.Path)) {
        ThrowInvalidRecentStore $row $rowNumber "duplicates path '$($row.Path)'"
      }

      $store[$row.Path] = [RecentDir]@{
        Path = $row.Path
        LastEntered = $lastEntered
        EnterCount = $enterCount
        Favour = $favour
      }
    }
  }

  $store
}

function GetRecentStoreHash() {
  if ($cde.RECENT_DIRS_FILE -and (Test-Path -LiteralPath $cde.RECENT_DIRS_FILE)) {
    (Get-FileHash -LiteralPath $cde.RECENT_DIRS_FILE).Hash.ToString()
  }
  else { $null }
}

function ReadRecentSnapshot() {
  $hashBefore = GetRecentStoreHash
  $store = ReadRecentStore
  $hashAfter = GetRecentStoreHash

  if ($hashBefore -ne $hashAfter) {
    throw [IO.IOException]::new("Recent directories file '$($cde.RECENT_DIRS_FILE)' changed while it was being read.")
  }

  [pscustomobject]@{ Store = $store; Hash = $hashAfter }
}

function SetRecentState([hashtable] $store, [AllowNull()] [string] $recentHash) {
  $Script:recent = $store
  $cde.recentHash = $recentHash
}

function InstallRecentStoreFile([string] $tempFile, [string] $backupFile) {
  if (Test-Path -LiteralPath $cde.RECENT_DIRS_FILE) {
    [IO.File]::Replace($tempFile, $cde.RECENT_DIRS_FILE, $backupFile)
  }
  else {
    [IO.File]::Move($tempFile, $cde.RECENT_DIRS_FILE)
  }
}

function WriteRecentStore([hashtable] $store) {
  if (!$cde.RECENT_DIRS_FILE) { return }

  if (!$store.Count) {
    if (Test-Path -LiteralPath $cde.RECENT_DIRS_FILE) {
      Remove-Item -LiteralPath $cde.RECENT_DIRS_FILE -ErrorAction Stop
    }
    return
  }

  $parent = [IO.Path]::GetDirectoryName($cde.RECENT_DIRS_FILE)
  $leaf = [IO.Path]::GetFileName($cde.RECENT_DIRS_FILE)
  $tempFile = Join-Path $parent ".$leaf.$PID.$([guid]::NewGuid().ToString('N')).tmp"
  $backupFile = "$tempFile.backup"

  try {
    $store.Values |
    Sort-Object Path |
    Export-Csv -NoTypeInformation -LiteralPath $tempFile

    InstallRecentStoreFile $tempFile $backupFile
  }
  finally {
    Remove-Item -LiteralPath $tempFile, $backupFile -ErrorAction Ignore
  }
}

function OpenRecentStore() {
  if (!$cde.RECENT_DIRS_FILE) { return }

  InvokeWithRecentLock {
    if (Test-Path -LiteralPath $cde.RECENT_DIRS_FILE) {
      $snapshot = ReadRecentSnapshot
      $store = $snapshot.Store
      $previousCount = $store.Count
      TrimRecent $store
      if ($store.Count -ne $previousCount) { WriteRecentStore $store }
      $storeHash = if ($store.Count -ne $previousCount) { GetRecentStoreHash } else { $snapshot.Hash }
      SetRecentState $store $storeHash
    }
    else {
      TrimRecent
      WriteRecentStore $recent
      SetRecentState $recent (GetRecentStoreHash)
    }
  }
}

function RefreshRecent() {
  if (!$cde.RECENT_DIRS_FILE) { return }

  InvokeWithRecentLock {
    $currentHash = GetRecentStoreHash

    if ($currentHash -ne $cde.recentHash) {
      WriteLog ($currentHash, $cde.recentHash)
      $snapshot = ReadRecentSnapshot
      SetRecentState $snapshot.Store $snapshot.Hash
    }
  }
}

function RecentsByTermWithSort([int] $first, [string[]] $terms, [scriptblock] $sort) {
  function MatchesTerms([string] $path) {
    function MatchPath($terms, $idx = 0) {
      $fst, $rst = $terms
      if (!$fst) { return $true }
      $nextIdx = $path.IndexOf($fst, $idx, [StringComparison]::CurrentCultureIgnoreCase)
      return ($nextIdx -ge 0) -and (MatchPath $rst ($nextIdx + $fst.Length))
    }
    function MatchLeaf($term) { (Split-Path -Leaf $path) -match $term }

    if (!$terms) { return $true }
    (MatchPath ($terms | Normalise)) -and (MatchLeaf ($terms[-1] | NormaliseAndEscape))
  }

  RefreshRecent
  $recent.Values.Where( { ($_.Path -ne ($pwd | RemoveTrailingSeparator)) -and (MatchesTerms $_.Path) }) |
  Sort-Object $sort -Descending |
  select -First $first -Expand Path
}

function GetFrecent([int] $first, [string[]] $terms) {
  function FrecencyFactor([uint64] $lastEntered) {
    $now = [System.DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()

    if ($lastEntered -gt ($now - 1000 * 60 * 60)) { 4 } # past hour
    elseif ($lastEntered -gt ($now - 1000 * 60 * 60 * 24)) { 2 } # past day
    elseif ($lastEntered -gt ($now - 1000 * 60 * 60 * 24 * 7)) { 1 / 2 } # past week
    else { 1 / 4 }
  }

  function FavourFactor([bool] $isFavoured) {
    ([int]$isFavoured * 1000) + 1
  }

  RecentsByTermWithSort $first $terms {
    $_.EnterCount * (FrecencyFactor $_.LastEntered) * (FavourFactor $_.Favour)
  }
}

function GetRecent([int] $first, [string[]] $terms) {
  RecentsByTermWithSort $first $terms { $_.LastEntered }
}

function UpdateRecent($path, $favour = $false) {
  $path = $path | RemoveTrailingSeparator
  if ($path -in $cde.RECENT_DIRS_EXCLUDE) { return }

  $lastEntered = [System.DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
  InvokeRecentStoreOperation {
    param($store)

    $entry = if (($current = $store[$path])) {
      $current
    }
    else {
      [RecentDir] @{ Path = $path; EnterCount = $favour }
    }

    if (!$favour) {
      $entry.LastEntered = $lastEntered
      $entry.EnterCount++
    }
    else {
      $entry.Favour = $true
    }

    $store[$path] = $entry
  }
}

function Unfavour([RecentDir] $dir) {
  InvokeRecentStoreOperation {
    param($store)

    if (($entry = $store[$dir.Path])) {
      if (!$entry.LastEntered) { $store.Remove($dir.Path) | Out-Null }
      else { $entry.Favour = $false }
    }
  }
}

function RemoveRecent([string[]] $dirs) {
  InvokeRecentStoreOperation {
    param($store)
    $dirs | % { $store.Remove($_) } | Out-Null
  }
}

function TrimRecent([hashtable] $store = $recent) {
  $bookmarkCount = @($store.Values.Where{ $_.Favour }).Count
  $ordinaryCapacity = [Math]::Max(0, [int]$cde.MaxRecentDirs - $bookmarkCount)
  $ordinary = @($store.Values.Where{ !$_.Favour })

  if ($ordinary.Count -gt $ordinaryCapacity) {
    $ordinary |
      Sort-Object @{ Expression = 'LastEntered'; Descending = $true }, @{ Expression = 'Path'; Descending = $false } |
      select -Skip $ordinaryCapacity -Expand Path |
      % { $store.Remove($_) } |
      Out-Null
  }
}

function InvokeRecentStoreOperation([scriptblock] $operation) {
  if (!$cde.RECENT_DIRS_FILE) {
    &$operation $recent
    TrimRecent
    return
  }

  InvokeWithRecentLock {
    $store = (ReadRecentSnapshot).Store
    &$operation $store
    TrimRecent $store
    WriteRecentStore $store
    SetRecentState $store (GetRecentStoreHash)
  }
}

function WriteLog($message) {
  $m = if ($message) { $message } else { '[null]' }
  &$logger $m
}