FindObject.psm1
|
#region Configuration $script:FindObjectConfigPath = Join-Path ([Environment]::GetFolderPath('ApplicationData')) 'FindObject\config.json' $script:FindObjectConfig = @{ Mode = 'Contains' # Contains | StartsWith | Fuzzy Property = 'Name' CaseSensitive = $false Highlight = $false } if (Test-Path -LiteralPath $script:FindObjectConfigPath) { try { $saved = Get-Content -LiteralPath $script:FindObjectConfigPath -Raw | ConvertFrom-Json foreach ($key in @('Mode', 'Property', 'CaseSensitive', 'Highlight')) { if ($null -ne $saved.$key) { $script:FindObjectConfig[$key] = $saved.$key } } } catch { Write-Verbose "Failed to load persisted FindObject config from '$script:FindObjectConfigPath': $_" } } function Get-FindObjectConfig { <# .SYNOPSIS Returns the current default settings used by Find-ObjectByName when a parameter isn't explicitly supplied. .EXAMPLE Get-FindObjectConfig #> [CmdletBinding()] param() [PSCustomObject]$script:FindObjectConfig } function Set-FindObjectConfig { <# .SYNOPSIS Changes the default Mode, Property, CaseSensitive, or Highlight settings used by Find-ObjectByName. .PARAMETER Persist Also saves the settings to disk so they apply in future PowerShell sessions. .EXAMPLE Set-FindObjectConfig -Mode Fuzzy -Highlight -Persist #> [CmdletBinding()] param( [ValidateSet('Contains', 'StartsWith', 'Fuzzy')] [string]$Mode, [string]$Property, [switch]$CaseSensitive, [switch]$Highlight, [switch]$Persist ) if ($PSBoundParameters.ContainsKey('Mode')) { $script:FindObjectConfig.Mode = $Mode } if ($PSBoundParameters.ContainsKey('Property')) { $script:FindObjectConfig.Property = $Property } if ($PSBoundParameters.ContainsKey('CaseSensitive')) { $script:FindObjectConfig.CaseSensitive = $CaseSensitive.IsPresent } if ($PSBoundParameters.ContainsKey('Highlight')) { $script:FindObjectConfig.Highlight = $Highlight.IsPresent } if ($Persist) { $configDir = Split-Path -Parent $script:FindObjectConfigPath if (-not (Test-Path -LiteralPath $configDir)) { New-Item -ItemType Directory -Path $configDir -Force | Out-Null } $script:FindObjectConfig | ConvertTo-Json | Set-Content -LiteralPath $script:FindObjectConfigPath -Encoding utf8 } [PSCustomObject]$script:FindObjectConfig } #endregion #region Internal helpers function Test-FindObjectFuzzyMatch { # Subsequence match: every character of $Pattern must appear in $Text, in order, not necessarily contiguous. param( [string]$Text, [string]$Pattern, [bool]$CaseSensitive ) if ([string]::IsNullOrEmpty($Pattern)) { return $true } if ([string]::IsNullOrEmpty($Text)) { return $false } $t = if ($CaseSensitive) { $Text } else { $Text.ToLowerInvariant() } $p = if ($CaseSensitive) { $Pattern } else { $Pattern.ToLowerInvariant() } $cursor = 0 foreach ($ch in $p.ToCharArray()) { $idx = $t.IndexOf($ch, $cursor) if ($idx -lt 0) { return $false } $cursor = $idx + 1 } return $true } function ConvertTo-FindObjectClauses { # Parses -SearchTerms into an ordered list of {Operator, Keyword} clauses. # Supports both array syntax (`"a","and","b"`) and natural single-string syntax (`"a AND b"`, `"chrome OR firefox NOT helper"`). # A keyword with no preceding operator defaults to OR against the accumulated result, matching the module's original behavior. param([string[]]$SearchTerms) $tokens = [System.Collections.Generic.List[string]]::new() foreach ($term in $SearchTerms) { if ([string]::IsNullOrWhiteSpace($term)) { continue } if ($term -match '(?i)(^|\s)(and|or|not)(\s|$)') { foreach ($piece in ($term -split '\s+')) { if (-not [string]::IsNullOrWhiteSpace($piece)) { $tokens.Add($piece) } } } else { $tokens.Add($term) } } $clauses = [System.Collections.Generic.List[hashtable]]::new() $pendingOperator = $null foreach ($token in $tokens) { if ($token -ieq 'and') { $pendingOperator = 'AND'; continue } if ($token -ieq 'or') { $pendingOperator = 'OR'; continue } if ($token -ieq 'not') { $pendingOperator = 'NOT'; continue } $operator = if ($clauses.Count -eq 0) { $null } elseif ($pendingOperator) { $pendingOperator } else { 'OR' } $clauses.Add(@{ Operator = $operator; Keyword = $token }) $pendingOperator = $null } if ($clauses.Count -eq 0) { throw "No valid search keywords provided in '-SearchTerms' after parsing operators. Please provide at least one keyword." } # The comma operator prevents PowerShell from unrolling a single-item List<T> into a bare hashtable # on the way out of the pipeline - without it, $clauses.Count downstream would silently report the # hashtable's key count (2) instead of the actual clause count (1) whenever there's only one keyword. return , $clauses } function Format-FindObjectHighlight { # Wraps every occurrence of the positive (non-NOT) keywords in ANSI bold-yellow for terminal display. param( [string]$Text, [string[]]$Keywords, [bool]$CaseSensitive ) $comparison = if ($CaseSensitive) { [System.StringComparison]::Ordinal } else { [System.StringComparison]::OrdinalIgnoreCase } $ranges = [System.Collections.Generic.List[object]]::new() foreach ($keyword in $Keywords) { if ([string]::IsNullOrEmpty($keyword)) { continue } $searchFrom = 0 while ($searchFrom -le $Text.Length) { $idx = $Text.IndexOf($keyword, $searchFrom, $comparison) if ($idx -lt 0) { break } $ranges.Add(@{ Start = $idx; End = $idx + $keyword.Length }) $searchFrom = $idx + $keyword.Length } } if ($ranges.Count -eq 0) { return $Text } $sorted = $ranges | Sort-Object { $_.Start } $boldYellow = "$([char]27)[1;33m" $reset = "$([char]27)[0m" $sb = [System.Text.StringBuilder]::new() $cursor = 0 foreach ($range in $sorted) { if ($range.Start -lt $cursor) { continue } [void]$sb.Append($Text.Substring($cursor, $range.Start - $cursor)) [void]$sb.Append($boldYellow).Append($Text.Substring($range.Start, $range.End - $range.Start)).Append($reset) $cursor = $range.End } [void]$sb.Append($Text.Substring($cursor)) return $sb.ToString() } #endregion function Find-ObjectByName { <# .SYNOPSIS Filters pipeline objects by matching a property (or the object's own string form) against one or more keywords, combined with AND / OR / NOT logic. .DESCRIPTION Accepts objects via the pipeline and keeps only the ones whose target value matches the given -SearchTerms. Keywords can be supplied as separate array elements ("test","and","file") or as a single natural-language string ("test AND file", "chrome OR firefox NOT helper"). A keyword with no operator in front of it defaults to OR against everything matched so far. Matching itself is controlled by -Mode: Contains (default, wildcard substring), StartsWith, or Fuzzy (characters of the keyword must appear in order, not necessarily together - e.g. "fndobj" matches "FindObject"). By default the object's 'Name' property is compared; use -Property to target a different property, or -AsString to match against the object's own ToString() representation (useful for plain strings or objects with no natural name). .PARAMETER SearchTerms Keywords to search for, optionally combined with 'and', 'or', or 'not'. Also accepts a single string such as "chrome OR firefox NOT helper". .PARAMETER InputObject The object(s) passed from the pipeline. .PARAMETER Mode How each keyword is compared against the value: Contains (default), StartsWith, or Fuzzy. Defaults to the value from Get-FindObjectConfig. .PARAMETER Property The property to match against. Defaults to 'Name' (or whatever Set-FindObjectConfig -Property has set). .PARAMETER AsString Match against the object's own string representation instead of a named property. .PARAMETER CaseSensitive Perform a case-sensitive comparison. Off by default. .PARAMETER Highlight Print a colorized preview of each match to the host (in addition to passing the object through the pipeline unchanged), with matched text highlighted. .EXAMPLE Get-Command -Module MicrosoftTeams | Find-ObjectByName "get", "or", "policy" .EXAMPLE Get-Process | Find-ObjectByName "chrome OR firefox NOT helper" .EXAMPLE Get-Process | Find-ObjectByName "crome" -Mode Fuzzy # Matches "chrome.exe" even though "crome" isn't a literal substring. .EXAMPLE "apple pie", "banana bread" | Find-ObjectByName "pie" -AsString # Matches against the strings themselves rather than a .Name property. .NOTES - Objects without the target property (or with an empty/whitespace value) are skipped. - Comparison is case-insensitive unless -CaseSensitive is specified. #> [CmdletBinding()] [Alias('fob')] param( [Parameter(Mandatory = $true, Position = 0)] [Alias('FilterString')] [string[]]$SearchTerms, [Parameter(ValueFromPipeline = $true)] [psobject]$InputObject, [ValidateSet('Contains', 'StartsWith', 'Fuzzy')] [string]$Mode, [string]$Property, [switch]$AsString, [switch]$CaseSensitive, [switch]$Highlight ) begin { if (-not $PSBoundParameters.ContainsKey('Mode')) { $Mode = $script:FindObjectConfig.Mode } if (-not $PSBoundParameters.ContainsKey('Property')) { $Property = $script:FindObjectConfig.Property } if (-not $PSBoundParameters.ContainsKey('CaseSensitive')) { $CaseSensitive = [bool]$script:FindObjectConfig.CaseSensitive } if (-not $PSBoundParameters.ContainsKey('Highlight')) { $Highlight = [bool]$script:FindObjectConfig.Highlight } $clauses = ConvertTo-FindObjectClauses -SearchTerms $SearchTerms # Pre-calculate wildcard patterns once so the process block (called once per pipeline object) never # rebuilds strings on the hot path - the one genuinely useful idea shared by every "Bolt" perf PR. foreach ($clause in $clauses) { $clause.Pattern = if ($Mode -eq 'StartsWith') { "$($clause.Keyword)*" } else { "*$($clause.Keyword)*" } } $clauseCount = $clauses.Count $positiveKeywords = @($clauses | Where-Object { $_.Operator -ne 'NOT' } | ForEach-Object { $_.Keyword }) $isFuzzy = $Mode -eq 'Fuzzy' $useCaseSensitiveLike = [bool]$CaseSensitive Write-Verbose "Find-ObjectByName initialized. Mode: $Mode, Property: $Property, Clauses: $($clauses.Count)" } process { if ($null -eq $InputObject) { return } if ($AsString) { $value = [string]$InputObject } else { $propertyValue = $InputObject.$Property if ($null -eq $propertyValue) { if ($VerbosePreference -ne 'SilentlyContinue') { Write-Verbose "Input object type '$($InputObject.GetType().FullName)' does not have a '$Property' property or it is null. Skipping." } return } $value = if ($propertyValue -is [string]) { $propertyValue } else { [string]$propertyValue } } if ([string]::IsNullOrWhiteSpace($value)) { return } # Inlined rather than routed through a helper function: calling a function per clause per pipeline # object is the single biggest cost in a tight PowerShell loop - the thing every "Bolt" PR was # (mostly ineffectually) trying to work around. Fuzzy mode still calls out since it's opt-in and # not the hot path. $firstClause = $clauses[0] $result = if ($isFuzzy) { Test-FindObjectFuzzyMatch -Text $value -Pattern $firstClause.Keyword -CaseSensitive $useCaseSensitiveLike } elseif ($useCaseSensitiveLike) { $value -clike $firstClause.Pattern } else { $value -like $firstClause.Pattern } for ($i = 1; $i -lt $clauseCount; $i++) { $clause = $clauses[$i] $operator = $clause.Operator if ($operator -eq 'AND' -and -not $result) { continue } if ($operator -eq 'OR' -and $result) { continue } $clauseMatch = if ($isFuzzy) { Test-FindObjectFuzzyMatch -Text $value -Pattern $clause.Keyword -CaseSensitive $useCaseSensitiveLike } elseif ($useCaseSensitiveLike) { $value -clike $clause.Pattern } else { $value -like $clause.Pattern } switch ($operator) { 'AND' { $result = $result -and $clauseMatch } 'OR' { $result = $result -or $clauseMatch } 'NOT' { $result = $result -and (-not $clauseMatch) } } } if (-not $result) { return } if ($Highlight -and $Mode -ne 'Fuzzy') { $rendered = Format-FindObjectHighlight -Text $value -Keywords $positiveKeywords -CaseSensitive $CaseSensitive Write-Host " * $rendered" } Write-Output $InputObject } } # Create aliases New-Alias -Name fob -Value Find-ObjectByName -Force # Export the functions and aliases Export-ModuleMember -Function Find-ObjectByName, Get-FindObjectConfig, Set-FindObjectConfig -Alias fob |