src/PSMutation.Glob.ps1
|
# Patterns in `mutate` and `tests` (#265), and pairing a file with its tests by name. # # Expanded ONCE, at start-up, into the plain file lists everything else already reads -- so the # sandbox plan, the coverage mapping, a recheck and -ChangedFile see literal paths exactly as # before, and the run prints what each pattern became. A pattern is a promise about files, and one # that matches nothing is refused rather than read as an empty selection. # # The rules are PSComplexity's config `exclude` (Fortigi/PSComplexity#164), so the two modules read # a pattern the same way: `*` and `?` stay inside one path segment, `**` spans any number of # segments, and a pattern also matches everything below what it names. Matched without regard to # case, as every other path comparison here is. function Test-PSMutationGlob { # Whether a config entry is a pattern rather than a path. [OutputType([bool])] [CmdletBinding()] param([Parameter(Mandatory)] [AllowEmptyString()] [string]$Value) return $Value.Contains('*') -or $Value.Contains('?') } function Get-PSMutationGlobSegment { # A pattern's path segments, written with either separator, without empty or `.` segments. [OutputType([string[]])] [CmdletBinding()] param([Parameter(Mandatory)] [string]$Pattern) return [string[]]@($Pattern -split '[\\/]' | Where-Object { $_ -and $_ -ne '.' }) } function ConvertTo-PSMutationGlobRegex { # One pattern as a regular expression over a path relative to the source root, `/`-separated. [OutputType([regex])] [CmdletBinding()] param([Parameter(Mandatory)] [string]$Pattern) $segments = @(Get-PSMutationGlobSegment -Pattern $Pattern) $body = '' for ($i = 0; $i -lt $segments.Count; $i++) { $last = $i -eq $segments.Count - 1 if ($segments[$i] -eq '**') { $body += $last ? '.*' : '(?:[^/]+/)*' continue } $body += [regex]::Escape($segments[$i]).Replace('\*', '[^/]*').Replace('\?', '[^/]') if (-not $last) { $body += '/' } } $options = [System.Text.RegularExpressions.RegexOptions]::CultureInvariant -bor [System.Text.RegularExpressions.RegexOptions]::IgnoreCase return [regex]::new("^$body(?:/.*)?$", $options) } function Expand-PSMutationGlob { # The files under Root a pattern matches, relative to Root with `/`, sorted. # # Walked from the pattern's literal prefix -- `src/**/*.ps1` reads src/ and nothing else -- so a # pattern costs the tree it names rather than the whole repository. # # Comma-wrapped so none stays an empty array; both types declared for that wrap. [OutputType([string[]], [object[]])] [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Pattern, [Parameter(Mandatory)] [string]$Root ) $regex = ConvertTo-PSMutationGlobRegex -Pattern $Pattern $found = foreach ($start in (Get-PSMutationGlobStart -Pattern $Pattern -Root $Root)) { foreach ($f in [System.IO.Directory]::EnumerateFiles($start, '*', [System.IO.SearchOption]::AllDirectories)) { $relative = [System.IO.Path]::GetRelativePath($Root, $f).Replace('\', '/') if ($regex.IsMatch($relative)) { $relative } } } return , [string[]]@($found | Sort-Object -Unique) } function Get-PSMutationGlobStart { # The directories a pattern's literal prefix names -- `src/sub` for `src/sub/**/*.ps1` -- # found without regard to case, as the rest of the pattern is matched, so a pattern selects the # same files on every platform. Two directories differing only in case are both walked. [OutputType([string[]])] [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Pattern, [Parameter(Mandatory)] [string]$Root ) $dirs = @($Root) foreach ($segment in (Get-PSMutationGlobSegment -Pattern $Pattern)) { if (Test-PSMutationGlob -Value $segment) { break } $dirs = @(foreach ($d in $dirs) { [System.IO.Directory]::EnumerateDirectories($d) | Where-Object { [System.IO.Path]::GetFileName($_) -eq $segment } }) } return [string[]]$dirs } function Expand-PSMutationPathList { # A config list with every pattern replaced by the files it matches: [pscustomobject]@{ Files; # Faults; Expanded }. A path is kept as written, so the checks that already refuse a missing or # repeated file still answer for it. A file a pattern adds that the list already holds is not # added again; Expanded says what each pattern became, for the start-up lines. [OutputType([pscustomobject])] [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Entry, [Parameter(Mandatory)] [string]$Root, # How the list is named in a fault: 'mutate', or "tests['src/a.ps1']". [Parameter(Mandatory)] [string]$Key ) $files = [System.Collections.Generic.List[string]]::new() $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $faults = [System.Collections.Generic.List[string]]::new() $expanded = [System.Collections.Generic.List[object]]::new() # Paths first, so a file both listed and matched keeps the place its explicit entry gives it. foreach ($e in $Entry) { if ($e -is [string] -and (Test-PSMutationGlob -Value $e)) { continue } $files.Add($e) [void]$seen.Add((ConvertTo-PSMutationRootRelative -Path ([string]$e) -Root $Root)) } foreach ($e in $Entry) { if (-not ($e -is [string] -and (Test-PSMutationGlob -Value $e))) { continue } # Assigned, not wrapped: the comma-wrapped return would nest inside @(). $matched = Expand-PSMutationGlob -Pattern $e -Root $Root if ($matched.Count -eq 0) { $faults.Add("Config key '$Key' has the pattern '$e', which matches no file under $Root") continue } $expanded.Add([pscustomobject]@{ Pattern = $e; Files = $matched }) foreach ($m in $matched) { if ($seen.Add($m)) { $files.Add($m) } } } return [pscustomobject]@{ Files = $files.ToArray(); Faults = $faults.ToArray(); Expanded = $expanded.ToArray() } } function Get-PSMutationConventionTest { # The test file a mutated file is paired with by name, `tests/<Name>.Tests.ps1`, relative to # Root -- or $null when that file is not there. [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] [string]$File, [Parameter(Mandatory)] [string]$Root ) $test = "tests/$([System.IO.Path]::GetFileNameWithoutExtension($File)).Tests.ps1" if ([System.IO.File]::Exists((Join-Path $Root $test))) { return $test } return $null } function Resolve-PSMutationConfigPattern { # The config with its patterns expanded and, under testsByConvention, its missing tests # entries filled in by name: [pscustomobject]@{ Cfg; Lines }. Lines say what each pattern and # each pairing became, for the run to print. Throws, naming every pattern that matched nothing. # # A copy, not an edit: the parsed config is the caller's, and a recheck reads it again. [OutputType([pscustomobject])] [CmdletBinding()] param( [Parameter(Mandatory)] $Cfg, [Parameter(Mandatory)] [string]$Root ) # Shallow: every value this changes is replaced whole, never edited in place. $copy = [pscustomobject]@{} foreach ($p in $Cfg.PSObject.Properties) { $copy | Add-Member -NotePropertyName $p.Name -NotePropertyValue $p.Value } $lines = [System.Collections.Generic.List[string]]::new() $faults = [System.Collections.Generic.List[string]]::new() $mutate = Expand-PSMutationPathList -Entry @($Cfg.mutate) -Root $Root -Key 'mutate' $faults.AddRange([string[]]$mutate.Faults) foreach ($x in $mutate.Expanded) { $lines.Add(" mutate '$($x.Pattern)' -> $($x.Files -join ', ')") } $copy.mutate = [string[]]$mutate.Files $tests = [ordered]@{} foreach ($p in @($Cfg.tests.PSObject.Properties | Where-Object { $_ })) { $list = Expand-PSMutationPathList -Entry @($p.Value) -Root $Root -Key "tests['$($p.Name)']" $faults.AddRange([string[]]$list.Faults) foreach ($x in $list.Expanded) { $lines.Add(" tests['$($p.Name)'] '$($x.Pattern)' -> $($x.Files -join ', ')") } $tests[$p.Name] = [string[]]$list.Files } if ($faults.Count -gt 0) { throw (($faults -join '; ') + '. A pattern is a claim about files; one that matches none is a typo or a moved directory.') } if ([bool]$Cfg.testsByConvention) { $lines.AddRange([string[]]@(Add-PSMutationConventionTest -Tests $tests -Mutate $copy.mutate -Root $Root)) } $copy | Add-Member -NotePropertyName tests -NotePropertyValue ([pscustomobject]$tests) -Force return [pscustomobject]@{ Cfg = $copy; Lines = $lines.ToArray() } } function Add-PSMutationConventionTest { # Fill in, by name, the tests entry of every mutated file that has none and whose # tests/<Name>.Tests.ps1 is there, returning a line per pair made. An explicit entry wins. [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [System.Collections.IDictionary]$Tests, [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]]$Mutate, [Parameter(Mandatory)] [string]$Root ) foreach ($file in $Mutate) { if (Test-PSMutationTestsEntry -Tests $Tests -File $file -Root $Root) { continue } $test = Get-PSMutationConventionTest -File $file -Root $Root if ($null -eq $test) { continue } $Tests[$file] = [string[]]@($test) " $file -> $test (by name)" } } function Test-PSMutationTestsEntry { # Whether the tests map already has an entry for a file, spelt as the config spells it or not. [OutputType([bool])] [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [System.Collections.IDictionary]$Tests, [Parameter(Mandatory)] [string]$File, [Parameter(Mandatory)] [string]$Root ) $want = ConvertTo-PSMutationRootRelative -Path $File -Root $Root foreach ($k in $Tests.Keys) { if ((ConvertTo-PSMutationRootRelative -Path $k -Root $Root) -eq $want) { return $true } } return $false } function ConvertTo-PSMutationRootRelative { # A config path as the one spelling a pattern's matches use: relative to Root, with `/`. # `src/a.ps1`, `./src/a.ps1` and `src\a.ps1` are one file. [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path, [Parameter(Mandatory)] [string]$Root ) return [System.IO.Path]::GetRelativePath($Root, [System.IO.Path]::GetFullPath((Join-Path $Root $Path.Replace('\', '/')))).Replace('\', '/') } |