Eigenverft.Manifested.Drydock.IO.ps1
function Find-FilesByPattern { <# .SYNOPSIS Robust, manual (no -Recurse) filename search with Windows wildcards. .DESCRIPTION Traverses the tree with an explicit stack (deep-first), continues on errors, and matches file names against one or more wildcard patterns (e.g. *.txt, *.sln). Returns an array of FileInfo objects. .PARAMETER Path Root directory where the search should begin. .PARAMETER Pattern Filename wildcard(s). Accepts array or comma/semicolon list (e.g. "*.txt;*.csproj"). .EXAMPLE $files = Find-FilesByPattern -Path "C:\MyProjects" -Pattern "*.txt;*.md" foreach ($f in $files) { $f.FullName } #> [CmdletBinding()] [Alias("ffbp")] param( [Parameter(Mandatory=$true)] [string]$Path, [Parameter(Mandatory=$true)] [string[]]$Pattern ) # Validate root if (-not (Test-Path -LiteralPath $Path -PathType Container)) { throw "The specified path '$Path' does not exist or is not a directory." } function _Norm-List { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param([string[]]$Patterns) if (-not $Patterns) { return @() } $list = New-Object System.Collections.Generic.List[string] foreach ($p in $Patterns) { if ([string]::IsNullOrWhiteSpace($p)) { continue } $p -split '[,;]' | ForEach-Object { $t = $_.Trim() if ($t) { [void]$list.Add($t) } } } if ($list.Count -eq 0) { return @() } $list.ToArray() } # Normalize patterns (supports array or "a;b,c" lists) $pat = _Norm-List $Pattern if (-not $pat -or $pat.Count -eq 0) { throw "Pattern must not be empty." } # Prepare traversal try { $rootItem = Get-Item -LiteralPath $Path -ErrorAction Stop } catch { throw "Path '$Path' is not accessible: $_" } $stack = New-Object System.Collections.Stack $stack.Push($rootItem) $fmatches = New-Object System.Collections.Generic.List[System.IO.FileInfo] while ($stack.Count -gt 0) { $dir = $stack.Pop() # Discover subdirectories $subs = @() try { $subs = Get-ChildItem -LiteralPath $dir.FullName -Directory -ErrorAction Stop } catch { Write-Warning "Cannot list directories in '$($dir.FullName)': $_" } foreach ($sd in $subs) { $stack.Push($sd) } # Files in this directory $files = @() try { $files = Get-ChildItem -LiteralPath $dir.FullName -File -ErrorAction Stop } catch { Write-Warning "Cannot list files in '$($dir.FullName)': $_" } foreach ($f in $files) { foreach ($p in $pat) { if ($f.Name -like $p) { [void]$fmatches.Add($f); break } } } } return @($fmatches.ToArray()) } function Remove-FilesByPattern { <# .SYNOPSIS Delete files by filename wildcard(s) using explicit traversal; optionally remove empty subdirectories. .DESCRIPTION Walks the directory tree manually (deep-first) without using -Recurse and matches file names against one or more Windows-style wildcard patterns (e.g. *.log, *.tmp). Continues on listing/deletion errors. After deletions, it can remove empty subdirectories (deepest-first). Emits only brief summary via Write-Host. .PARAMETER Path Root directory where the deletion should begin. .PARAMETER Pattern Filename wildcard(s). Accepts array or comma/semicolon list (e.g. "*.log;*.tmp,*.bak"). .PARAMETER RemoveEmptyDirs Policy to remove empty subdirectories after deleting files. Allowed: 'Yes' | 'No'. Default: 'Yes'. .EXAMPLE Remove-FilesByPattern -Path "C:\Temp" -Pattern "*.log" .EXAMPLE Remove-FilesByPattern -Path "/var/tmp" -Pattern "*.log;*.tmp" -RemoveEmptyDirs No .EXAMPLE Remove-FilesByPattern -Path "D:\Build" -Pattern @("*.obj","*.pch","*.ipch") .NOTES Requirements: Windows PowerShell 5/5.1 and PowerShell 7+ on Windows/macOS/Linux. Behavior: Idempotent; subsequent runs converge (no output objects, summary only). #> [CmdletBinding()] param( [Parameter(Mandatory=$true)] [string]$Path, [Parameter(Mandatory=$true)] [string[]]$Pattern, [Parameter()] [ValidateSet('Yes','No')] [string]$RemoveEmptyDirs = 'Yes' ) # [reviewer] Validate root path early and fail fast with concise message. if (-not (Test-Path -LiteralPath $Path -PathType Container)) { throw "The specified path '$Path' does not exist or is not a directory." } # Inline helper: normalize pattern list; local scope; no pipeline output. function local:_Norm-List { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param([string[]]$Patterns) if (-not $Patterns) { return @() } $list = New-Object System.Collections.Generic.List[string] foreach ($p in $Patterns) { if ([string]::IsNullOrWhiteSpace($p)) { continue } foreach ($seg in ($p -split '[,;]')) { $t = $seg.Trim() if ($t.Length -gt 0) { [void]$list.Add($t) } } } if ($list.Count -eq 0) { return @() } $list.ToArray() } # Normalize patterns; keep parity with Find-FilesByPattern behavior. $pat = local:_Norm-List $Pattern if (-not $pat -or $pat.Count -eq 0) { throw "Pattern must not be empty." } # Prepare traversal using explicit stack; avoid -Recurse. $rootItem = $null try { $rootItem = Get-Item -LiteralPath $Path -ErrorAction Stop } catch { throw "Path '$Path' is not accessible: $_" } $stack = New-Object System.Collections.Stack $stack.Push($rootItem) $candidates = New-Object System.Collections.Generic.List[System.IO.FileInfo] $allDirs = New-Object System.Collections.Generic.List[System.IO.DirectoryInfo] $rootFull = $rootItem.FullName while ($stack.Count -gt 0) { $dir = $stack.Pop() # [reviewer] Track directories for later empty-dir cleanup; skip the root itself. if ($dir.FullName -ne $rootFull) { [void]$allDirs.Add($dir) } # Discover subdirectories; continue on errors. $subs = @() try { $subs = Get-ChildItem -LiteralPath $dir.FullName -Directory -ErrorAction Stop } catch { Write-Warning "Cannot list directories in '$($dir.FullName)': $_" } foreach ($sd in $subs) { $stack.Push($sd) } # List files; continue on errors. $files = @() try { $files = Get-ChildItem -LiteralPath $dir.FullName -File -ErrorAction Stop } catch { Write-Warning "Cannot list files in '$($dir.FullName)': $_" } # Match filenames against wildcard set; first-hit wins. foreach ($f in $files) { foreach ($p in $pat) { if ($f.Name -like $p) { [void]$candidates.Add($f); break } } } } # Delete matched files; keep going on individual failures. $deleted = 0 foreach ($fi in $candidates) { try { if (Test-Path -LiteralPath $fi.FullName -PathType Leaf) { Remove-Item -LiteralPath $fi.FullName -Force -ErrorAction Stop $deleted += 1 } } catch { Write-Warning "Failed to delete '$($fi.FullName)': $_" } } # Optionally remove empty subdirectories (deepest-first), never the root. $removedDirs = 0 if ($RemoveEmptyDirs -eq 'Yes') { $sorted = $allDirs | Sort-Object { ($_.FullName.Split([System.IO.Path]::DirectorySeparatorChar)).Count } -Descending foreach ($d in $sorted) { try { $items = Get-ChildItem -LiteralPath $d.FullName -Force -ErrorAction Stop if (-not $items -or $items.Count -eq 0) { Remove-Item -LiteralPath $d.FullName -Force -ErrorAction Stop $removedDirs += 1 } } catch { # [reviewer] Ignore cleanup failures; keep traversal robust/cross-platform. } } } # Minimal, consistent logging per policy. Write-Host ("Deleted files: {0}" -f $deleted) if ($RemoveEmptyDirs -eq 'Yes') { Write-Host ("Removed empty directories: {0}" -f $removedDirs) } } function Get-Path { <# .SYNOPSIS Combine flexible inputs in -Paths into a single path string (no filesystem I/O). .DESCRIPTION Accepts heterogeneous inputs (strings, nested arrays, DirectoryInfo/FileInfo, and hashtables/objects with path-like members). Flattens & validates, applies “last rooted wins”, then combines segments iteratively using System.IO.Path. Returns the combined path string with OS-appropriate separators. Does not resolve to absolute, does not touch the filesystem. .PARAMETER Paths One or more path-like items: - String(s) or nested arrays - DirectoryInfo/FileInfo (.FullName) - Hashtable/PSCustomObject with FullName / DirectoryName / Path (case-insensitive) .EXAMPLE Get-Path -Paths @("ddd","build") # Returns: ddd\build (on Windows) or ddd/build (on Unix) .EXAMPLE Get-Path -Paths @("C:\repo","artifacts","build\output\file.txt") # Returns: C:\repo\artifacts\build\output\file.txt .EXAMPLE # Mixed types are fine: $d = [IO.DirectoryInfo]"C:\repo" Get-Path -Paths @($d, @{Path='artifacts'}, 'build','output') .NOTES PowerShell 5/5.1 and 7+. To convert the result to an absolute path later (without touching the FS): [IO.Path]::GetFullPath( (Get-Path -Paths $Paths) ) #> [CmdletBinding()] [OutputType([string])] param( [Parameter(Mandatory=$true, Position=0)] [object[]]$Paths ) # --- Helpers (kept local for portability) --------------------------------------------------- function _Select-PathLikeValue { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param([object]$Item) if ($null -eq $Item) { return $null } if ($Item -is [string]) { $t = $Item.Trim() if ($t.Length -gt 0) { return $t } else { return $null } } if ($Item -is [System.IO.FileSystemInfo]) { return $Item.FullName } if ($Item -is [System.Collections.IDictionary]) { foreach ($k in @('FullName','DirectoryName','Path')) { foreach ($key in $Item.Keys) { if ($key -is [string] -and $key.Equals($k,[StringComparison]::OrdinalIgnoreCase)) { $v = $Item[$key]; if ($null -ne $v) { return ($v.ToString().Trim()) } } } } $vals = @($Item.Values) if ($vals.Count -eq 1 -and $null -ne $vals[0]) { return ($vals[0].ToString().Trim()) } return $null } $type = $Item.GetType() foreach ($m in @('FullName','DirectoryName','Path')) { $prop = $type.GetProperty($m) if ($null -ne $prop) { $val = $prop.GetValue($Item, $null) if ($null -ne $val) { return ($val.ToString().Trim()) } } } $s = $Item.ToString() if ($null -ne $s) { $t = $s.Trim() if ($t.Length -gt 0) { return $t } } return $null } function _Flatten-PathInputs { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param([object[]]$Items) $acc = New-Object System.Collections.Generic.List[string] if (-not $Items) { return @() } foreach ($it in $Items) { if ($null -eq $it) { continue } if ($it -is [string]) { $val = _Select-PathLikeValue $it if ($val) { [void]$acc.Add($val) } continue } if ($it -is [System.Collections.IEnumerable]) { if ($it -is [System.Collections.IDictionary]) { $val = _Select-PathLikeValue $it if ($val) { [void]$acc.Add($val) } } else { $nested = @() foreach ($n in $it) { $nested += ,$n } $flatNested = _Flatten-PathInputs $nested foreach ($s in $flatNested) { [void]$acc.Add($s) } } continue } $v = _Select-PathLikeValue $it if ($v) { [void]$acc.Add($v) } } if ($acc.Count -eq 0) { return @() } return $acc.ToArray() } function _Validate-Segments { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param([string[]]$Segments) $bad = [System.IO.Path]::GetInvalidPathChars() foreach ($seg in $Segments) { if ([string]::IsNullOrWhiteSpace($seg)) { throw "Encountered an empty path segment." } foreach ($ch in $bad) { if ($seg.IndexOf($ch) -ge 0) { throw ("Invalid character '{0}' found in segment '{1}'." -f $ch, $seg) } } } } # --- Normalize & Combine -------------------------------------------------------------------- $segments = _Flatten-PathInputs $Paths if (-not $segments -or $segments.Count -eq 0) { throw "Paths must contain at least one resolvable segment." } _Validate-Segments $segments # Last rooted wins: if a later segment is rooted, drop everything before it. $lastRooted = -1 for ($i = 0; $i -lt $segments.Count; $i++) { if ([System.IO.Path]::IsPathRooted($segments[$i])) { $lastRooted = $i } } if ($lastRooted -ge 0) { $segments = $segments[$lastRooted..($segments.Count - 1)] } # Iteratively combine (avoids Combine(string[]) quirkiness across runtimes). $current = $segments[0] for ($i = 1; $i -lt $segments.Count; $i++) { $current = [System.IO.Path]::Combine($current, $segments[$i]) } if ([string]::IsNullOrWhiteSpace($current)) { throw "The combined path is empty after normalization." } # Return the combined (possibly relative) path — no resolution to absolute. return $current } function New-Directory { <# .SYNOPSIS Combine flexible path inputs and ensure the directory exists. .DESCRIPTION Accepts heterogeneous inputs in -Paths (strings, nested arrays, DirectoryInfo/FileInfo, hashtables/objects with path-like members), flattens and sanitizes them, and then iteratively combines segments (cross-version safe). Creates the directory if missing and returns the absolute directory path. Idempotent and cross-platform. .PARAMETER Paths One or more items representing path segments. Supports: - String(s) or nested arrays - DirectoryInfo/FileInfo (uses .FullName) - PSCustomObject/Hashtable with one of: FullName, DirectoryName, Path .EXAMPLE $ne = New-Directory -Paths @("$gitTopLevelDirectory","$artifactsFolderName",$deploymentInfo.Branch.PathSegmentsSanitized) # Produces: <top>/artifacts/feature/stabilize (OS-specific separators), creates if missing. .NOTES Compatibility: Windows PowerShell 5/5.1 and PowerShell 7+ on Windows/macOS/Linux. Logging: Only announces creation via Write-Host when newly created. #> [CmdletBinding()] param( [Parameter(Mandatory=$true)] [object[]]$Paths ) # Helper: extract a path-like string from a single element. function _Select-PathLikeValue { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param([object]$Item) if ($null -eq $Item) { return $null } if ($Item -is [string]) { $t = $Item.Trim() if ($t.Length -gt 0) { return $t } else { return $null } } if ($Item -is [System.IO.FileSystemInfo]) { return $Item.FullName } if ($Item -is [System.Collections.IDictionary]) { foreach ($k in @('FullName','DirectoryName','Path')) { foreach ($key in $Item.Keys) { if ($key -is [string] -and $key.Equals($k, [System.StringComparison]::OrdinalIgnoreCase)) { $v = $Item[$key] if ($null -ne $v) { return ($v.ToString().Trim()) } } } } $vals = @($Item.Values) if ($vals.Count -eq 1 -and $null -ne $vals[0]) { return ($vals[0].ToString().Trim()) } return $null } $type = $Item.GetType() foreach ($m in @('FullName','DirectoryName','Path')) { $prop = $type.GetProperty($m) if ($null -ne $prop) { $val = $prop.GetValue($Item, $null) if ($null -ne $val) { return ($val.ToString().Trim()) } } } $s = $Item.ToString() if ($null -ne $s) { $t = $s.Trim() if ($t.Length -gt 0) { return $t } } return $null } # Helper: recursively flatten arbitrary/nested inputs into a list of non-empty strings. function _Flatten-PathInputs { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param([object[]]$Items) $acc = New-Object System.Collections.Generic.List[string] if (-not $Items) { return @() } foreach ($it in $Items) { if ($null -eq $it) { continue } if ($it -is [string]) { $val = _Select-PathLikeValue $it if ($val) { [void]$acc.Add($val) } continue } if ($it -is [System.Collections.IEnumerable]) { if ($it -is [System.Collections.IDictionary]) { $val = _Select-PathLikeValue $it if ($val) { [void]$acc.Add($val) } } else { $nested = @() foreach ($n in $it) { $nested += ,$n } $flatNested = _Flatten-PathInputs $nested foreach ($s in $flatNested) { [void]$acc.Add($s) } } continue } $v = _Select-PathLikeValue $it if ($v) { [void]$acc.Add($v) } } if ($acc.Count -eq 0) { return @() } return $acc.ToArray() } # Helper: basic segment sanity (invalid path chars). function _Validate-Segments { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param([string[]]$Segments) $bad = [System.IO.Path]::GetInvalidPathChars() foreach ($seg in $Segments) { if ([string]::IsNullOrWhiteSpace($seg)) { throw "Encountered an empty path segment." } foreach ($ch in $bad) { if ($seg.IndexOf($ch) -ge 0) { throw ("Invalid character '{0}' found in segment '{1}'." -f $ch, $seg) } } } } # Normalize. $segments = _Flatten-PathInputs $Paths if (-not $segments -or $segments.Count -eq 0) { throw "Paths must contain at least one resolvable segment." } _Validate-Segments $segments # Rooted policy: last rooted wins (mirrors typical Combine semantics). $lastRooted = -1 for ($i = 0; $i -lt $segments.Count; $i++) { if ([System.IO.Path]::IsPathRooted($segments[$i])) { $lastRooted = $i } } if ($lastRooted -ge 0) { $segments = $segments[$lastRooted..($segments.Count - 1)] } # Cross-version safe: iteratively combine (do NOT rely on Combine(string[])). $current = $segments[0] for ($i = 1; $i -lt $segments.Count; $i++) { $current = [System.IO.Path]::Combine($current, $segments[$i]) } $combined = $current if ([string]::IsNullOrWhiteSpace($combined)) { throw "The combined path is empty after normalization." } $fullPath = [System.IO.Path]::GetFullPath($combined) # Sanity: fail if a file exists at the target. if ([System.IO.File]::Exists($fullPath)) { throw ("A file already exists at '{0}'; cannot create a directory at this path." -f $fullPath) } # Idempotent creation; announce only if newly created. $existed = [System.IO.Directory]::Exists($fullPath) try { [System.IO.Directory]::CreateDirectory($fullPath) | Out-Null } catch { throw ("Failed to create or access directory '{0}': {1}" -f $fullPath, $_) } if (-not $existed) { Write-Host ("Created directory: {0}" -f $fullPath) } $fullPath } function Find-TreeContent { <# .SYNOPSIS Manual deepest-first file discovery + Windows-style wildcard text search with smart AUTO fallback. .DESCRIPTION Two-phase search: 1) Manual, error-resilient traversal (no -Recurse). Collect candidate files using Windows wildcards (*, ?, [...]). 2) Scan candidates’ contents with the SAME wildcard rules for -FindText (case-insensitive, classic Windows feel). Escaping (for BOTH filename and text patterns): - Use [*] for literal asterisk, and [?] for literal question mark. - Or escape with backslash: \* \? \[ \] or backtick: `* `? `[ `] - Literal '[' or ']' can also be matched via '[[]' or '[]]'. Encoding & AUTO mode: - Primary pass uses StreamReader with built-in BOM detection; fallback is Encoding.Default (ANSI/UTF-8 depending on OS). - If primary pass finds no matches AND no BOM was detected (i.e., Default was used), -Auto tries encodings in this order: UTF-8 → UTF-16 LE, and stops on the first that matches. - No binary scanning is performed. Output: - Always returns an **array** of result objects with: Path, LineNumber, Snippet, Encoding, Newline - `Snippet` is trimmed around the match (configurable via -MaxLineChars). Traversal order: - Files are processed from the deepest directory upward. Robustness: - Directory/file enumeration and reads continue on errors (warnings only). - Single size guard: -MaxFileSizeBytes (applies to text scanning). .PARAMETER Path Root directory to search. .PARAMETER Include Filename wildcards (*, ?, [...]). Accepts array or comma/semicolon list. Default: '*'. .PARAMETER Exclude Wildcard(s) to skip (matched against FullName). Accepts array or comma/semicolon list. .PARAMETER FindText Content pattern using Windows wildcards (*, ?, [...]) with the escaping rules above. .PARAMETER Auto When no BOM is detected and the first pass finds no matches, try UTF-8 then UTF-16 LE (stop on first success). .PARAMETER MaxFileSizeBytes Max size per file for scanning. Default: 64MB. .PARAMETER MaxLineChars Max characters kept in the returned Snippet (centered on match). Default: 256. .PARAMETER ShowProgress Write-Host discovery and match feedback. .EXAMPLE Find-TreeContent -Path C:\repo -Include "*.cs;*.ps1" -FindText "*TODO?*" -ShowProgress .EXAMPLE Find-TreeContent -Path $PWD -Include "*" -Exclude "*\bin\*;*/obj/*" -FindText "[*]CRITICAL[*]" -ShowProgress .EXAMPLE Find-TreeContent -Path . -Include "*" -FindText "mani" -Auto | Format-Table #> [CmdletBinding()] [Alias('ftc','deepfind')] param( [Parameter(Mandatory=$true)] [string]$Path, [Parameter()] [string[]]$Include = @('*'), [Parameter()] [string[]]$Exclude, [Parameter(Mandatory=$true)] [string]$FindText, [switch]$Auto, [ValidateRange(1, 1GB)] [int]$MaxFileSizeBytes = 1048576, [ValidateRange(32, 4096)] [int]$MaxLineChars = 256, [switch]$ShowProgress ) # ------------------------ Helpers ------------------------ function _Norm-List { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param([string[]]$Patterns) if (-not $Patterns) { return @() } $list = New-Object System.Collections.Generic.List[string] foreach ($p in $Patterns) { if ([string]::IsNullOrWhiteSpace($p)) { continue } $p -split '[,;]' | ForEach-Object { $t = $_.Trim() if ($t) { [void]$list.Add($t) } } } if ($list.Count -eq 0) { return @() } $list.ToArray() } function _WildcardToRegex { param([Parameter(Mandatory=$true)][string]$Pattern) # Convert Windows wildcards to a .NET regex (case-insensitive) $sb = New-Object System.Text.StringBuilder $i = 0 $len = $Pattern.Length while ($i -lt $len) { $ch = $Pattern[$i] if (($ch -eq '\' -or $ch -eq '`') -and ($i + 1 -lt $len)) { $nx = $Pattern[$i+1] if ($nx -in @('*','?','[',']','\','`')) { [void]$sb.Append([System.Text.RegularExpressions.Regex]::Escape([string]$nx)) $i += 2 continue } } if ($ch -eq '*') { [void]$sb.Append('.*'); $i++; continue } if ($ch -eq '?') { [void]$sb.Append('.'); $i++; continue } if ($ch -eq '[') { $j = $i + 1 while ($j -lt $len -and $Pattern[$j] -ne ']') { $j++ } if ($j -lt $len -and $Pattern[$j] -eq ']') { $content = $Pattern.Substring($i+1, $j-$i-1) if ($content.Length -gt 0 -and $content[0] -eq '!') { $content = '^' + $content.Substring(1) } $content = $content -replace '([\\\]\[.{}()+|$])','\\$1' [void]$sb.Append('[' + $content + ']') $i = $j + 1; continue } [void]$sb.Append('\['); $i++; continue } if ($ch -eq ']') { [void]$sb.Append('\]'); $i++; continue } [void]$sb.Append([System.Text.RegularExpressions.Regex]::Escape([string]$ch)); $i++ } return '(?i:' + $sb.ToString() + ')' } function _GetEncodingByWebName([string]$name) { try { return [System.Text.Encoding]::GetEncoding($name) } catch { return $null } } function _MakeSnippet([string]$line, [int]$idx, [int]$len, [int]$maxChars) { if ($null -eq $line) { return $null } if ($line.Length -le $maxChars) { return $line } $context = [Math]::Max(0, [Math]::Floor(($maxChars - $len) / 2)) $start = [Math]::Max(0, $idx - $context) if ($start + $maxChars -gt $line.Length) { $start = $line.Length - $maxChars } $slice = $line.Substring($start, [Math]::Min($maxChars, $line.Length - $start)) $prefix = if ($start -gt 0) { '…' } else { '' } $suffix = if (($start + $slice.Length) -lt $line.Length) { '…' } else { '' } return "$prefix$slice$suffix" } function _Detect-Newline { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param( [string]$Path, [System.Text.Encoding]$Encoding, [int]$MaxBytes = 131072 ) try { $fi = Get-Item -LiteralPath $Path -ErrorAction Stop } catch { return 'Unknown' } $readLen = [Math]::Min($fi.Length, [long]$MaxBytes) if ($readLen -le 0) { return 'Unknown' } try { $fs = [System.IO.File]::Open($Path, 'Open', 'Read', 'ReadWrite') $buf = New-Object byte[] $readLen $null = $fs.Read($buf, 0, $buf.Length) $fs.Dispose() } catch { return 'Unknown' } try { $txt = $Encoding.GetString($buf, 0, $buf.Length) } catch { return 'Unknown' } $crlf = ([regex]::Matches($txt, "`r`n")).Count $txt2 = $txt -replace "`r`n", '' $cr = ([regex]::Matches($txt2, "`r")).Count $lf = ([regex]::Matches($txt2, "`n")).Count if ($crlf -gt 0 -and $cr -eq 0 -and $lf -eq 0) { return 'CRLF' } if ($crlf -eq 0 -and $cr -eq 0 -and $lf -gt 0) { return 'LF' } if ($crlf -eq 0 -and $cr -gt 0 -and $lf -eq 0) { return 'CR' } if ($crlf -eq 0 -and $crlf -eq 0 -and $cr -eq 0 -and $lf -eq 0) { return 'Unknown' } return 'Mixed' } function _Discover-Files { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param( [string]$Root, [string[]]$Inc, [string[]]$Exc, [switch]$Progress ) try { $rootItem = Get-Item -LiteralPath $Root -ErrorAction Stop } catch { throw "Path '$Root' is not accessible: $_" } $stack = New-Object System.Collections.Stack $stack.Push(@{ Item = $rootItem; Depth = 0 }) $files = New-Object System.Collections.Generic.List[object] function _SegCount([string]$p) { if (-not $p) { return 0 } return ($p.TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) -split '[\\/]+').Count } $rootSegs = _SegCount $rootItem.FullName while ($stack.Count -gt 0) { $frame = $stack.Pop() $dir = $frame.Item $depth = $frame.Depth if ($Progress) { Write-Host "[DIR] $($dir.FullName)" -ForegroundColor Cyan } $subs = @() try { $subs = Get-ChildItem -LiteralPath $dir.FullName -Directory -ErrorAction Stop } catch { Write-Warning "Cannot list directories in '$($dir.FullName)': $_" } foreach ($sd in $subs) { $stack.Push(@{ Item = $sd; Depth = $depth + 1 }) } $dirFiles = @() try { $dirFiles = Get-ChildItem -LiteralPath $dir.FullName -File -ErrorAction Stop } catch { Write-Warning "Cannot list files in '$($dir.FullName)': $_" } foreach ($f in $dirFiles) { $name = $f.Name $full = $f.FullName $ok = $false foreach ($p in $Inc) { if ($name -like $p) { $ok = $true; break } } if (-not $ok) { continue } if ($Exc -and $Exc.Count -gt 0) { $skip = $false foreach ($e in $Exc) { if ($full -like $e) { $skip = $true; break } } if ($skip) { continue } } $depthNow = [Math]::Max(0, (_SegCount $f.DirectoryName) - $rootSegs) if ($Progress) { Write-Host " [FILE] $full" -ForegroundColor DarkGray } $files.Add([pscustomobject]@{ FullName = $full Name = $name Depth = [int]$depthNow Length = $f.Length }) | Out-Null } } $files | Sort-Object -Property ` @{ Expression = 'Depth'; Descending = $true }, ` @{ Expression = 'Name' ; Descending = $false } } function _Scan-TextSmart { [Diagnostics.CodeAnalysis.SuppressMessage("PSUseApprovedVerbs","")] param( [string]$Path, [string]$RegexPattern, [int]$MaxBytes, [int]$MaxChars, [switch]$AllowFallback ) $fi = $null try { $fi = Get-Item -LiteralPath $Path -ErrorAction Stop } catch { Write-Warning "Cannot access '$Path': $_"; return @() } if ($fi.Length -gt $MaxBytes) { Write-Verbose "Skip > MaxFileSizeBytes: $Path"; return @() } $rx = New-Object System.Text.RegularExpressions.Regex($RegexPattern, [System.Text.RegularExpressions.RegexOptions]::Compiled) $results = New-Object System.Collections.Generic.List[object] $primaryEnc = $null # pass 1: BOM-aware (Default fallback) $fs = $null; $sr = $null try { $fs = [System.IO.File]::Open($Path, 'Open', 'Read', 'ReadWrite') $sr = New-Object System.IO.StreamReader($fs, [System.Text.Encoding]::Default, $true) $null = $sr.Peek() $primaryEnc = $sr.CurrentEncoding $lineNo = 0 while ($null -ne ($line = $sr.ReadLine())) { $lineNo++ $m = $rx.Match($line) if ($m.Success) { $snippet = (_MakeSnippet $line $m.Index $m.Length $MaxChars).Trim() $results.Add([pscustomobject]@{ Path = $Path LineNumber = $lineNo Snippet = $snippet Encoding = $sr.CurrentEncoding.WebName }) | Out-Null } } } catch { Write-Warning "Text scan failed: '$Path': $_" } finally { if ($sr) { $sr.Dispose() } elseif ($fs) { $fs.Dispose() } } if ($results.Count -gt 0) { return $results.ToArray() } # pass 2: AUTO fallback (only if allowed and no BOM recognized -> Default used) if ($AllowFallback -and $primaryEnc -and ($primaryEnc.WebName -eq [System.Text.Encoding]::Default.WebName)) { foreach ($enc in @([System.Text.Encoding]::UTF8, [System.Text.Encoding]::Unicode)) { $fs2 = $null; $sr2 = $null try { $fs2 = [System.IO.File]::Open($Path, 'Open', 'Read', 'ReadWrite') $sr2 = New-Object System.IO.StreamReader($fs2, $enc, $false) $lineNo2 = 0 while ($null -ne ($line2 = $sr2.ReadLine())) { $lineNo2++ $m2 = $rx.Match($line2) if ($m2.Success) { $snippet2 = (_MakeSnippet $line2 $m2.Index $m2.Length $MaxChars).Trim() $results.Add([pscustomobject]@{ Path = $Path LineNumber = $lineNo2 Snippet = $snippet2 Encoding = $enc.WebName }) | Out-Null } } } catch { Write-Verbose "Alt-encoding scan failed for '$Path' with $($enc.WebName): $_" } finally { if ($sr2) { $sr2.Dispose() } elseif ($fs2) { $fs2.Dispose() } } if ($results.Count -gt 0) { break } # stop on first successful encoding } } return $results.ToArray() } # ------------------------ Validate & prepare ------------------------ if (-not (Test-Path -LiteralPath $Path -PathType Container)) { throw "Path '$Path' does not exist or is not a directory." } $inc = _Norm-List $Include if (-not $inc -or $inc.Count -eq 0) { $inc = @('*') } $exc = _Norm-List $Exclude $regexPattern = _WildcardToRegex -Pattern $FindText if ($ShowProgress) { Write-Host "[DISCOVERY] Enumerating '$Path'..." -ForegroundColor Yellow } # ------------------------ Phase 1: discovery ------------------------ $candidates = _Discover-Files -Root $Path -Inc $inc -Exc $exc -Progress:$ShowProgress if ($ShowProgress) { Write-Host "[DISCOVERY] $($candidates.Count) candidate file(s) found." -ForegroundColor Yellow Write-Host "[SCAN] Processing deepest files first..." -ForegroundColor Yellow } # ------------------------ Phase 2: scanning ------------------------ $allResults = New-Object System.Collections.Generic.List[object] foreach ($f in $candidates) { $p = $f.FullName if ($ShowProgress) { Write-Host "[SCAN] $p" -ForegroundColor DarkCyan } $hits = _Scan-TextSmart -Path $p -RegexPattern $regexPattern -MaxBytes $MaxFileSizeBytes -MaxChars $MaxLineChars -AllowFallback:$Auto if ($hits -and $hits.Count -gt 0) { # Determine newline once for the file, attach to each hit $encObj = _GetEncodingByWebName ($hits[0].Encoding) $nl = if ($encObj) { _Detect-Newline -Path $p -Encoding $encObj } else { 'Unknown' } foreach ($h in $hits) { # add Newline & (optional) progress line $h | Add-Member -NotePropertyName Newline -NotePropertyValue $nl if ($ShowProgress) { Write-Host " [+] L$($h.LineNumber) ($($h.Encoding), $nl) $($h.Snippet)" -ForegroundColor Green } [void]$allResults.Add($h) } } } # Always return an array (even when 0 or 1) $out = $allResults.ToArray() return @($out) } function Find-TreeContentByFile { <# .SYNOPSIS Call Find-TreeContent and group results per file. .DESCRIPTION Invokes Find-TreeContent with the same parameters, then returns one object per file: - FileName : full path (group key, i.e., $g.Name) - Encoding : single encoding for the file (from first match) - Newline : single newline style for the file - ITEMS : array of { LineNumber, Snippet } sorted by line .PARAMETER Path Root directory to search (passed through). .PARAMETER Include Filename wildcards (passed through). .PARAMETER Exclude Wildcard(s) to skip (passed through). .PARAMETER FindText Content wildcard (passed through). .PARAMETER Auto Enable encoding auto-fallback (passed through). .PARAMETER MaxFileSizeBytes Per-file size cap for scanning (passed through). .PARAMETER MaxLineChars Snippet width (passed through). .PARAMETER ShowProgress Discovery/match feedback (passed through). .EXAMPLE Find-TreeContentByFile -Path . -Include "*.cs;*.ps1" -FindText "*TODO?*" .EXAMPLE Find-TreeContentByFile -Path C:\repo -FindText "[*]CRITICAL[*]" -Auto | Format-List #> [CmdletBinding()] [Alias('ftc-byfile','deepfind-byfile')] param( [Parameter(Mandatory=$true)] [string]$Path, [Parameter()] [string[]]$Include = @('*'), [Parameter()] [string[]]$Exclude, [Parameter(Mandatory=$true)] [string]$FindText, [switch]$Auto, [ValidateRange(1, 1GB)] [int]$MaxFileSizeBytes = 1048576, [ValidateRange(32, 4096)] [int]$MaxLineChars = 256, [switch]$ShowProgress ) # Call the underlying function exactly with the same parameters $results = Find-TreeContent @PSBoundParameters if (-not $results) { return @() } $groups = $results | Group-Object -Property Path $out = foreach ($g in $groups) { $first = $g.Group | Select-Object -First 1 [pscustomobject]@{ FileName = $g.Name Encoding = $first.Encoding Newline = $first.Newline Lines = ($g.Group | Sort-Object LineNumber | Select-Object LineNumber, Snippet) } } return @($out) } function Resolve-ModulePath { <# .SYNOPSIS Resolve the on-disk directory (ModuleBase) for a module by name, honoring prerelease rules. .DESCRIPTION Prefers the currently loaded module (active in the session), regardless of prerelease. If not loaded, searches installed modules on $env:PSModulePath. By default returns the highest stable version. When -VersionScope IncludePrerelease is chosen, prerelease versions are considered; numeric version wins first, and for equal numeric versions, stable outranks prerelease. PSModulePath order is used as a final tie-breaker. .PARAMETER ModuleName Module name to resolve (e.g., 'Pester'). .PARAMETER VersionScope 'Stable' or 'IncludePrerelease'. Default: 'Stable'. Controls whether installed prerelease versions are considered. A loaded module is always accepted regardless of prerelease status. .PARAMETER All Return all discovered ModuleBase paths in resolution order (loaded first, then installed). .PARAMETER ThrowIfNotFound Throw if no matching module is found (neither loaded nor installed). .EXAMPLE Resolve-ModulePath -ModuleName Pester # Loaded module wins; else highest installed stable by precedence. .EXAMPLE Resolve-ModulePath -ModuleName Eigenverft.Manifested.Drydock -VersionScope IncludePrerelease # Considers prereleases: e.g., 4.0.0-beta outranks 3.9.0 stable; if 4.0.0 stable exists, it outranks 4.0.0-beta. .EXAMPLE Resolve-ModulePath -ModuleName Pester -All -VersionScope IncludePrerelease # Lists all candidate paths ordered by version (desc), stable before prerelease when equal, then PSModulePath precedence. .NOTES - Compatible with Windows PowerShell 5.1 and PowerShell 7+ on Windows/macOS/Linux. - Detects prerelease via PrivateData.PSData.Prerelease in the manifest when available. #> [CmdletBinding()] param( [Parameter(Mandatory = $true, Position = 0)] [Alias('Name')] [ValidateNotNullOrEmpty()] [string] $ModuleName, [ValidateSet('Stable','IncludePrerelease')] [string] $VersionScope = 'Stable', [switch] $All, [switch] $ThrowIfNotFound ) # Reviewer: Avoid global function leakage; use local scriptblocks for helpers. $roots = ($env:PSModulePath -split [IO.Path]::PathSeparator) $GetPrecedenceIndex = { param([Parameter(Mandatory = $true)][string]$ModuleBase) # Reviewer: Normalize inputs; tolerate odd entries and IO exceptions. $baseFull = $ModuleBase try { $baseFull = [IO.Path]::GetFullPath($ModuleBase) } catch { } for ($i = 0; $i -lt $roots.Length; $i++) { $r = $roots[$i] if ([string]::IsNullOrWhiteSpace($r)) { continue } $rFull = $r try { $rFull = [IO.Path]::GetFullPath($r) } catch { } $trimmed = $rFull.TrimEnd('\','/') if ($baseFull.StartsWith($trimmed, [System.StringComparison]::InvariantCultureIgnoreCase)) { return $i } } return [int]::MaxValue } $GetPrereleaseLabel = { param([Parameter(Mandatory = $true)][System.Management.Automation.PSModuleInfo]$Module) # Reviewer: Prefer manifest PrivateData.PSData.Prerelease if exposed, else try read the .psd1. $pre = $null try { if ($Module.PrivateData -and $Module.PrivateData.PSData -and $Module.PrivateData.PSData.Prerelease) { $pre = [string]$Module.PrivateData.PSData.Prerelease } } catch { } if (-not $pre) { # Try explicit manifest path first if known, else fallback to <Name>.psd1 next to ModuleBase. $manifestPath = $null try { if ($Module.Path -and $Module.Path.EndsWith('.psd1', [System.StringComparison]::OrdinalIgnoreCase)) { $manifestPath = $Module.Path } else { $candidate = Join-Path -Path $Module.ModuleBase -ChildPath ($Module.Name + '.psd1') if (Test-Path -LiteralPath $candidate) { $manifestPath = $candidate } } if ($manifestPath) { $mf = Import-PowerShellDataFile -Path $manifestPath -ErrorAction Stop if ($mf.PrivateData -and $mf.PrivateData.PSData -and $mf.PrivateData.PSData.Prerelease) { $pre = [string]$mf.PrivateData.PSData.Prerelease } } } catch { } } return $pre } $includePre = ($VersionScope -eq 'IncludePrerelease') # 1) Prefer loaded (active) modules, regardless of prerelease. $loaded = @(Get-Module -Name $ModuleName -All | Sort-Object Version -Descending) # 2) Discover installed candidates. $installed = @(Get-Module -ListAvailable -Name $ModuleName -All) if (-not $loaded -and -not $installed) { if ($ThrowIfNotFound) { throw "Module '$ModuleName' not found (neither loaded nor installed on PSModulePath)." } return } # Build annotated table for installed modules to sort correctly. $annotatedInstalled = @() foreach ($m in $installed) { $pre = & $GetPrereleaseLabel -Module $m $isPre = (-not [string]::IsNullOrWhiteSpace($pre)) if (-not $includePre -and $isPre) { continue } # Reviewer: filter prerelease unless explicitly requested. $precIndex = & $GetPrecedenceIndex -ModuleBase $m.ModuleBase $annotatedInstalled += [pscustomobject]@{ Module = $m ModuleBase = $m.ModuleBase Version = $m.Version IsPre = $isPre PrecIndex = $precIndex } } # Sort installed: Version desc; stable before prerelease; PSModulePath precedence asc. $orderedInstalled = @() if ($annotatedInstalled.Count -gt 0) { $orderedInstalled = $annotatedInstalled | Sort-Object ` @{ Expression = { $_.Version }; Descending = $true }, ` @{ Expression = { $_.IsPre } }, ` @{ Expression = { $_.PrecIndex } } } if ($All) { # Reviewer: Return loaded first (in session order by version), then installed (excluding duplicates). $result = New-Object System.Collections.Generic.List[string] foreach ($lm in $loaded) { if ($lm.ModuleBase -and -not $result.Contains($lm.ModuleBase, [System.StringComparer]::OrdinalIgnoreCase)) { [void]$result.Add($lm.ModuleBase) } } foreach ($row in $orderedInstalled) { $mb = $row.ModuleBase if ($mb -and -not $result.Contains($mb, [System.StringComparer]::OrdinalIgnoreCase)) { [void]$result.Add($mb) } } if ($result.Count -eq 0) { if ($ThrowIfNotFound) { throw "Module '$ModuleName' not found in the requested scope." } return } # Reviewer: Output in final resolution order, no extra noise. return $result.ToArray() } # Single best resolution: loaded wins, else first installed by ordering. if ($loaded.Count -gt 0) { return $loaded[0].ModuleBase } if ($orderedInstalled.Count -gt 0) { return $orderedInstalled[0].ModuleBase } if ($ThrowIfNotFound) { $scope = 'stable' if ($includePre) { $scope = 'any (including prerelease)' } throw "Module '$ModuleName' not found in $scope installations on PSModulePath." } } |